274 lines
9.4 KiB
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)
|
|
}
|
|
})
|
|
}
|