Files
sm-user-plugin/reset_two_factor_test.go
Jakub Zych 20e7b175f8 fix: Reset 2FA keeps the privileged-group guard
Resetting the second factors of a privileged-group member now needs
golem15.users.manage_privileged_groups, as changing their password or
deleting them does; without it the action answers 403 before Reset.
2026-10-26 16:04:11 +01:00

243 lines
9.3 KiB
Go

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)
}
})
}