NextMyDay/internal/core/server/spec007_recovery_test.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

524 lines
18 KiB
Go

package server_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// member is one enrolled user with a live session (the chain's four tiers).
type member struct {
userID int64
identityID int64
guid string
sessionID string
email string
role users.Role
}
type recoveryHarness struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
requests *recovery.Store
mailer *mail.Fake
csrf *httpx.CSRF
pre *httpx.PreSessionCSRF
srv *server.Server
admin member
director member
manager member
team member
peer member // a second team member (same role as team)
}
func newRecoveryHarness(t *testing.T) *recoveryHarness {
t.Helper()
ctx := context.Background()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud)
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x51}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
ids := core.Identities()
sessions := core.Sessions()
const (
csrfKey = "recovery-csrf-key-0123456789abcdef"
cookieSecret = "recovery-cookie-secret-0123456789abcdef"
)
csrf, err := httpx.NewCSRF([]byte(csrfKey))
if err != nil {
t.Fatalf("NewCSRF: %v", err)
}
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
h := &recoveryHarness{
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
mailer: mail.NewFake(), csrf: csrf, pre: pre,
}
h.requests = recovery.NewStore(db.DB, aud)
// The chain, built top-down (D-019).
h.admin = h.enroll(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
h.director = h.enroll(ctx, h.admin.userID, "director@example.com", "Director", users.RoleDirector)
h.manager = h.enroll(ctx, h.director.userID, "manager@example.com", "Manager", users.RoleManager)
h.team = h.enroll(ctx, h.manager.userID, "team@example.com", "Team", users.RoleTeam)
h.peer = h.enroll(ctx, h.manager.userID, "peer@example.com", "Peer", users.RoleTeam)
rec, err := recovery.New(recovery.Deps{
Requests: h.requests,
Users: us,
Identities: ids,
Mailer: h.mailer,
CSRF: pre,
Audit: aud,
BaseURL: "https://myday.example.com",
Logger: nil,
})
if err != nil {
t.Fatalf("recovery.New: %v", err)
}
reg := server.NewMapRegistry()
reg.RegisterCore("home", server.Home)
reg.RegisterModule(server.NewModule("home").
SetDefault("dashboard", server.Home).
AddRoute("home", "home", server.Home))
server.RegisterRecoveryFlow(reg, rec)
cfg := &config.Config{
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"},
Database: config.Database{Path: db.Path()},
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
}
srv, err := server.New(server.Deps{
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
RoleGate: server.NewTiers(),
})
if err != nil {
t.Fatalf("server.New: %v", err)
}
h.srv = srv
return h
}
// enroll creates a user on the chain, activates an identity and mints a session.
func (h *recoveryHarness) enroll(ctx context.Context, creator int64, email, name string, role users.Role) member {
h.t.Helper()
uid, err := h.users.Create(ctx, creator, email, name, role)
if err != nil {
h.t.Fatalf("create %s: %v", role, err)
}
id, guid, err := h.ids.Mint(ctx, uid)
if err != nil {
h.t.Fatalf("Mint %s: %v", role, err)
}
if err := h.ids.Activate(ctx, id); err != nil {
h.t.Fatalf("Activate %s: %v", role, err)
}
sess, err := h.sessions.Mint(ctx, id, "127.0.0.1", "test")
if err != nil {
h.t.Fatalf("session Mint %s: %v", role, err)
}
return member{userID: uid, identityID: id, guid: guid, sessionID: sess.ID, email: email, role: role}
}
func (h *recoveryHarness) preCSRF() (*http.Cookie, string) {
h.t.Helper()
tok, err := h.pre.Token()
if err != nil {
h.t.Fatalf("pre-session Token: %v", err)
}
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
}
// access posts the recovery request form (anonymous, A15).
func (h *recoveryHarness) access(email string) *httptest.ResponseRecorder {
h.t.Helper()
cookie, token := h.preCSRF()
form := url.Values{"email": {email}, "csrf": {token}}
req := httptest.NewRequest("POST", "/access?do=auth&action=recover", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// decide issues a session-gated approve/deny action as actor.
func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder {
h.t.Helper()
form := url.Values{
"request": {strconv.FormatInt(requestID, 10)},
"csrf": {h.csrf.Token(actor.sessionID)},
}
req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: actor.sessionID})
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// openRequest submits team's recovery request and returns its id.
func (h *recoveryHarness) openRequest(m member) recovery.Request {
h.t.Helper()
if rec := h.access(m.email); rec.Code != http.StatusOK {
h.t.Fatalf("POST /access = %d, want 200", rec.Code)
}
req, err := h.requests.Pending(h.ctx, m.userID)
if err != nil {
h.t.Fatalf("Pending(%d): %v", m.userID, err)
}
return req
}
// auditActions returns the set of audit actions recorded.
func (h *recoveryHarness) auditActions(t *testing.T) map[string]bool {
t.Helper()
rows, err := h.db.Query(`SELECT action FROM audit_log`)
if err != nil {
t.Fatalf("read audit_log: %v", err)
}
defer rows.Close()
got := map[string]bool{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
t.Fatalf("scan audit action: %v", err)
}
got[a] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate audit_log: %v", err)
}
return got
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC2_NoSelfApprovalOrLowerRole: the requester can never approve
// their own request, and a peer (same role) cannot either (D-019, AC-2).
func TestSpec007AC2_NoSelfApprovalOrLowerRole(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
// The requester themselves -> forbidden. The body must be EXACTLY the
// client error — a returned error would make the door append a 500 on top
// of an already-written response.
if rec := h.decide("approve", h.team, req.ID); rec.Code != http.StatusForbidden {
t.Errorf("self-approval = %d, want 403 (AC-2)", rec.Code)
} else if got := rec.Body.String(); strings.TrimSpace(got) != "forbidden" {
t.Errorf("self-approval body = %q, want exactly %q", got, "forbidden")
}
// A peer (same role) -> forbidden.
if rec := h.decide("approve", h.peer, req.ID); rec.Code != http.StatusForbidden {
t.Errorf("peer approval = %d, want 403 (AC-2)", rec.Code)
}
// Nothing changed: the identity is still active, the request still pending.
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity after a refused approval = %+v, want still active (AC-2)", idn)
}
still, err := h.requests.Get(h.ctx, req.ID)
if err != nil || still.Status != recovery.StatusPending {
t.Errorf("request after a refused approval = %+v, want pending (AC-2)", still)
}
}
// TestSpec007AC2_R7_ChainAuthority: the recovery authority is the CHAIN (R-7).
// A director in a DIFFERENT subtree is a role above a team member but not on
// their manager line, so they may NOT approve; the admin (the root) may.
func TestSpec007AC2_R7_ChainAuthority(t *testing.T) {
h := newRecoveryHarness(t)
// A second director, created by the admin: role-above team, but NOT an
// ancestor of team (team -> manager -> director -> admin).
directorB := h.enroll(h.ctx, h.admin.userID, "director2@example.com", "Director2", users.RoleDirector)
req := h.openRequest(h.team)
// Role-above but off the chain -> forbidden (R-7's correction).
if rec := h.decide("approve", directorB, req.ID); rec.Code != http.StatusForbidden {
t.Errorf("non-ancestor director approval = %d, want 403 (R-7)", rec.Code)
}
// The request is untouched by the refusal.
if still, err := h.requests.Get(h.ctx, req.ID); err != nil || still.Status != recovery.StatusPending {
t.Errorf("request after a refused off-chain approval = %+v, want pending (R-7)", still)
}
// The admin is the root of the chain and may approve.
if rec := h.decide("approve", h.admin, req.ID); rec.Code != http.StatusOK {
t.Errorf("admin approval = %d, want 200 (R-7)", rec.Code)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC3_ApprovalRotatesIdentity: an approver on the requester's chain
// revokes the old identity and all its sessions and mints a NEW GUID, then
// emails the enrolment link (D-007, AC-3).
func TestSpec007AC3_ApprovalRotatesIdentity(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
rec := h.decide("approve", h.manager, req.ID) // manager is above team
if rec.Code != http.StatusOK {
t.Fatalf("approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
// The OLD identity is revoked and its GUID tombstoned.
old, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil {
t.Fatalf("ByGUID(old): %v", err)
}
if old.Status != auth.StatusRevoked {
t.Errorf("old identity status = %s, want revoked (AC-3)", old.Status)
}
// Every session of the old identity is dead.
if _, err := h.sessions.Validate(h.ctx, h.team.sessionID); err == nil {
t.Error("a pre-approval session survived the rotation (AC-3)")
}
// A NEW GUID is minted, pending enrollment, under the same user.
fresh, err := h.ids.ByUserID(h.ctx, h.team.userID)
if err != nil {
t.Fatalf("ByUserID: %v", err)
}
if fresh.GUID == h.team.guid {
t.Error("approval reissued the old GUID (AC-3)")
}
if fresh.Status != auth.StatusPendingEnrollment {
t.Errorf("new identity status = %s, want pending_enrollment (AC-3)", fresh.Status)
}
// The enrolment link reached the address on record.
found := false
for _, m := range h.mailer.To(h.team.email) {
if strings.Contains(m.Body, fresh.GUID) {
found = true
}
}
if !found {
t.Errorf("no enrolment email carrying the new GUID reached %s (AC-3)", h.team.email)
}
// The request is approved.
done, err := h.requests.Get(h.ctx, req.ID)
if err != nil || done.Status != recovery.StatusApproved {
t.Errorf("request = %+v, want approved (AC-3)", done)
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC4_AccessIsGeneric: /access never reveals whether the email
// matched — the same status and the same body either way (A3, AC-4).
func TestSpec007AC4_AccessIsGeneric(t *testing.T) {
h := newRecoveryHarness(t)
hit := h.access(h.team.email)
miss := h.access("nobody@example.com")
if hit.Code != http.StatusOK || miss.Code != http.StatusOK {
t.Fatalf("handle codes = %d/%d, want 200/200", hit.Code, miss.Code)
}
if hit.Body.String() != miss.Body.String() {
t.Errorf("the /access response differs on a hit vs a miss:\nhit: %q\nmiss: %q",
hit.Body.String(), miss.Body.String())
}
if !strings.Contains(hit.Body.String(), "If a record exists") {
t.Errorf("the /access notice is not generic: %q", hit.Body.String())
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC6_RecoveryIsAudited: request, approve, deny and the identity
// rotation are all recorded (A11, AC-6).
func TestSpec007AC6_RecoveryIsAudited(t *testing.T) {
h := newRecoveryHarness(t)
// approve
req := h.openRequest(h.team)
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
t.Fatalf("approve = %d, want 200", rec.Code)
}
// deny
req2 := h.openRequest(h.manager)
if rec := h.decide("deny", h.director, req2.ID); rec.Code != http.StatusOK {
t.Fatalf("deny = %d, want 200", rec.Code)
}
got := h.auditActions(t)
for _, want := range []string{
"recovery.request",
"recovery.approve",
"recovery.deny",
"identity.rotate",
} {
if !got[want] {
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
}
}
}
// ── supporting behaviour ─────────────────────────────────────────────────────
// TestSpec007AccessIssuesPreSessionCSRF: the anonymous /access form carries the
// signed pre-session token (A15) and the action without it is refused.
func TestSpec007AccessIssuesPreSessionCSRF(t *testing.T) {
h := newRecoveryHarness(t)
rec := request(h.srv, "GET", "/access")
if rec.Code != http.StatusOK {
t.Fatalf("GET /access = %d, want 200", rec.Code)
}
if !hasCookie(rec, httpx.PreSessionCookieName) {
t.Error("GET /access issued no pre-session CSRF cookie (A15)")
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("GET /access Cache-Control = %q, want no-store", got)
}
// Without the double-submit token the action is refused (A15).
req := httptest.NewRequest("POST", "/access?do=auth&action=recover",
strings.NewReader(url.Values{"email": {h.team.email}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.srv.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("recovery request without CSRF = %d, want 403 (A15)", rr.Code)
}
}
// TestSpec007_RepeatRequestDoesNotRenotify: a second POST for the same user
// reuses the pending request and does NOT email the chain again (no spam).
func TestSpec007_RepeatRequestDoesNotRenotify(t *testing.T) {
h := newRecoveryHarness(t)
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
t.Fatalf("first POST /access = %d, want 200", rec.Code)
}
first := len(h.mailer.Sent())
if first == 0 {
t.Fatal("the first request sent no notification")
}
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
t.Fatalf("second POST /access = %d, want 200", rec.Code)
}
if got := len(h.mailer.Sent()); got != first {
t.Errorf("a repeated request re-notified the chain: %d messages, want %d", got, first)
}
}
// TestSpec007_SecondDecisionRefused: the request is claimed atomically — a
// second approve/deny is refused and does not rotate a second time.
func TestSpec007_SecondDecisionRefused(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
t.Fatalf("first approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if rec := h.decide("approve", h.director, req.ID); rec.Code != http.StatusConflict {
t.Errorf("second approve = %d, want 409 (already decided)", rec.Code)
}
// The user has exactly one live (pending) identity — no double rotation.
var n int
if err := h.db.QueryRow(
`SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, h.team.userID,
).Scan(&n); err != nil {
t.Fatalf("count live identities: %v", err)
}
if n != 1 {
t.Errorf("live identities = %d, want exactly 1 (no double rotation)", n)
}
}
// TestSpec007_ExpiredRequestCannotBeApproved: a request past the ~72h window
// is expired and refused (SPEC-007 §4).
func TestSpec007_ExpiredRequestCannotBeApproved(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
// Age the request well past the TTL.
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
if _, err := h.db.Exec(`UPDATE recovery_requests SET requested_at = ? WHERE request_id = ?`, past, req.ID); err != nil {
t.Fatalf("age request: %v", err)
}
rec := h.decide("approve", h.manager, req.ID)
if rec.Code != http.StatusGone {
t.Errorf("approve an expired request = %d, want 410", rec.Code)
}
// The identity was NOT rotated.
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity after an expired approval = %+v, want still active", idn)
}
stored, err := h.requests.Get(h.ctx, req.ID)
if err != nil || stored.Status != recovery.StatusExpired {
t.Errorf("request = %+v, want expired", stored)
}
}
// TestSpec007DenyDoesNotRotate: a denial leaves the requester's identity intact.
func TestSpec007DenyDoesNotRotate(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
if rec := h.decide("deny", h.director, req.ID); rec.Code != http.StatusOK {
t.Fatalf("deny = %d, want 200", rec.Code)
}
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity after a denial = %+v, want still active", idn)
}
done, err := h.requests.Get(h.ctx, req.ID)
if err != nil || done.Status != recovery.StatusDenied {
t.Errorf("request = %+v, want denied", done)
}
}