package auth_test import ( "bytes" "context" "database/sql" "errors" "fmt" "log/slog" "path/filepath" "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/routing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) // The Identities service satisfies the pure model's injected Resolver // (SPEC-002 §3.1) — a compile-time proof. var _ routing.Resolver = (*auth.IdentitiesService)(nil) // fakeClock drives session/identity expiry deterministically. type fakeClock struct{ now time.Time } func (c *fakeClock) Now() time.Time { return c.now } func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) } type fixture struct { t *testing.T ctx context.Context db *store.DB users *users.Store ids *auth.IdentitiesService sessions *auth.SessionsService clock *fakeClock } func newFixture(t *testing.T, opts ...auth.Option) *fixture { t.Helper() 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) } clk := &fakeClock{now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)} allOpts := append([]auth.Option{auth.WithClock(clk.Now)}, opts...) aud := audit.New(db.DB) core := auth.New(db.DB, aud, allOpts...) return &fixture{ t: t, ctx: context.Background(), db: db, users: users.New(db.DB, aud), ids: core.Identities(), sessions: core.Sessions(), clock: clk, } } func (f *fixture) admin() int64 { f.t.Helper() id, err := f.users.Create(f.ctx, 0, "admin@example.com", "Admin", users.RoleAdmin) mustNoErr(f.t, err) return id } func (f *fixture) activeIdentity(userID int64) (int64, string) { f.t.Helper() id, guid, err := f.ids.Mint(f.ctx, userID) mustNoErr(f.t, err) mustNoErr(f.t, f.ids.Activate(f.ctx, id)) return id, guid } func (f *fixture) auditCount() int { f.t.Helper() var n int if err := f.db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&n); err != nil { f.t.Fatalf("count audit_log: %v", err) } return n } // expectAudit asserts fn produced at least one audit row (AC-7). func (f *fixture) expectAudit(name string, fn func()) { f.t.Helper() before := f.auditCount() fn() if after := f.auditCount(); after <= before { f.t.Errorf("AC-7: %s wrote no audit row (before=%d after=%d)", name, before, after) } } func mustNoErr(t *testing.T, err error) { t.Helper() if err != nil { t.Fatal(err) } } // AC-1: two active identities for one user is impossible — service AND index. func TestAC1_OneActiveIdentityPerUser(t *testing.T) { f := newFixture(t) admin := f.admin() director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector) mustNoErr(t, err) f.activeIdentity(director) // Service check: Mint refuses while a live identity exists. if _, _, err := f.ids.Mint(f.ctx, director); !errors.Is(err, auth.ErrIdentityExists) { t.Errorf("Mint with a live identity = %v, want ErrIdentityExists", err) } now := store.FormatTime(f.clock.Now()) // Index check: a hand-inserted second ACTIVE row is rejected. if _, err := f.db.Exec( `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-active', 'active', ?)`, director, now, ); err == nil { t.Error("a second active identity was inserted, want a UNIQUE violation (AC-1 index)") } // Index check via the service: a hand-inserted pending row cannot be // activated into a second active identity. res, err := f.db.Exec( `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-pending', 'pending_enrollment', ?)`, director, now, ) mustNoErr(t, err) pendingID, err := res.LastInsertId() mustNoErr(t, err) if err := f.ids.Activate(f.ctx, pendingID); err == nil { t.Error("Activate produced a second active identity, want an error (AC-1)") } } // AC-2: guid is unique and a revoked GUID is never reissued (tombstoned). func TestAC2_GUIDUniqueAndTombstoned(t *testing.T) { f := newFixture(t) admin := f.admin() id, guid1 := f.activeIdentity(admin) if guid1 == "" || routing.IsReservedWorkflow(guid1) { t.Fatalf("minted guid %q is empty or reserved", guid1) } mustNoErr(t, f.ids.Revoke(f.ctx, id)) guid2, err := f.ids.Rotate(f.ctx, admin) mustNoErr(t, err) if guid2 == guid1 { t.Error("Rotate reissued the tombstoned GUID (AC-2)") } // The old GUID can never be re-inserted. if _, err := f.db.Exec( `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, 'pending_enrollment', ?)`, admin, guid1, store.FormatTime(f.clock.Now()), ); err == nil { t.Error("a tombstoned GUID was reused, want a UNIQUE violation (AC-2)") } // And it resolves as revoked/tombstoned, not as a live identity. old, err := f.ids.ByGUID(f.ctx, guid1) mustNoErr(t, err) if old.Status != auth.StatusRevoked { t.Errorf("old identity status = %s, want revoked", old.Status) } if found, enrolled := f.ids.GUID(guid1); found || enrolled { t.Errorf("tombstoned GUID resolved found=%v enrolled=%v, want false/false", found, enrolled) } } // AC-3: an empty session id is rejected (A2). func TestAC3_EmptySessionRejected(t *testing.T) { f := newFixture(t) if _, err := f.sessions.Validate(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) { t.Errorf("Validate(\"\") = %v, want ErrEmptySessionID (AC-3, A2)", err) } if err := f.sessions.Revoke(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) { t.Errorf("Revoke(\"\") = %v, want ErrEmptySessionID", err) } } // AC-4: expired/revoked sessions error; RevokeAll kills every session. func TestAC4_ExpiredRevokedAndRevokeAll(t *testing.T) { f := newFixture(t, auth.WithSessionTTL(time.Hour)) admin := f.admin() id, _ := f.activeIdentity(admin) s1, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") mustNoErr(t, err) s2, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") mustNoErr(t, err) if _, err := f.sessions.Validate(f.ctx, s1.ID); err != nil { t.Fatalf("a fresh session failed to validate: %v", err) } // Revoked -> error. mustNoErr(t, f.sessions.Revoke(f.ctx, s1.ID)) if _, err := f.sessions.Validate(f.ctx, s1.ID); !errors.Is(err, auth.ErrSessionRevoked) { t.Errorf("Validate(revoked) = %v, want ErrSessionRevoked", err) } // Expired -> error. f.clock.Advance(2 * time.Hour) if _, err := f.sessions.Validate(f.ctx, s2.ID); !errors.Is(err, auth.ErrSessionExpired) { t.Errorf("Validate(expired) = %v, want ErrSessionExpired", err) } // RevokeAll kills every live session of the identity. a, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") mustNoErr(t, err) b, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test") mustNoErr(t, err) mustNoErr(t, f.sessions.RevokeAll(f.ctx, id)) for _, sid := range []string{a.ID, b.ID} { if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionRevoked) { t.Errorf("Validate(%s) after RevokeAll = %v, want ErrSessionRevoked", sid, err) } } } // AC-5: Rotate revokes the old identity AND its sessions, and mints a new GUID. func TestAC5_RotateRevokesOldAndMintsNew(t *testing.T) { f := newFixture(t) admin := f.admin() id, guid1 := f.activeIdentity(admin) session, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") mustNoErr(t, err) guid2, err := f.ids.Rotate(f.ctx, admin) mustNoErr(t, err) if guid2 == "" || guid2 == guid1 { t.Fatalf("Rotate guid = %q, want a fresh non-empty GUID", guid2) } old, err := f.ids.ByGUID(f.ctx, guid1) mustNoErr(t, err) if old.Status != auth.StatusRevoked || old.RevokedAt == nil { t.Errorf("old identity = %+v, want revoked with a timestamp", old) } // Every session of the old identity is dead. if _, err := f.sessions.Validate(f.ctx, session.ID); !errors.Is(err, auth.ErrSessionRevoked) { t.Errorf("old session after Rotate = %v, want ErrSessionRevoked", err) } // The new identity is pending enrollment under the same user. fresh, err := f.ids.ByGUID(f.ctx, guid2) mustNoErr(t, err) if fresh.Status != auth.StatusPendingEnrollment { t.Errorf("new identity status = %s, want pending_enrollment", fresh.Status) } if fresh.UserID != admin { t.Errorf("new identity user = %d, want %d (employment survives rotation)", fresh.UserID, admin) } } // AC-7: every service mutation writes an audit_log row (A11). func TestAC7_EveryMutationAudited(t *testing.T) { f := newFixture(t) admin := f.admin() var director int64 f.expectAudit("users.Create", func() { var err error director, err = f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector) mustNoErr(t, err) }) f.expectAudit("users.SetRole", func() { mustNoErr(t, f.users.SetRole(f.ctx, director, users.RoleManager)) }) var id int64 f.expectAudit("identities.Mint", func() { var err error id, _, err = f.ids.Mint(f.ctx, director) mustNoErr(t, err) }) f.expectAudit("identities.Activate", func() { mustNoErr(t, f.ids.Activate(f.ctx, id)) }) var sess auth.Session f.expectAudit("sessions.Mint", func() { var err error sess, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") mustNoErr(t, err) }) f.expectAudit("sessions.Rotate", func() { _, err := f.sessions.Rotate(f.ctx, sess.ID) mustNoErr(t, err) }) sess2, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") mustNoErr(t, err) f.expectAudit("sessions.Revoke", func() { mustNoErr(t, f.sessions.Revoke(f.ctx, sess2.ID)) }) _, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua") mustNoErr(t, err) f.expectAudit("sessions.RevokeAll", func() { mustNoErr(t, f.sessions.RevokeAll(f.ctx, id)) }) f.expectAudit("identities.Revoke", func() { mustNoErr(t, f.ids.Revoke(f.ctx, id)) }) f.expectAudit("identities.Rotate", func() { _, err := f.ids.Rotate(f.ctx, director) mustNoErr(t, err) }) } // AC-8: the TOTP secret is encrypted at rest and never stored or logged in // plaintext (A9). func TestAC8_TOTPSecretEncryptedAndNeverPlaintext(t *testing.T) { var logs bytes.Buffer old := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) t.Cleanup(func() { slog.SetDefault(old) }) sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x7f}, auth.KeySize)) mustNoErr(t, err) f := newFixture(t, auth.WithSealer(sealer)) admin := f.admin() id, _ := f.activeIdentity(admin) secret := []byte("JBSWY3DPEHPK3PXP") // the shape of a base32 TOTP secret f.expectAudit("totp.PutTOTPSecret", func() { mustNoErr(t, f.ids.PutTOTPSecret(f.ctx, id, secret)) }) // 1) What is stored is ciphertext, not the secret. var raw []byte if err := f.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil { t.Fatalf("read secret_encrypted: %v", err) } if bytes.Equal(raw, secret) || bytes.Contains(raw, secret) { t.Error("AC-8: secret_encrypted holds the plaintext secret (A9)") } // 2) It still decrypts back to the secret. got, err := f.ids.TOTPSecret(f.ctx, id) mustNoErr(t, err) if !bytes.Equal(got, secret) { t.Errorf("TOTPSecret = %q, want %q", got, secret) } // 3) No audit row carries the secret. rows, err := f.db.Query( `SELECT COALESCE(action,''), COALESCE(subject,''), COALESCE(detail,''), COALESCE(correlation_id,'') FROM audit_log`, ) mustNoErr(t, err) defer rows.Close() for rows.Next() { var a, s, d, c string mustNoErr(t, rows.Scan(&a, &s, &d, &c)) if strings.Contains(a+s+d+c, string(secret)) { t.Error("AC-8: an audit row contains the TOTP secret (A9)") } } mustNoErr(t, rows.Err()) // 4) Nothing reached the log. if strings.Contains(logs.String(), string(secret)) { t.Error("AC-8: the TOTP secret reached the log (A9)") } // Verification is audited too and never stores plaintext. f.expectAudit("totp.MarkTOTPVerified", func() { mustNoErr(t, f.ids.MarkTOTPVerified(f.ctx, id)) }) } func TestTOTPWithoutSealerFailsClosed(t *testing.T) { f := newFixture(t) // no sealer admin := f.admin() id, _ := f.activeIdentity(admin) if err := f.ids.PutTOTPSecret(f.ctx, id, []byte("secret")); !errors.Is(err, auth.ErrNoSealer) { t.Errorf("PutTOTPSecret without a sealer = %v, want ErrNoSealer (fail closed)", err) } } // The Identities service satisfies routing.Resolver end to end. func TestResolverContract(t *testing.T) { f := newFixture(t) admin := f.admin() if found, enrolled := f.ids.GUID("definitely-unknown"); found || enrolled { t.Error("unknown GUID reported found") } if found, _ := f.ids.GUID(""); found { t.Error("empty GUID reported found") } id, guid, err := f.ids.Mint(f.ctx, admin) // pending mustNoErr(t, err) if found, enrolled := f.ids.GUID(guid); !found || enrolled { t.Errorf("pending GUID: found=%v enrolled=%v, want true/false", found, enrolled) } mustNoErr(t, f.ids.Activate(f.ctx, id)) if found, enrolled := f.ids.GUID(guid); !found || !enrolled { t.Errorf("active GUID: found=%v enrolled=%v, want true/true", found, enrolled) } } func TestIdentityLifecycleErrors(t *testing.T) { f := newFixture(t) admin := f.admin() if _, _, err := f.ids.Mint(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) { t.Errorf("Mint(unknown user) = %v, want ErrUnknownUser", err) } if _, err := f.ids.Rotate(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) { t.Errorf("Rotate(unknown user) = %v, want ErrUnknownUser", err) } id, _, err := f.ids.Mint(f.ctx, admin) mustNoErr(t, err) mustNoErr(t, f.ids.Activate(f.ctx, id)) if err := f.ids.Activate(f.ctx, id); !errors.Is(err, auth.ErrIdentityNotPending) { t.Errorf("Activate twice = %v, want ErrIdentityNotPending", err) } // A live identity blocks a second Mint. if _, _, err := f.ids.Mint(f.ctx, admin); !errors.Is(err, auth.ErrIdentityExists) { t.Errorf("second Mint = %v, want ErrIdentityExists", err) } if _, err := f.sessions.Mint(f.ctx, id, "ip", "ua"); err != nil { t.Fatalf("Mint session for active identity: %v", err) } } func TestSessionMintRequiresActiveIdentity(t *testing.T) { f := newFixture(t) admin := f.admin() pending, _, err := f.ids.Mint(f.ctx, admin) // still pending mustNoErr(t, err) if _, err := f.sessions.Mint(f.ctx, pending, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotActive) { t.Errorf("Mint for pending identity = %v, want ErrIdentityNotActive", err) } if _, err := f.sessions.Mint(f.ctx, 999, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotFound) { t.Errorf("Mint for unknown identity = %v, want ErrIdentityNotFound", err) } } func TestSessionGCKeepsValidSessions(t *testing.T) { f := newFixture(t, auth.WithSessionTTL(time.Hour)) admin := f.admin() id, _ := f.activeIdentity(admin) stale1, err := f.sessions.Mint(f.ctx, id, "ip", "ua") mustNoErr(t, err) stale2, err := f.sessions.Mint(f.ctx, id, "ip", "ua") mustNoErr(t, err) f.clock.Advance(2 * time.Hour) // both stale sessions are now expired fresh, err := f.sessions.Mint(f.ctx, id, "ip", "ua") mustNoErr(t, err) n, err := f.sessions.GC(f.ctx, f.clock.Now()) mustNoErr(t, err) if n != 2 { t.Errorf("GC removed %d rows, want 2", n) } // The valid session survives — GC never takes the last valid session. if _, err := f.sessions.Validate(f.ctx, fresh.ID); err != nil { t.Errorf("GC removed a valid session: %v", err) } for _, sid := range []string{stale1.ID, stale2.ID} { if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionNotFound) { t.Errorf("stale session %s = %v, want ErrSessionNotFound after GC", sid, err) } } } func TestEnrollmentWindow(t *testing.T) { f := newFixture(t, auth.WithEnrollWindow(72*time.Hour)) admin := f.admin() id, _, err := f.ids.Mint(f.ctx, admin) // pending mustNoErr(t, err) ok, err := f.ids.WithinEnrollmentWindow(f.ctx, id) mustNoErr(t, err) if !ok { t.Error("a fresh pending identity is outside its enrollment window") } f.clock.Advance(73 * time.Hour) if ok, err = f.ids.WithinEnrollmentWindow(f.ctx, id); err != nil { t.Fatalf("WithinEnrollmentWindow: %v", err) } if ok { t.Error("an identity past its window is still enrollable (A1)") } } // R-4: the identity services take the ACTOR from the audit.Meta on the context // (creator/approver) and record the AFFECTED user as the subject — never a // hard-coded owner. func TestR4_IdentityAuditActorFromContext(t *testing.T) { f := newFixture(t) admin := f.admin() director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector) mustNoErr(t, err) // The creator acts with an explicit actor on the context (the door would // attach it); the affected user is the subject. ctx := audit.WithMeta(f.ctx, audit.Meta{ActorUserID: admin, ClientIP: "203.0.113.9", CorrelationID: "corr-r4"}) id, guid, err := f.ids.Mint(ctx, director) mustNoErr(t, err) mustNoErr(t, f.ids.Activate(ctx, id)) mustNoErr(t, f.ids.Revoke(ctx, id)) if _, err := f.ids.Rotate(ctx, director); err != nil { t.Fatalf("Rotate: %v", err) } _ = guid rows, err := f.db.Query(`SELECT action, actor_user_id, subject, correlation_id FROM audit_log WHERE action IN ('identity.mint','identity.activate','identity.revoke','identity.rotate') ORDER BY id`) if err != nil { t.Fatalf("query audit_log: %v", err) } defer rows.Close() want := map[string]bool{ "identity.mint": true, "identity.activate": true, "identity.revoke": true, "identity.rotate": true, } seen := 0 for rows.Next() { var action, subject string var actor sql.NullInt64 var corr sql.NullString if err := rows.Scan(&action, &actor, &subject, &corr); err != nil { t.Fatalf("scan: %v", err) } seen++ if !actor.Valid || actor.Int64 != admin { t.Errorf("%s actor = %+v, want the context actor %d (R-4)", action, actor, admin) } if subject != fmt.Sprintf("user:%d", director) { t.Errorf("%s subject = %q, want the affected user (R-4)", action, subject) } if corr.String != "corr-r4" { t.Errorf("%s correlation = %q, want corr-r4", action, corr.String) } delete(want, action) } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } if seen != 4 || len(want) != 0 { t.Errorf("audited %d identity events, missing %v (R-4)", seen, want) } }