Files
sm-user-plugin/login_gate_smoke_test.go

274 lines
9.4 KiB
Go

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