Files
krow_backend/go-api/internal/auth/session_test.go
2026-08-24 13:06:29 +05:30

489 lines
17 KiB
Go

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)
}
}
}