package auth import ( "context" "errors" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/krow/krow-backend/go-api/internal/testutil" ) // The session tests run against a real PostgreSQL database, because what they // are checking is largely the schema: the unique index that makes lookup work, // the CHECK that refuses a raw token, and the ON DELETE CASCADE that stops a // deleted user leaving a live session behind. None of that can be exercised // against an in-memory fake. // // Each test gets its own throwaway database, migrated but not seeded — the // seed fixture has nothing to say about sessions, and skipping it keeps these // tests fast. testutil skips rather than fails when PostgreSQL is absent. type fixture struct { pool *pgxpool.Pool orgID string userID string store *PGStore ctx context.Context } func newFixture(t *testing.T, label string) *fixture { t.Helper() ctx := context.Background() pool := testutil.Sandbox(t, label) testutil.ApplyAllMigrations(ctx, t, pool) f := &fixture{pool: pool, ctx: ctx, store: NewPGStore(pool)} if err := pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`, "Auth Test Org", "auth-test-org").Scan(&f.orgID); err != nil { t.Fatalf("create organization: %v", err) } f.userID = f.newUser(t, "session-owner@example.test") return f } func (f *fixture) newUser(t *testing.T, email string) string { t.Helper() var id string if err := f.pool.QueryRow(f.ctx, `INSERT INTO users (org_id, email, full_name, role) VALUES ($1::uuid, $2::citext, $3, 'admin') RETURNING id::text`, f.orgID, email, "Session Owner").Scan(&id); err != nil { t.Fatalf("create user %s: %v", email, err) } return id } func (f *fixture) sessionCount(t *testing.T) int { t.Helper() var n int if err := f.pool.QueryRow(f.ctx, `SELECT count(*)::int FROM sessions`).Scan(&n); err != nil { t.Fatalf("count sessions: %v", err) } return n } // manager builds a Manager over the fixture's store with a clock the test drives. func (f *fixture) manager(t *testing.T, p Policy, clock *time.Time) *Manager { t.Helper() m, err := NewManager(f.store, p) if err != nil { t.Fatalf("NewManager: %v", err) } return m.WithClock(func() time.Time { return *clock }) } // shortPolicy keeps the arithmetic in these tests small and legible. The // production values are asserted separately, in TestDefaultPolicy. var shortPolicy = Policy{ IdleLifetime: time.Hour, AbsoluteLifetime: 3 * time.Hour, RememberIdleLifetime: 24 * time.Hour, RememberAbsoluteLifetime: 72 * time.Hour, } // 9. A session is created, and what lands in the database is the hash. func TestSessionCreation(t *testing.T) { f := newFixture(t, "session_create") now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) m := f.manager(t, shortPolicy, &now) token, sess, err := m.Issue(f.ctx, f.userID, false) if err != nil { t.Fatalf("Issue: %v", err) } if token == "" { t.Fatal("Issue returned an empty token") } if sess.ID == "" { t.Error("the session was not given an id") } if sess.UserID != f.userID { t.Errorf("UserID = %q, want %q", sess.UserID, f.userID) } if sess.TokenHash != HashToken(token) { t.Error("the session's token hash is not the hash of the returned token") } if sess.TokenHash == token { t.Fatal("the raw token was stored as the hash") } if want := now.Add(shortPolicy.IdleLifetime); !sess.ExpiresAt.Equal(want) { t.Errorf("ExpiresAt = %v, want %v", sess.ExpiresAt, want) } if want := now.Add(shortPolicy.AbsoluteLifetime); !sess.AbsoluteExpiresAt.Equal(want) { t.Errorf("AbsoluteExpiresAt = %v, want %v", sess.AbsoluteExpiresAt, want) } // The row itself: exactly one, holding the hash and never the token. var stored string if err := f.pool.QueryRow(f.ctx, `SELECT token_hash FROM sessions WHERE id = $1::uuid`, sess.ID).Scan(&stored); err != nil { t.Fatalf("read the stored session: %v", err) } if stored != HashToken(token) { t.Error("the stored token_hash is not the hash of the token") } // Nothing anywhere in the table equals the token. This is the property the // whole design exists for, so it is asserted against the database and not // against the struct. var leaked int if err := f.pool.QueryRow(f.ctx, `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, token).Scan(&leaked); err != nil { t.Fatalf("scan for a leaked token: %v", err) } if leaked != 0 { t.Fatal("the raw token is present in the database") } // Remember Me gets the longer pair of deadlines. _, remembered, err := m.Issue(f.ctx, f.userID, true) if err != nil { t.Fatalf("Issue with remember: %v", err) } if want := now.Add(shortPolicy.RememberIdleLifetime); !remembered.ExpiresAt.Equal(want) { t.Errorf("Remember Me ExpiresAt = %v, want %v", remembered.ExpiresAt, want) } if want := now.Add(shortPolicy.RememberAbsoluteLifetime); !remembered.AbsoluteExpiresAt.Equal(want) { t.Errorf("Remember Me AbsoluteExpiresAt = %v, want %v", remembered.AbsoluteExpiresAt, want) } } // 10. A live session is found by the token, and only by the right token. func TestSessionLookup(t *testing.T) { f := newFixture(t, "session_lookup") now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) m := f.manager(t, shortPolicy, &now) token, issued, err := m.Issue(f.ctx, f.userID, false) if err != nil { t.Fatalf("Issue: %v", err) } got, err := m.Authenticate(f.ctx, token) if err != nil { t.Fatalf("Authenticate: %v", err) } if got.ID != issued.ID || got.UserID != f.userID { t.Errorf("Authenticate returned session %q for user %q, want %q / %q", got.ID, got.UserID, issued.ID, f.userID) } // Lookup is the read-only form and must agree. if looked, err := m.Lookup(f.ctx, token); err != nil || looked.ID != issued.ID { t.Errorf("Lookup = %q, %v; want %q, nil", looked.ID, err, issued.ID) } // A different, valid-looking token must not resolve. This is the case the // unique index and the hash lookup exist to make hopeless. other, err := GenerateToken() if err != nil { t.Fatalf("GenerateToken: %v", err) } if _, err := m.Authenticate(f.ctx, other); !errors.Is(err, ErrSessionNotFound) { t.Errorf("an unknown token returned %v, want ErrSessionNotFound", err) } // A malformed token must be refused without a round trip, and an empty one // must be refused before it is hashed at all. if _, err := m.Authenticate(f.ctx, "not-a-real-token"); !errors.Is(err, ErrSessionNotFound) { t.Errorf("a malformed token returned %v, want ErrSessionNotFound", err) } if _, err := m.Authenticate(f.ctx, ""); !errors.Is(err, ErrEmptyToken) { t.Errorf("an empty token returned %v, want ErrEmptyToken", err) } // The store is keyed by hash and refuses a raw token outright, so a caller // that forgets to hash gets an error rather than a silent miss. if _, err := f.store.FindByTokenHash(f.ctx, token); !errors.Is(err, ErrSessionNotFound) { t.Errorf("FindByTokenHash with a raw token returned %v, want ErrSessionNotFound", err) } } // 11. An expired session is rejected, and is cleaned up as it is found. func TestExpiredSessionIsRejected(t *testing.T) { f := newFixture(t, "session_expiry") now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) m := f.manager(t, shortPolicy, &now) token, sess, err := m.Issue(f.ctx, f.userID, false) if err != nil { t.Fatalf("Issue: %v", err) } // One second before the deadline it is still good. now = sess.ExpiresAt.Add(-time.Second) if _, err := m.Authenticate(f.ctx, token); err != nil { t.Fatalf("a session one second from expiry was rejected: %v", err) } // Exactly at the deadline it is not. The boundary is closed, not open: a // session that expires at 12:00 is dead at 12:00. fresh, err := m.Lookup(f.ctx, token) if err != nil { t.Fatalf("Lookup: %v", err) } now = fresh.ExpiresAt if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionExpired) { t.Fatalf("an expired session returned %v, want ErrSessionExpired", err) } // Finding it expired removes it, so the row does not linger until a sweep. if n := f.sessionCount(t); n != 0 { t.Errorf("%d sessions remain after an expired one was authenticated, want 0", n) } // And the second attempt cannot tell the client anything different. if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionNotFound) { t.Errorf("re-authenticating a swept session returned %v, want ErrSessionNotFound", err) } } // The absolute ceiling is what stops a sliding window being slid forever. func TestSessionCannotOutliveItsAbsoluteDeadline(t *testing.T) { f := newFixture(t, "session_absolute") start := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) now := start m := f.manager(t, shortPolicy, &now) token, sess, err := m.Issue(f.ctx, f.userID, false) if err != nil { t.Fatalf("Issue: %v", err) } ceiling := sess.AbsoluteExpiresAt // Keep using the session steadily, well inside the idle window each time, // right up to the ceiling. The sliding deadline must never cross it. for _, at := range []time.Duration{50 * time.Minute, 105 * time.Minute, 160 * time.Minute, 175 * time.Minute} { now = start.Add(at) got, err := m.Authenticate(f.ctx, token) if err != nil { t.Fatalf("Authenticate at +%v: %v", at, err) } if got.ExpiresAt.After(ceiling) { t.Fatalf("at +%v the sliding deadline %v passed the absolute ceiling %v", at, got.ExpiresAt, ceiling) } if !got.AbsoluteExpiresAt.Equal(ceiling) { t.Fatalf("at +%v the absolute ceiling moved to %v, want %v", at, got.AbsoluteExpiresAt, ceiling) } } // At the ceiling the session is over, however recently it was used. now = ceiling if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionExpired) { t.Fatalf("at the absolute ceiling Authenticate returned %v, want ErrSessionExpired", err) } } // 12. Revoking a session deletes it — and revoking twice is not an error, // because logging out with a stale cookie should succeed. func TestSessionDeletion(t *testing.T) { f := newFixture(t, "session_delete") now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) m := f.manager(t, shortPolicy, &now) token, sess, err := m.Issue(f.ctx, f.userID, false) if err != nil { t.Fatalf("Issue: %v", err) } if err := m.Revoke(f.ctx, token); err != nil { t.Fatalf("Revoke: %v", err) } if n := f.sessionCount(t); n != 0 { t.Errorf("%d sessions remain after revocation, want 0", n) } if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionNotFound) { t.Errorf("a revoked token returned %v, want ErrSessionNotFound", err) } if err := m.Revoke(f.ctx, token); err != nil { t.Errorf("revoking twice returned %v, want nil", err) } if err := m.RevokeID(f.ctx, sess.ID); err != nil { t.Errorf("revoking an absent session by id returned %v, want nil", err) } // The store's own contract is stricter: it reports what it did. if _, _, err := m.Issue(f.ctx, f.userID, false); err != nil { t.Fatalf("Issue: %v", err) } var id string if err := f.pool.QueryRow(f.ctx, `SELECT id::text FROM sessions LIMIT 1`).Scan(&id); err != nil { t.Fatalf("read the session id: %v", err) } if err := f.store.Delete(f.ctx, id); err != nil { t.Fatalf("store.Delete: %v", err) } if err := f.store.Delete(f.ctx, id); !errors.Is(err, ErrSessionNotFound) { t.Errorf("deleting an absent session returned %v, want ErrSessionNotFound", err) } } // 13. Deleting a user deletes their sessions. Without this, a removed account // keeps working until its cookie happens to expire. func TestUserDeletionCascadesToSessions(t *testing.T) { f := newFixture(t, "session_cascade") now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) m := f.manager(t, shortPolicy, &now) // A second user, so the test can prove the cascade removes one user's // sessions and leaves the other's alone. otherID := f.newUser(t, "other-user@example.test") doomedToken, _, err := m.Issue(f.ctx, f.userID, false) if err != nil { t.Fatalf("Issue for the doomed user: %v", err) } if _, _, err := m.Issue(f.ctx, f.userID, true); err != nil { t.Fatalf("second Issue for the doomed user: %v", err) } survivingToken, _, err := m.Issue(f.ctx, otherID, false) if err != nil { t.Fatalf("Issue for the surviving user: %v", err) } if n := f.sessionCount(t); n != 3 { t.Fatalf("%d sessions before the delete, want 3", n) } if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, f.userID); err != nil { // A RESTRICT foreign key would fail here, which is exactly the design // this test rules out. t.Fatalf("delete the user: %v", err) } if n := f.sessionCount(t); n != 1 { t.Fatalf("%d sessions after deleting one of two users, want 1", n) } if _, err := m.Authenticate(f.ctx, doomedToken); !errors.Is(err, ErrSessionNotFound) { t.Errorf("a deleted user's session returned %v, want ErrSessionNotFound", err) } if _, err := m.Authenticate(f.ctx, survivingToken); err != nil { t.Errorf("the other user's session was destroyed too: %v", err) } } // Sweep collects the sessions nobody comes back for, by either deadline. func TestSweepDeletesExpiredSessions(t *testing.T) { f := newFixture(t, "session_sweep") start := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) now := start m := f.manager(t, shortPolicy, &now) if _, _, err := m.Issue(f.ctx, f.userID, false); err != nil { // dies at +1h t.Fatalf("Issue short: %v", err) } longToken, _, err := m.Issue(f.ctx, f.userID, true) // dies at +24h if err != nil { t.Fatalf("Issue long: %v", err) } now = start.Add(90 * time.Minute) n, err := m.Sweep(f.ctx) if err != nil { t.Fatalf("Sweep: %v", err) } if n != 1 { t.Errorf("Sweep removed %d sessions, want 1", n) } if _, err := m.Authenticate(f.ctx, longToken); err != nil { t.Errorf("Sweep removed a live session: %v", err) } now = start.Add(25 * time.Hour) if n, err = m.Sweep(f.ctx); err != nil || n != 1 { t.Errorf("second Sweep removed %d sessions (err %v), want 1", n, err) } if got := f.sessionCount(t); got != 0 { t.Errorf("%d sessions remain after the sweep, want 0", got) } } // The database is the last line of defence against storing a raw token: the // CHECK constraint refuses anything that is not a SHA-256 hex digest, even if // the Go guard were bypassed. func TestDatabaseRefusesARawToken(t *testing.T) { f := newFixture(t, "session_rawtoken") now := time.Now().UTC() token, err := GenerateToken() if err != nil { t.Fatalf("GenerateToken: %v", err) } // Through the store: a Go error naming the mistake. err = f.store.Create(f.ctx, &Session{ UserID: f.userID, TokenHash: token, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour), CreatedDate: now, LastSeenAt: now, }) if err == nil { t.Fatal("the store accepted a raw token as a token hash") } // Straight past the store, in SQL: the constraint still refuses it. _, err = f.pool.Exec(f.ctx, `INSERT INTO sessions (user_id, token_hash, expires_at, absolute_expires_at) VALUES ($1::uuid, $2::text, now() + interval '1 hour', now() + interval '2 hours')`, f.userID, token) if err == nil { t.Fatal("the sessions table accepted a raw token; the CHECK constraint is not doing its job") } // The same insert with a proper hash succeeds, so the constraint is not // simply rejecting everything. if _, err := f.pool.Exec(f.ctx, `INSERT INTO sessions (user_id, token_hash, expires_at, absolute_expires_at) VALUES ($1::uuid, $2::text, now() + interval '1 hour', now() + interval '2 hours')`, f.userID, HashToken(token)); err != nil { t.Fatalf("a well-formed session was refused: %v", err) } // And the same hash cannot be stored twice: UNIQUE is what makes a token // identify exactly one session. if _, err := f.pool.Exec(f.ctx, `INSERT INTO sessions (user_id, token_hash, expires_at, absolute_expires_at) VALUES ($1::uuid, $2::text, now() + interval '1 hour', now() + interval '2 hours')`, f.userID, HashToken(token)); err == nil { t.Fatal("two sessions were stored with the same token hash") } } // The lifetimes are a decision, so they are asserted rather than assumed. func TestDefaultPolicy(t *testing.T) { p := DefaultPolicy if p.IdleLifetime != 12*time.Hour { t.Errorf("IdleLifetime = %v, want 12h", p.IdleLifetime) } if p.RememberIdleLifetime != 30*24*time.Hour { t.Errorf("RememberIdleLifetime = %v, want 720h (30 days)", p.RememberIdleLifetime) } // The point of the absolute bound: it must exist and must exceed the // window it caps, or a session could live forever. if p.AbsoluteLifetime <= 0 || p.RememberAbsoluteLifetime <= 0 { t.Fatal("an absolute lifetime is unset; a session could live forever") } if err := p.validate(); err != nil { t.Errorf("the default policy is not valid: %v", err) } } func TestNewManagerRejectsBadInput(t *testing.T) { if _, err := NewManager(nil, DefaultPolicy); err == nil { t.Error("NewManager accepted a nil store") } bad := map[string]Policy{ "zero": {}, "absolute below idle": {IdleLifetime: 2 * time.Hour, AbsoluteLifetime: time.Hour, RememberIdleLifetime: time.Hour, RememberAbsoluteLifetime: time.Hour}, "remember-me unset": {IdleLifetime: time.Hour, AbsoluteLifetime: time.Hour}, "negative idle lifetime": {IdleLifetime: -time.Hour, AbsoluteLifetime: time.Hour, RememberIdleLifetime: time.Hour, RememberAbsoluteLifetime: time.Hour}, } for name, p := range bad { if _, err := NewManager(NewPGStore(nil), p); err == nil { t.Errorf("NewManager accepted the %s policy", name) } } }