From 6844d362c4d15bf70b6ccd29d2c5cd913457251f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 14:06:09 +0100 Subject: [PATCH] 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 --- README.md | 2 + login_gate_test.go | 401 +++++++++++++++++++++++++++++++++++++++ password_changed_test.go | 224 ++++++++++++++++++++++ reset_two_factor_test.go | 194 +++++++++++++++++++ 4 files changed, 821 insertions(+) create mode 100644 login_gate_test.go create mode 100644 password_changed_test.go create mode 100644 reset_two_factor_test.go diff --git a/README.md b/README.md index 5326e94..d277562 100644 --- a/README.md +++ b/README.md @@ -353,4 +353,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, the URL fallback when the thumbnail fails, and that deleting an organisation removes its avatar and logo attachments, the rows and, once the delete commits, the blobs and thumbnails. +The login-gate seam, the password-change hook and the Reset 2FA slot have their own tests. `TestLoginGates` (in `-short`, clean under `-race`) pins the registry: a nil or empty registry allows, gates run in the order they were added, the first result or error wins and stops the chain, nil gates are ignored, and `controllers.CheckLoginGates` allows when no registry is published. `TestLoginGateOrder` pins where `POST /login` asks the gates: refused first factors (wrong password, unknown email, not activated, banned) never reach them; the gate sees the attempt with the body already read; the bcrypt rehash happens before a challenge and fires no password-change event; a challenged deactivated account stays deactivated with no mail and no login event, while an allowed one is restored with one reactivate mail and one login event; a gate's status and body pass through; `/refresh` never asks a gate. `TestPasswordChangedSources` checks that the reset, the user's own change and an administrator setting a password each fire one `AccessRevokedEvent` with `PasswordChanged()` true inside the write's transaction (a listener's write through `ev.Tx` commits with it, a listener error rolls both back), and that the rehash and a registration fire none. `TestResetTwoFactorAction` covers the `reset_two_factor` record action: not offered without a published `classes.TwoFactorAdmin`, offered with the danger confirmation when one reports a second factor, hidden when it reports none, refused without `golem15.users.reset_two_factor`, `Run` refused outside the action's transaction, `Reset` committed with the action and rolled back by its error. The plan 01 smoke tests (`TestLoginGatesSmoke`, `TestLoginGateSmoke`, `TestPasswordChangedEventSmoke`, `TestResetTwoFactorSlotSmoke`) stay. The Postgres tests need Docker. + The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/login_gate_test.go b/login_gate_test.go new file mode 100644 index 0000000..b2770e5 --- /dev/null +++ b/login_gate_test.go @@ -0,0 +1,401 @@ +package user + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// TestLoginGates pins the registry of login gates (D-02) without a +// database: a nil or empty registry allows, gates run in the order they were +// added, the first result wins and stops the chain, an error stops the chain +// and is returned, nil gates are ignored, and Add and Check are safe for +// concurrent use (run with -race). CheckLoginGates allows a sign-in when no +// app or no registry is published. +func TestLoginGates(t *testing.T) { + ctx := context.Background() + attempt := classes.LoginAttempt{Source: classes.LoginSourcePassword} + + t.Run("nil_and_empty_allow", func(t *testing.T) { + var nilGates *classes.LoginGates + if res, err := nilGates.Check(ctx, attempt); res != nil || err != nil { + t.Fatalf("nil registry = %v, %v", res, err) + } + nilGates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { + t.Fatal("a gate added to a nil registry ran") + return nil, nil + })) + if res, err := classes.NewLoginGates().Check(ctx, attempt); res != nil || err != nil { + t.Fatalf("empty registry = %v, %v", res, err) + } + g := classes.NewLoginGates() + g.Add(nil) + if res, err := g.Check(ctx, attempt); res != nil || err != nil { + t.Fatalf("registry with a nil gate = %v, %v", res, err) + } + }) + + t.Run("order_and_first_result_wins", func(t *testing.T) { + var order []string + gate := func(name string, res *classes.LoginGateResult) classes.LoginGate { + return gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) { + if a.Source != classes.LoginSourcePassword { + t.Fatalf("gate %s saw source %q", name, a.Source) + } + order = append(order, name) + return res, nil + }) + } + first := &classes.LoginGateResult{Status: http.StatusOK, Body: map[string]any{"two_factor_required": true}} + second := &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "later"}} + g := classes.NewLoginGates() + g.Add(gate("a", nil)) + g.Add(gate("b", nil)) + g.Add(gate("c", first)) + g.Add(gate("d", second)) + res, err := g.Check(ctx, attempt) + if err != nil || res != first { + t.Fatalf("Check = %v, %v; want the first result", res, err) + } + if fmt.Sprint(order) != "[a b c]" { + t.Fatalf("gates ran %v, want [a b c]", order) + } + order = nil + all := classes.NewLoginGates() + all.Add(gate("x", nil)) + all.Add(gate("y", nil)) + if res, err := all.Check(ctx, attempt); res != nil || err != nil { + t.Fatalf("allowing gates = %v, %v", res, err) + } + if fmt.Sprint(order) != "[x y]" { + t.Fatalf("allowing gates ran %v, want [x y]", order) + } + }) + + t.Run("error_stops_the_chain", func(t *testing.T) { + boom := errors.New("challenge store down") + later := 0 + g := classes.NewLoginGates() + g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, nil })) + g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { + // A gate that answers both a result and an error fails closed. + return &classes.LoginGateResult{Body: map[string]any{"ignored": true}}, boom + })) + g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { + later++ + return nil, nil + })) + res, err := g.Check(ctx, attempt) + if res != nil || !errors.Is(err, boom) { + t.Fatalf("erroring gate = %v, %v; want nil and the error", res, err) + } + if later != 0 { + t.Fatal("a gate after the erroring one ran") + } + }) + + t.Run("concurrent_add_and_check", func(t *testing.T) { + g := classes.NewLoginGates() + var calls atomic.Int64 + allow := gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { + calls.Add(1) + return nil, nil + }) + var wg sync.WaitGroup + for i := range 64 { + wg.Add(1) + go func() { + defer wg.Done() + if i%2 == 0 { + g.Add(allow) + return + } + if res, err := g.Check(ctx, attempt); res != nil || err != nil { + t.Errorf("concurrent Check = %v, %v", res, err) + } + }() + } + wg.Wait() + calls.Store(0) + if res, err := g.Check(ctx, attempt); res != nil || err != nil { + t.Fatalf("Check after the adds = %v, %v", res, err) + } + if calls.Load() != 32 { + t.Fatalf("Check ran %d gates, want the 32 added", calls.Load()) + } + }) + + t.Run("check_login_gates_lookup", func(t *testing.T) { + if res, err := controllers.CheckLoginGates(ctx, nil, attempt); res != nil || err != nil { + t.Fatalf("no app = %v, %v", res, err) + } + app := backpack.New(sessionConfig(t)) + if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != nil || err != nil { + t.Fatalf("no published registry = %v, %v", res, err) + } + gates := classes.NewLoginGates() + if err := app.Publish(gates); err != nil { + t.Fatal(err) + } + want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}} + // A gate added after publishing is seen: the registry is looked up + // on every call. + gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return want, nil })) + if res, err := controllers.CheckLoginGates(ctx, app, attempt); res != want || err != nil { + t.Fatalf("published registry = %v, %v", res, err) + } + }) +} + +// countingGate counts its calls, records the attempts it saw and answers +// with result (nil allows). +type countingGate struct { + mu sync.Mutex + calls int + seen []classes.LoginAttempt + result *classes.LoginGateResult +} + +func (g *countingGate) CheckLogin(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) { + g.mu.Lock() + defer g.mu.Unlock() + g.calls++ + snapshot := a + if a.User != nil { + user := *a.User + snapshot.User = &user + } + g.seen = append(g.seen, snapshot) + return g.result, nil +} + +func (g *countingGate) count() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.calls +} + +// gatedEnv boots the plugin through the admin harness with one counting +// gate added to the registry its Register published. +func gatedEnv(t *testing.T, result *classes.LoginGateResult) (*adminEnv, *countingGate) { + t.Helper() + env := newAdminEnv(t) + gates, ok := env.app.Lookup[*classes.LoginGates]() + if !ok || gates == nil { + t.Fatal("the plugin's Register published no *classes.LoginGates") + } + gate := &countingGate{result: result} + gates.Add(gate) + return env, gate +} + +// TestLoginGateOrder pins where POST /login consults the gates (D-02, D-18 +// A10): after the ban check, the password, the activation and the bcrypt +// rehash, and before the restore of a deactivated account, its reactivate +// mail, the mint and the login event. A gate's answer passes through with +// its status and body; JWT refresh never consults a gate. +func TestLoginGateOrder(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + challenge := &classes.LoginGateResult{Body: map[string]any{ + "two_factor_required": true, + "challenge_token": "c", + "available_methods": []string{"totp"}, + "expires_at": "2026-10-26T12:00:00+00:00", + }} + + t.Run("first_factor_failures_never_reach_the_gate", func(t *testing.T) { + env, gate := gatedEnv(t, challenge) + user := env.seedUser(t, "lgo-wrong", nil) + if status, _ := env.userLogin(t, user.Email, "wrong-password"); status != http.StatusUnauthorized { + t.Fatalf("wrong password = %d", status) + } + if status, _ := env.userLogin(t, "lgo-nobody-"+adminStamp()+"@example.test", "correct-horse"); status != http.StatusUnauthorized { + t.Fatalf("unknown email = %d", status) + } + inactive := env.seedUser(t, "lgo-inactive", func(u *models.User) { u.IsActivated = false }) + if status, _ := env.userLogin(t, inactive.Email, "correct-horse"); status != http.StatusUnauthorized { + t.Fatalf("not activated = %d", status) + } + banned := env.seedUser(t, "lgo-banned", nil) + env.seedThrottle(t, banned, "", true, nil) + if status, _ := env.userLogin(t, banned.Email, "correct-horse"); status != http.StatusUnauthorized { + t.Fatalf("banned = %d", status) + } + if gate.count() != 0 { + t.Fatalf("the gate ran %d times for refused first factors", gate.count()) + } + }) + + t.Run("gate_sees_the_attempt", func(t *testing.T) { + env, gate := gatedEnv(t, challenge) + user := env.seedUser(t, "lgo-attempt", nil) + rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{ + "email": user.Email, "password": "correct-horse", "trusted_device_token": "td-attempt", + }) + if rec.Code != http.StatusOK { + t.Fatalf("login = %d %s", rec.Code, rec.Body.String()) + } + if gate.count() != 1 { + t.Fatalf("gate calls = %d, want 1", gate.count()) + } + seen := gate.seen[0] + if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.User.ID != user.ID || seen.Request == nil { + t.Fatalf("attempt = %+v", seen) + } + if seen.Fields["trusted_device_token"] != "td-attempt" || seen.Fields["email"] != user.Email { + t.Fatalf("attempt fields = %#v", seen.Fields) + } + }) + + t.Run("rehash_before_a_challenge", func(t *testing.T) { + env, gate := gatedEnv(t, challenge) + probe := &passwordProbe{} + env.app.Events.Listen("test.login-gate-order.rehash", func(_ context.Context, ev *classes.AccessRevokedEvent) error { + probe.mu.Lock() + probe.seen = append(probe.seen, *ev) + probe.mu.Unlock() + return nil + }) + user := env.seedUser(t, "lgo-rehash", nil) + if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil { + t.Fatal(err) + } + rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"}) + if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true { + t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String()) + } + if gate.count() != 1 { + t.Fatalf("gate calls = %d", gate.count()) + } + stored := env.reload(t, user.ID).Password + if stored == user.Password || bouncer.NeedsRehash(stored, 11) || !bouncer.CheckPassword(stored, "correct-horse") { + t.Fatal("a challenged login did not rehash the password at the higher cost") + } + if seen := probe.take(); len(seen) != 0 { + t.Fatalf("the rehash fired %+v", seen) + } + }) + + t.Run("challenged_soft_deleted_stays_deleted", func(t *testing.T) { + env, gate := gatedEnv(t, challenge) + events := listenLoginEvents(env.app, "test.login-gate-order.soft") + user := env.seedUser(t, "lgo-soft", nil) + if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"}) + body := decodeBody(t, rec.Body.Bytes()) + if rec.Code != http.StatusOK || body["two_factor_required"] != true || body["token"] != nil { + t.Fatalf("challenged login = %d %s", rec.Code, rec.Body.String()) + } + if !gate.seen[0].User.DeletedAt.Valid { + t.Fatal("the gate saw a restored account; the restore must wait for the gates") + } + if !env.reload(t, user.ID).DeletedAt.Valid { + t.Fatal("a challenged login restored the deactivated account") + } + if mails := env.mails(user.Email); len(mails) != 0 { + t.Fatalf("a challenged login sent %d mails", len(mails)) + } + if events.count() != 0 { + t.Fatalf("a challenged login fired %d login events", events.count()) + } + }) + + t.Run("allowed_soft_deleted_restored_once", func(t *testing.T) { + env, gate := gatedEnv(t, nil) + events := listenLoginEvents(env.app, "test.login-gate-order.allowed") + user := env.seedUser(t, "lgo-allowed", nil) + if err := env.gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + status, token := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK || token == "" { + t.Fatalf("allowed login = %d", status) + } + if gate.count() != 1 { + t.Fatalf("gate calls = %d", gate.count()) + } + if env.reload(t, user.ID).DeletedAt.Valid { + t.Fatal("an allowed login did not restore the account") + } + if mails := env.mails(user.Email); len(mails) != 1 { + t.Fatalf("reactivate mails = %d, want 1", len(mails)) + } + if events.count() != 1 || events.got[0].Source != classes.LoginSourcePassword || events.got[0].User.ID != user.ID { + t.Fatalf("login events = %+v", events.got) + } + }) + + t.Run("gate_answer_passes_through", func(t *testing.T) { + for _, c := range []struct { + name string + result *classes.LoginGateResult + status int + body string + }{ + {"status_zero_is_200", &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}}, http.StatusOK, `{"two_factor_required":true}`}, + {"refusal", &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, http.StatusUnauthorized, `{"error":"Invalid or expired challenge."}`}, + {"other_status", &classes.LoginGateResult{Status: http.StatusTooManyRequests, Body: map[string]any{"error": "slow down", "retry": 3}}, http.StatusTooManyRequests, `{"error":"slow down","retry":3}`}, + } { + t.Run(c.name, func(t *testing.T) { + env, _ := gatedEnv(t, c.result) + events := listenLoginEvents(env.app, "test.login-gate-order."+c.name) + user := env.seedUser(t, "lgo-"+c.name, nil) + rec := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": user.Email, "password": "correct-horse"}) + if rec.Code != c.status || rec.Body.String() != c.body { + t.Fatalf("answer = %d %s, want %d %s", rec.Code, rec.Body.String(), c.status, c.body) + } + if events.count() != 0 { + t.Fatal("a gate answer fired a login event") + } + if env.reload(t, user.ID).LastSeen != nil { + t.Fatal("a gate answer stamped last_seen") + } + }) + } + }) + + t.Run("refresh_never_consults_a_gate", func(t *testing.T) { + env, gate := gatedEnv(t, nil) + user := env.seedUser(t, "lgo-refresh", nil) + status, token := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK { + t.Fatalf("login = %d", status) + } + if gate.count() != 1 { + t.Fatalf("gate calls after login = %d", gate.count()) + } + // From now on the gate would challenge: refresh must not ask it. + gate.mu.Lock() + gate.result = challenge + gate.mu.Unlock() + for range 2 { + rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", token, nil) + var body struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" { + t.Fatalf("refresh body %s: %v", rec.Body.String(), err) + } + token = body.Token + } + if gate.count() != 1 { + t.Fatalf("refresh consulted the gate: %d calls", gate.count()) + } + }) +} diff --git a/password_changed_test.go b/password_changed_test.go new file mode 100644 index 0000000..a89598c --- /dev/null +++ b/password_changed_test.go @@ -0,0 +1,224 @@ +package user + +import ( + "context" + "errors" + "net/http" + "strconv" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "gorm.io/gorm" +) + +// passwordSource is what a D-07 listener observed for one event: the event +// itself, whether the stored hash it read through ev.Tx already checks +// against the new password, and the marker it wrote through ev.Tx. +type passwordSource struct { + ev classes.AccessRevokedEvent + newHash bool +} + +// passwordListener is a D-07 listener: for every password-change event it +// reads the stored hash through ev.Tx, writes a marker row through ev.Tx +// (jwt_blacklist, jti = marker) and then fails when fail is set. +type passwordListener struct { + mu sync.Mutex + seen []passwordSource + fail bool + plain string + marker string +} + +func (l *passwordListener) arm(plain, marker string, fail bool) { + l.mu.Lock() + defer l.mu.Unlock() + l.seen, l.plain, l.marker, l.fail = nil, plain, marker, fail +} + +func (l *passwordListener) take() []passwordSource { + l.mu.Lock() + defer l.mu.Unlock() + out := l.seen + l.seen = nil + return out +} + +func (l *passwordListener) handle(ctx context.Context, ev *classes.AccessRevokedEvent) error { + l.mu.Lock() + defer l.mu.Unlock() + if !ev.PasswordChanged() { + return nil + } + got := passwordSource{ev: *ev} + if ev.Tx != nil && len(ev.UserIDs) == 1 { + var hash string + if err := ev.Tx.WithContext(ctx).Raw(`SELECT password FROM users WHERE id = ?`, ev.UserIDs[0]).Scan(&hash).Error; err != nil { + return err + } + got.newHash = l.plain != "" && bouncer.CheckPassword(hash, l.plain) + if l.marker != "" { + if err := ev.Tx.WithContext(ctx).Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, NOW(), NOW())`, l.marker).Error; err != nil { + return err + } + } + } + l.seen = append(l.seen, got) + if l.fail { + return errors.New("listener refused") + } + return nil +} + +func markerExists(t *testing.T, gdb *gorm.DB, marker string) bool { + t.Helper() + var n int64 + if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, marker).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n == 1 +} + +// TestPasswordChangedSources pins the D-07 hook from every source. A +// password reset, the user's own change and an administrator setting a +// password each fire exactly one AccessRevokedEvent with its own reason and +// PasswordChanged() true, inside the write's transaction: a marker the +// listener writes through ev.Tx commits with the write, and a listener error +// rolls back the write and the marker. Reset and change write the hash +// before they fire, so the listener reads the new hash through ev.Tx; the +// administrator's save fires from FormBeforeUpdate, before cabana writes +// the record in the same transaction. The bcrypt rehash on login and a new +// registration fire no password-change event. +func TestPasswordChangedSources(t *testing.T) { + if testing.Short() { + t.Skip("Postgres") + } + env := newAdminEnv(t) + listener := &passwordListener{} + env.app.Events.Listen("test.password_changed_sources", listener.handle) + admin := env.superAdmin(t) + ctx := context.Background() + session := func(user *models.User) string { + return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pcs-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour)) + } + reset := func(t *testing.T, user *models.User, password string) int { + t.Helper() + code, err := classes.IssueResetCode(ctx, env.gdb, user) + if err != nil { + t.Fatal(err) + } + packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code + return env.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{ + "code": packed, "password": password, "password_confirmation": password, + }).Code + } + change := func(t *testing.T, user *models.User, password string) int { + t.Helper() + return env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{ + "current_password": "correct-horse", "password": password, "password_confirmation": password, + }).Code + } + adminSet := func(t *testing.T, user *models.User, password string) int { + t.Helper() + return env.call(t, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{ + "name": user.Name, "email": user.Email, "password": password, "password_confirmation": password, + }).Code + } + sources := []struct { + name string + reason string + write func(*testing.T, *models.User, string) int + mutate func(*models.User) + newHash bool + }{ + {"reset", classes.AccessRevokedPasswordReset, reset, nil, true}, + {"change", classes.AccessRevokedPasswordChanged, change, func(u *models.User) { u.HasSelfSetPassword = true }, true}, + {"admin_set", classes.AccessRevokedPasswordSetByAdmin, adminSet, nil, false}, + } + + for _, src := range sources { + t.Run(src.name+"_fires_once_in_the_transaction", func(t *testing.T) { + user := env.seedUser(t, "pcs-"+src.name, src.mutate) + plain := "pcs-new-" + src.name + "-password" + marker := "pcs-marker-" + src.name + "-" + adminStamp() + listener.arm(plain, marker, false) + if status := src.write(t, user, plain); status != http.StatusOK { + t.Fatalf("%s = %d", src.name, status) + } + seen := listener.take() + if len(seen) != 1 { + t.Fatalf("%s fired %d password-change events, want 1", src.name, len(seen)) + } + ev := seen[0].ev + if ev.Reason != src.reason || !ev.PasswordChanged() || ev.Tx == nil || len(ev.UserIDs) != 1 || ev.UserIDs[0] != user.ID { + t.Fatalf("event = %+v", ev) + } + if ev.Before.IsZero() || time.Since(ev.Before) > time.Minute { + t.Fatalf("event cutoff = %s", ev.Before) + } + if seen[0].newHash != src.newHash { + t.Fatalf("listener read the new hash through ev.Tx = %v, want %v", seen[0].newHash, src.newHash) + } + if !markerExists(t, env.gdb, marker) { + t.Fatal("the listener's write through ev.Tx did not commit with the password write") + } + if !bouncer.CheckPassword(env.reload(t, user.ID).Password, plain) { + t.Fatal("the new password was not stored") + } + }) + + t.Run(src.name+"_listener_error_rolls_back", func(t *testing.T) { + user := env.seedUser(t, "pcs-rb-"+src.name, src.mutate) + marker := "pcs-rb-marker-" + src.name + "-" + adminStamp() + listener.arm("pcs-never-set", marker, true) + if status := src.write(t, user, "pcs-never-set"); status == http.StatusOK { + t.Fatalf("%s with a failing listener answered 200", src.name) + } + if len(listener.take()) != 1 { + t.Fatal("the failing listener did not run exactly once") + } + if stored := env.reload(t, user.ID).Password; stored != user.Password { + t.Fatalf("%s: the stored hash changed although the listener failed", src.name) + } + if markerExists(t, env.gdb, marker) { + t.Fatalf("%s: the listener's write survived the rollback", src.name) + } + }) + } + + t.Run("rehash_fires_none", func(t *testing.T) { + user := env.seedUser(t, "pcs-rehash", nil) + if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) }) + listener.arm("", "", false) + if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK { + t.Fatalf("login = %d", status) + } + if bouncer.NeedsRehash(env.reload(t, user.ID).Password, 11) { + t.Fatal("the login did not rehash") + } + if seen := listener.take(); len(seen) != 0 { + t.Fatalf("the rehash fired %d password-change events", len(seen)) + } + }) + + t.Run("register_fires_none", func(t *testing.T) { + if err := env.app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil { + t.Fatal(err) + } + listener.arm("", "", false) + email := "pcs-register-" + adminStamp() + "@example.test" + env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/register", "", map[string]string{ + "email": email, "password": "correct-horse", "name": "Registrant", + }) + if seen := listener.take(); len(seen) != 0 { + t.Fatalf("a registration fired %d password-change events", len(seen)) + } + }) +} diff --git a/reset_two_factor_test.go b/reset_two_factor_test.go new file mode 100644 index 0000000..62f6ac4 --- /dev/null +++ b/reset_two_factor_test.go @@ -0,0 +1,194 @@ +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) + } + }) +}