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, // M2s makes the notification asynchronous by default; these assertions // are about no-spam semantics, so run it synchronously for determinism. Dispatch: func(fn func()) { fn() }, }) 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 } // sessionCookie is the session cookie the door expects for this harness's // transport. base_url is https here, so the cookie carries the `__Host-` // prefix (L1s). func (h *recoveryHarness) sessionCookie(id string) *http.Cookie { return &http.Cookie{Name: httpx.SessionCookieNameFor(true), Value: id} } // 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(h.sessionCookie(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) } }