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
This commit is contained in:
2026-10-26 14:06:09 +01:00
parent 3b22134292
commit 6844d362c4
4 changed files with 821 additions and 0 deletions

View File

@@ -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`.

401
login_gate_test.go Normal file
View File

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

224
password_changed_test.go Normal file
View File

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

194
reset_two_factor_test.go Normal file
View File

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