package user import ( "context" "encoding/json" "errors" "net/http" "reflect" "strings" "sync" "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/postcard" ) // gateFunc adapts a function to classes.LoginGate. type gateFunc func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) func (f gateFunc) CheckLogin(ctx context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) { return f(ctx, a) } // TestLoginGatesSmoke pins the registry: nil and empty allow, the first // non-nil result or error wins and stops the chain. func TestLoginGatesSmoke(t *testing.T) { ctx := context.Background() var nilGates *classes.LoginGates if res, err := nilGates.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil { t.Fatalf("nil registry = %v, %v", res, err) } empty := classes.NewLoginGates() if res, err := empty.Check(ctx, classes.LoginAttempt{}); res != nil || err != nil { t.Fatalf("empty registry = %v, %v", res, err) } want := &classes.LoginGateResult{Body: map[string]any{"two_factor_required": true}} calls := 0 g := classes.NewLoginGates() g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls++ return nil, nil })) g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls++ return want, nil })) g.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { calls++ return nil, nil })) res, err := g.Check(ctx, classes.LoginAttempt{}) if err != nil || res != want { t.Fatalf("A(nil) then B(result) = %v, %v", res, err) } if calls != 2 { t.Fatalf("gates called %d times, want 2 (the chain stops at the result)", calls) } boom := errors.New("gate failed") later := 0 e := classes.NewLoginGates() e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, boom })) e.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { later++ return nil, nil })) if res, err := e.Check(ctx, classes.LoginAttempt{}); res != nil || !errors.Is(err, boom) { t.Fatalf("erroring gate = %v, %v", res, err) } if later != 0 { t.Fatal("a gate after the erroring one was called") } } // loginEvents records every classes.LoginEvent fired on app. type loginEvents struct { mu sync.Mutex got []classes.LoginEvent } func listenLoginEvents(app *backpack.App, owner string) *loginEvents { rec := &loginEvents{} app.Events.Listen[*classes.LoginEvent](owner, func(_ context.Context, e *classes.LoginEvent) error { rec.mu.Lock() rec.got = append(rec.got, *e) rec.mu.Unlock() return nil }) return rec } func (l *loginEvents) count() int { l.mu.Lock() defer l.mu.Unlock() return len(l.got) } // gateApp is sessionApp with a published login-gate registry (bootPlugin // runs Boot only, so the test publishes what Register would). func gateApp(t *testing.T) (*backpack.App, *classes.LoginGates) { t.Helper() app, _ := sessionApp(t, "0s") gates := classes.NewLoginGates() if err := app.Publish(gates); err != nil { t.Fatal(err) } return app, gates } func decodeBody(t *testing.T, raw []byte) map[string]any { t.Helper() var body map[string]any if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("decode %s: %v", raw, err) } return body } // TestLoginGateSmoke drives POST /login through a registered gate. func TestLoginGateSmoke(t *testing.T) { if testing.Short() { t.Skip("Postgres (testcontainers)") } _, gdb := sessionApp(t, "0s") t.Run("no_gate", func(t *testing.T) { app, _ := gateApp(t) events := listenLoginEvents(app, "test.login-gate.no-gate") user := insertUser(t, gdb, "gate-none@example.test", "correct-horse") rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-none@example.test","password":"correct-horse"}`) if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } body := decodeBody(t, rec.Body.Bytes()) if tok, _ := body["token"].(string); tok == "" { t.Fatalf("no token: %s", rec.Body.String()) } if _, ok := body["user"].(map[string]any); !ok { t.Fatalf("no user: %s", rec.Body.String()) } if events.count() != 1 { t.Fatalf("login events = %d, want 1", events.count()) } if e := events.got[0]; e.Source != classes.LoginSourcePassword || e.User == nil || e.User.ID != user.ID { t.Fatalf("event = %+v", e) } }) challengeBody := map[string]any{ "two_factor_required": true, "challenge_token": strings.Repeat("x", 64), "available_methods": []any{"totp"}, "expires_at": "2026-10-21T12:00:00+00:00", } t.Run("challenge", func(t *testing.T) { app, gates := gateApp(t) events := listenLoginEvents(app, "test.login-gate.challenge") var seen classes.LoginAttempt gates.Add(gateFunc(func(_ context.Context, a classes.LoginAttempt) (*classes.LoginGateResult, error) { seen = a return &classes.LoginGateResult{Status: http.StatusOK, Body: challengeBody}, nil })) insertUser(t, gdb, "gate-challenge@example.test", "correct-horse") rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-challenge@example.test","password":"correct-horse","trusted_device_token":"td-123"}`) if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } body := decodeBody(t, rec.Body.Bytes()) if !reflect.DeepEqual(body, challengeBody) { t.Fatalf("body = %#v", body) } if _, ok := body["token"]; ok { t.Fatal("a challenged login answered a token") } if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.Request == nil { t.Fatalf("attempt = %+v", seen) } if seen.Fields["trusted_device_token"] != "td-123" { t.Fatalf("fields = %#v", seen.Fields) } if events.count() != 0 { t.Fatalf("a challenged login fired %d login events", events.count()) } }) t.Run("refusal", func(t *testing.T) { app, gates := gateApp(t) gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, nil })) insertUser(t, gdb, "gate-refusal@example.test", "correct-horse") rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-refusal@example.test","password":"correct-horse"}`) if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` { t.Fatalf("refusal = %d %s", rec.Code, rec.Body.String()) } }) t.Run("gate_error", func(t *testing.T) { app, gates := gateApp(t) events := listenLoginEvents(app, "test.login-gate.error") gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return nil, errors.New("challenge store down") })) insertUser(t, gdb, "gate-error@example.test", "correct-horse") rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-error@example.test","password":"correct-horse"}`) if rec.Code != http.StatusInternalServerError { t.Fatalf("gate error = %d %s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), `"token"`) { t.Fatalf("gate error answered a token: %s", rec.Body.String()) } if events.count() != 0 { t.Fatal("a failed gate fired a login event") } }) t.Run("soft_deleted_stays", func(t *testing.T) { app, gates := gateApp(t) gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) { return &classes.LoginGateResult{Body: challengeBody}, nil })) mail := &captureMail{} if err := app.Publish[postcard.Mailer](mail); err != nil { t.Fatal(err) } user := insertUser(t, gdb, "gate-soft@example.test", "correct-horse") if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`) if rec.Code != http.StatusOK || decodeBody(t, rec.Body.Bytes())["two_factor_required"] != true { t.Fatalf("challenge = %d %s", rec.Code, rec.Body.String()) } var still models.User if err := gdb.Unscoped().First(&still, user.ID).Error; err != nil { t.Fatal(err) } if !still.DeletedAt.Valid { t.Fatal("a challenged login restored the deactivated account") } if len(mail.got) != 0 { t.Fatalf("a challenged login sent %v", mail.got) } // Without a gate the same login restores the account, as before. plain, _ := gateApp(t) plainMail := &captureMail{} if err := plain.Publish[postcard.Mailer](plainMail); err != nil { t.Fatal(err) } rec = postJSON(t, controllers.Login(plain), "/_user/api/v1/login", `{"email":"gate-soft@example.test","password":"correct-horse"}`) if rec.Code != http.StatusOK { t.Fatalf("restore = %d %s", rec.Code, rec.Body.String()) } var restored models.User if err := gdb.Unscoped().First(&restored, user.ID).Error; err != nil { t.Fatal(err) } if restored.DeletedAt.Valid { t.Fatal("login without a gate did not restore the account") } if len(plainMail.got) != 1 || !strings.HasPrefix(plainMail.got[0], "golem15.user::mail.reactivate") { t.Fatalf("reactivate mails = %v", plainMail.got) } }) }