Files
sm-user-plugin/reset_two_factor_test.go
Jakub Zych 6844d362c4 test: login gates, password-changed sources and the reset action
- TestLoginGates: registry order, first result or error wins, nil gates,
  concurrent Add and Check, CheckLoginGates without a registry
- TestLoginGateOrder: refused first factors never reach a gate, rehash
  before a challenge, restore and reactivate mail only after the gates,
  gate answers pass through, refresh never asks a gate
- TestPasswordChangedSources: reset, change and admin set fire once inside
  the write's transaction; rehash and registration fire none
- TestResetTwoFactorAction: offered only with a service and a factor,
  danger, permission, transaction and rollback
2026-10-26 14:06:09 +01:00

195 lines
6.9 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/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.
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("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)
}
})
}