package user import ( "context" "errors" "net/http" "slices" "sync" "testing" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // scriptedTwoFactorAdmin is a classes.TwoFactorAdmin whose answers a test // sets: has is what HasTwoFactor reports; Reset renames the user through // the transaction it is given (to prove which transaction that is) and then // returns resetErr. type scriptedTwoFactorAdmin struct { mu sync.Mutex has bool resetErr error resets []uint } func (s *scriptedTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) { if tx == nil { return false, errors.New("no handle") } s.mu.Lock() defer s.mu.Unlock() return s.has, nil } func (s *scriptedTwoFactorAdmin) Reset(ctx context.Context, tx *gorm.DB, user *models.User) error { s.mu.Lock() defer s.mu.Unlock() s.resets = append(s.resets, user.ID) if err := tx.WithContext(ctx).Exec(`UPDATE users SET name = 'reset-marker' WHERE id = ?`, user.ID).Error; err != nil { return err } return s.resetErr } func (s *scriptedTwoFactorAdmin) calls() []uint { s.mu.Lock() defer s.mu.Unlock() return slices.Clone(s.resets) } // resetAction returns the Users controller's reset_two_factor record action // on app. func resetAction(t *testing.T, app *backpack.App) pact.AdminRecordAction { t.Helper() ctl := adminControllersOn(func() *backpack.App { return app })["golem15.user.users"] for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() { if action.Name == "reset_two_factor" { return action } } t.Fatal("the Users controller has no reset_two_factor record action") return pact.AdminRecordAction{} } // TestResetTwoFactorAction pins the D-15 record action on the Users // screen: never offered without a published classes.TwoFactorAdmin; // offered with the danger confirmation when one is published and reports a // second factor; hidden when it reports none; refused without // golem15.users.reset_two_factor; Run needs the action's transaction, runs // Reset in it, and Reset's error rolls the transaction back. A member of a // privileged group keeps the takeover guard: without // golem15.users.manage_privileged_groups Run answers 403 with the guard's // message before Reset. func TestResetTwoFactorAction(t *testing.T) { if testing.Short() { t.Skip("Postgres") } t.Run("not_offered_without_a_service", func(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers, permResetTwoFactor) user := env.seedUser(t, "rta-none", nil) if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") { t.Fatal("offered without a published TwoFactorAdmin") } if applies, err := resetAction(t, env.app).Applies(context.Background(), user); applies || err != nil { t.Fatalf("Applies without a service = %v, %v", applies, err) } if rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{}); rec.Code == http.StatusOK { t.Fatalf("ran without a service: %s", rec.Body.String()) } if env.reload(t, user.ID).Name == nil || *env.reload(t, user.ID).Name != "rta-none" { t.Fatal("the user changed although nothing ran") } }) t.Run("offered_with_danger_and_hidden_without_a_factor", func(t *testing.T) { env := newAdminEnv(t) svc := &scriptedTwoFactorAdmin{has: true} if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil { t.Fatal(err) } token := env.admin(t, permAccessUsers, permResetTwoFactor) user := env.seedUser(t, "rta-offer", nil) action := env.recordAction(t, token, user.ID, "reset_two_factor") if action == nil { t.Fatalf("not offered: %v", env.offered(t, token, user.ID)) } if action["danger"] != true || action["label"] != "Reset 2FA" || action["confirm"] == nil || action["confirm"] == "" { t.Fatalf("offered action = %v", action) } svc.mu.Lock() svc.has = false svc.mu.Unlock() if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") { t.Fatal("offered for a user without a second factor") } // A superuser holds every permission and sees it too. svc.mu.Lock() svc.has = true svc.mu.Unlock() if !slices.Contains(env.offered(t, env.superAdmin(t), user.ID), "reset_two_factor") { t.Fatal("not offered to a superuser") } }) t.Run("permission_required", func(t *testing.T) { env := newAdminEnv(t) svc := &scriptedTwoFactorAdmin{has: true} if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil { t.Fatal(err) } plain := env.admin(t, permAccessUsers) user := env.seedUser(t, "rta-perm", nil) if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") { t.Fatal("offered without golem15.users.reset_two_factor") } env.expect(t, http.StatusForbidden, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), plain, map[string]any{}) if len(svc.calls()) != 0 { t.Fatal("a refused administrator reached Reset") } }) t.Run("run_needs_the_transaction", func(t *testing.T) { env := newAdminEnv(t) svc := &scriptedTwoFactorAdmin{has: true} if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil { t.Fatal(err) } user := env.seedUser(t, "rta-notx", nil) _, err := resetAction(t, env.app).Run(context.Background(), pact.AdminRecordActionInput{Record: user}) wantPlainError(t, "reset_two_factor without a transaction", err) if len(svc.calls()) != 0 { t.Fatal("Reset ran without the action's transaction") } }) t.Run("run_commits_with_the_action", func(t *testing.T) { env := newAdminEnv(t) svc := &scriptedTwoFactorAdmin{has: true} if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil { t.Fatal(err) } token := env.admin(t, permAccessUsers, permResetTwoFactor) user := env.seedUser(t, "rta-ok", nil) env.expect(t, http.StatusOK, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{}) if calls := svc.calls(); len(calls) != 1 || calls[0] != user.ID { t.Fatalf("Reset calls = %v", calls) } if name := env.reload(t, user.ID).Name; name == nil || *name != "reset-marker" { t.Fatalf("Reset's write did not commit: name = %v", name) } }) t.Run("privileged_member_needs_the_privileged_permission", func(t *testing.T) { code := "rta-priv-" + adminStamp() env := newAdminEnv(t, func(cfg *compass.Config) { if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", code}); err != nil { t.Fatal(err) } }) svc := &scriptedTwoFactorAdmin{has: true} if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil { t.Fatal(err) } limited := env.admin(t, permAccessUsers, permResetTwoFactor) manager := env.admin(t, permAccessUsers, permResetTwoFactor, permManagePrivileged) member := env.seedUser(t, "rta-privileged", nil) env.join(t, member, env.seedGroup(t, "Privileged "+code, code)) ordinary := env.seedUser(t, "rta-ordinary", nil) rec := env.expect(t, http.StatusForbidden, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), limited, map[string]any{}) answer := adminDecode(t, rec) const want = "This user belongs to a privileged group. Resetting their two-factor authentication needs the permission to manage privileged groups. Nothing was changed." if answer.Error.Code != "forbidden" || answer.Error.Message != want { t.Fatalf("refusal = %s", rec.Body.String()) } if len(svc.calls()) != 0 { t.Fatalf("a refused reset reached Reset: %v", svc.calls()) } if name := env.reload(t, member.ID).Name; name == nil || *name != "rta-privileged" { t.Fatalf("the refused reset changed the member: name = %v", name) } // An ordinary user stays resettable with the reset permission alone. if !slices.Contains(env.offered(t, limited, ordinary.ID), "reset_two_factor") { t.Fatal("not offered for an ordinary user") } env.expect(t, http.StatusOK, http.MethodPost, userPath(ordinary.ID, "/actions/reset_two_factor"), limited, map[string]any{}) // With the privileged permission the member is offered and reset. if !slices.Contains(env.offered(t, manager, member.ID), "reset_two_factor") { t.Fatal("not offered to an administrator who manages privileged groups") } env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/reset_two_factor"), manager, map[string]any{}) if calls := svc.calls(); len(calls) != 2 || calls[0] != ordinary.ID || calls[1] != member.ID { t.Fatalf("Reset calls = %v", calls) } }) t.Run("run_error_rolls_back", func(t *testing.T) { env := newAdminEnv(t) svc := &scriptedTwoFactorAdmin{has: true, resetErr: errors.New("credential delete failed")} if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil { t.Fatal(err) } token := env.admin(t, permAccessUsers, permResetTwoFactor) user := env.seedUser(t, "rta-rollback", nil) rec := env.call(t, http.MethodPost, userPath(user.ID, "/actions/reset_two_factor"), token, map[string]any{}) if rec.Code != http.StatusInternalServerError { t.Fatalf("failing Reset = %d %s", rec.Code, rec.Body.String()) } if len(svc.calls()) != 1 { t.Fatalf("Reset calls = %v", svc.calls()) } if name := env.reload(t, user.ID).Name; name == nil || *name != "rta-rollback" { t.Fatalf("Reset's write survived its error: name = %v", name) } }) }