489 lines
17 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|