Files
OwnCord/Server/api/auth_characterization_test.go
T
J3vbandClaude Fable 5 71d867cbdb test(b3-1): auth characterization tests — freeze the slice before B3-2 (#1449)
* docs(b3-0): record PR #1448 = d383d8c7 in the evidence block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b3-1): inventory of what the 85 auth tests already pin, per route and property

Route x property table in the B3-1 evidence block: the existing test for
each row, or GAP and the characterization row that fills it (next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b3-1): auth characterization — fill the inventory gaps against today's behaviour

12 tests / 44 table rows over the mounted auth router, no mocks: read
faults via ALTER TABLE RENAME, write faults via RAISE(FAIL) triggers.
Three rows pin defects as-is with `// known:` and ledger entries
OC-0376 (register 500 after the account commit), OC-0377 (verify-totp
maps a DB error to 401), OC-0378 (challenge consumed before the session
insert); fixed in B3-9, not here. Mutation spot-check: 401->500 in
totpChallengeSecret, 500->401 in loginAuthenticate, 503->401 in
AuthMiddleware each turned the rows that name them RED.

The nine watched ledger-count claims move 56 open / 375 -> 59 / 378.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b3-1): coverage before/after, row count and pre-squash SHAs in the evidence block

auth_handler.go 78.0% -> 90.2%, totp_handler.go 78.8% -> 91.1% (statements).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b3-1): Codex P2s — guaranteed-invalid TOTP code, B3-9 scope, hp-0 breakdown

- wrongTOTPCode derives a code outside the three accepted steps instead of
  assuming "000000" is invalid (1-in-333k flake in the per-user cap row)
- OC-0376/0377/0378 added to B3-9 in the step table and its section
- hp-0 open-record breakdown recomputed for the 59-open ledger

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b3-1): Codex round 2 — OC-0378 fix must keep the challenge claim atomic; coverage command syntax

- OC-0378 suggestedFix: Consume first, restore/re-issue on session failure;
  issuing before Consume lets two concurrent verifies both create sessions
- evidence block: go test -coverprofile=cover.out ./api/ (flag needs a file)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b3-1): Codex round 3 — exclude the +2 TOTP step, sequence auth B3-9 after B3-2, keep the replay claim in OC-0378's remedy

- wrongTOTPCode excludes {-1,0,+1,+2}: the verifier samples the clock after
  the helper, so a step boundary in between shifts its window to {0,+1,+2}
- B3-9 "Parallel with": OC-0345/0346 any; OC-0323 with B3-8; OC-0376..0378
  after B3-2 (matches the safe-parallelism rule and the section text)
- OC-0378 suggestedFix: a restored challenge must carry the accepted
  verification or roll back the MarkUsed claim, or the retry is a replay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:33:05 +02:00

992 lines
45 KiB
Go

package api_test
// B3-1 characterization tests for the auth slice (docs/plans/
// b3-server-architecture-guardrails-2026-08-29.md §B3-1). Every row pins
// TODAY's behaviour of auth_handler.go and totp_handler.go so that B3-2 can
// move the orchestration into service.AuthService without changing it. A row
// that reveals a defect is pinned as-is with a `// known:` comment and a
// ledger entry — it is not fixed here.
//
// Only the gaps in auth_handler_test.go, totp_handler_test.go and
// auth_handler_delete_broadcast_test.go are filled; the inventory of what
// those files already pin is the table in the plan's B3-1 evidence block.
//
// Fault injection uses the database itself, so no handler is mocked:
// - a SELECT fault is `ALTER TABLE x RENAME TO x_gone` (the sqlc query then
// fails with "no such table", a wrapped error that is not a sentinel);
// - a write fault is a BEFORE INSERT/UPDATE/DELETE trigger that RAISE(FAIL)s
// (the pattern TestConfirmTOTP_RevokeFailureSurfacesWarning introduced).
import (
"bytes"
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"regexp"
"slices"
"strings"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"golang.org/x/crypto/bcrypt"
)
// ─── Harness ─────────────────────────────────────────────────────────────────
// errBody is the errorResponse shape every refusal in the slice uses.
type errBody struct {
Error string `json:"error"`
Message string `json:"message"`
}
// send issues one request against the mounted auth router. token, ip and ua
// are optional; the body is JSON-encoded unless it is already a []byte.
func send(t *testing.T, router http.Handler, method, path, token, ip, ua string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
switch b := body.(type) {
case nil:
case []byte:
raw = b
default:
var err error
if raw, err = json.Marshal(b); err != nil {
t.Fatalf("marshal body: %v", err)
}
}
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if ua != "" {
req.Header.Set("User-Agent", ua)
}
if ip == "" {
ip = "127.0.0.1"
}
req.RemoteAddr = ip + ":9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func decodeErr(t *testing.T, rr *httptest.ResponseRecorder) errBody {
t.Helper()
var e errBody
if err := json.Unmarshal(rr.Body.Bytes(), &e); err != nil {
t.Fatalf("decode error body %q: %v", rr.Body.String(), err)
}
return e
}
// wantErr asserts status plus the exact error code and message.
func wantErr(t *testing.T, rr *httptest.ResponseRecorder, status int, code, message string) {
t.Helper()
if rr.Code != status {
t.Fatalf("status = %d, want %d; body = %s", rr.Code, status, rr.Body.String())
}
e := decodeErr(t, rr)
if e.Error != code || e.Message != message {
t.Fatalf("body = {%q, %q}, want {%q, %q}", e.Error, e.Message, code, message)
}
}
// hideTable makes every query against table fail with a non-sentinel error.
func hideTable(t *testing.T, database *db.DB, table string) {
t.Helper()
if _, err := database.ExecContext(context.Background(), "ALTER TABLE "+table+" RENAME TO "+table+"_gone"); err != nil {
t.Fatalf("hide %s: %v", table, err)
}
}
// failWrite makes every `op` against table fail. op is INSERT, DELETE or
// "UPDATE OF <column>".
func failWrite(t *testing.T, database *db.DB, op, table string) {
t.Helper()
name := "fault_" + regexp.MustCompile(`[^a-z]+`).ReplaceAllString(strings.ToLower(op+"_"+table), "_")
if _, err := database.ExecContext(context.Background(),
"CREATE TRIGGER "+name+" BEFORE "+op+" ON "+table+" BEGIN SELECT RAISE(FAIL, 'injected fault'); END"); err != nil {
t.Fatalf("install %s: %v", name, err)
}
}
// seedUser creates a user with the given password and returns its ID.
func seedUser(t *testing.T, database *db.DB, username, password string, roleID int) int64 {
t.Helper()
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
uid, err := database.CreateUser(context.Background(), username, hash, roleID)
if err != nil {
t.Fatalf("CreateUser(%s): %v", username, err)
}
return uid
}
// seedSession creates a login session for uid and returns the bearer token.
func seedSession(t *testing.T, database *db.DB, uid int64) string {
t.Helper()
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return token
}
func setSetting(t *testing.T, database *db.DB, key, value string) {
t.Helper()
if _, err := database.ExecContext(context.Background(),
`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, key, value); err != nil {
t.Fatalf("set %s: %v", key, err)
}
}
func seedInvite(t *testing.T, database *db.DB) string {
t.Helper()
ownerID := seedUser(t, database, "inviteowner", "ownerPass1", 1)
code, err := database.CreateInvite(context.Background(), ownerID, 1, nil)
if err != nil {
t.Fatalf("CreateInvite: %v", err)
}
return code
}
func inviteUseCount(t *testing.T, database *db.DB, code string) int {
t.Helper()
var n int
if err := database.QueryRowContext(context.Background(), `SELECT use_count FROM invites WHERE code = ?`, code).Scan(&n); err != nil {
t.Fatalf("invite use_count: %v", err)
}
return n
}
func userByName(t *testing.T, database *db.DB, username string) *db.User {
t.Helper()
u, err := database.GetUserByUsername(context.Background(), username)
if err != nil {
t.Fatalf("GetUserByUsername(%s): %v", username, err)
}
return u
}
// loginPartial logs in a TOTP-enrolled user and returns the partial token.
func loginPartial(t *testing.T, router http.Handler, username, password, ip, ua string) string {
t.Helper()
rr := send(t, router, http.MethodPost, "/api/v1/auth/login", "", ip, ua, map[string]string{"username": username, "password": password})
if rr.Code != http.StatusOK {
t.Fatalf("login status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
pt, _ := resp["partial_token"].(string)
if pt == "" || resp["requires_2fa"] != true {
t.Fatalf("expected a 2FA challenge, got %s", rr.Body.String())
}
return pt
}
// enrolTOTP stores secret for uid the way the confirm handler does
// (encrypted with the router's key) and returns the plaintext secret.
func enrolTOTP(t *testing.T, database *db.DB, uid int64) string {
t.Helper()
secret, err := auth.GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret: %v", err)
}
enc, err := auth.EncryptTOTPSecret(testTOTPKey, secret)
if err != nil {
t.Fatalf("EncryptTOTPSecret: %v", err)
}
if err := database.UpdateUserTOTPSecret(context.Background(), uid, &enc); err != nil {
t.Fatalf("UpdateUserTOTPSecret: %v", err)
}
return secret
}
func totpCode(t *testing.T, secret string) string {
t.Helper()
code, err := auth.GenerateTOTPCode(secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
return code
}
// wrongTOTPCode returns a six-digit code the verifier rejects for secret:
// it differs from the codes of the previous, current and next 30-second
// steps, which are the three VerifyTOTPCode accepts, and from the step after
// that — the verifier samples the clock later than this helper, so a request
// that crosses a step boundary in between is checked against {0,+1,+2}. A
// constant such as "000000" collides with one of them once in ~333k runs.
func wrongTOTPCode(t *testing.T, secret string) string {
t.Helper()
now := time.Now().UTC()
valid := map[string]bool{}
for _, d := range []time.Duration{-30 * time.Second, 0, 30 * time.Second, 60 * time.Second} {
code, err := auth.GenerateTOTPCode(secret, now.Add(d))
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
valid[code] = true
}
for i := range 5 {
if code := fmt.Sprintf("%06d", i); !valid[code] {
return code
}
}
t.Fatal("unreachable: five candidates, at most four valid codes")
return ""
}
// ─── Enumeration defence ─────────────────────────────────────────────────────
// Every credential rejection on /login is the same 401 — status, body,
// Content-Type and rate-limiter side effects — whether the account does not
// exist, exists, is banned, or is spelled in another case.
func TestAuthCharacterization_LoginRejectionsAreIndistinguishable(t *testing.T) {
type outcome struct {
status int
body string
ctype string
windows int
locks int
}
attempt := func(seed func(*db.DB), username string) outcome {
database := newAuthTestDB(t)
seed(database)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := send(t, router, http.MethodPost, "/api/v1/auth/login", "", "", "", map[string]string{"username": username, "password": "wrongPass1"})
w, l := limiter.Len()
return outcome{rr.Code, rr.Body.String(), rr.Header().Get("Content-Type"), w, l}
}
none := func(*db.DB) {}
known := func(d *db.DB) { seedUser(t, d, "target", "correctPass1", 4) }
banned := func(d *db.DB) {
uid := seedUser(t, d, "target", "correctPass1", 4)
if err := d.BanUser(context.Background(), uid, "characterization", nil); err != nil {
t.Fatalf("BanUser: %v", err)
}
}
rows := []struct {
name string
seed func(*db.DB)
username string
}{
{"unknown user", none, "target"},
{"wrong password", known, "target"},
{"banned user, wrong password", banned, "target"},
{"case variant, wrong password", known, "TARGET"},
}
ref := attempt(rows[0].seed, rows[0].username)
if ref.status != http.StatusUnauthorized {
t.Fatalf("reference status = %d, want 401; body = %s", ref.status, ref.body)
}
var e errBody
if err := json.Unmarshal([]byte(ref.body), &e); err != nil || e.Error != "UNAUTHORIZED" || e.Message != "invalid credentials" {
t.Fatalf("reference body = %s, want UNAUTHORIZED/invalid credentials", ref.body)
}
// One route window ("login:"+ip) plus the two failure windows
// ("login_fail:"+ip, "login_user_fail:"+name); no lockout yet.
if ref.windows != 3 || ref.locks != 0 {
t.Fatalf("reference limiter state = %d windows / %d lockouts, want 3 / 0", ref.windows, ref.locks)
}
for _, row := range rows[1:] {
if got := attempt(row.seed, row.username); got != ref {
t.Errorf("%s: %+v, want byte-identical to unknown user %+v", row.name, got, ref)
}
}
}
// The timing class of an unknown-user rejection matches a wrong-password
// rejection: auth.CheckPassword runs a dummy bcrypt compare when there is no
// hash. The api suite hashes at bcrypt.MinCost, where the compare is too
// cheap to measure, so this row raises the cost for its own hashes only.
func TestAuthCharacterization_UnknownUserTakesAsLongAsWrongPassword(t *testing.T) {
const cost = 10 // ~50-100 ms per compare: well above scheduler noise
auth.SetCostForTesting(cost)
t.Cleanup(func() { auth.SetCostForTesting(bcrypt.MinCost) })
database := newAuthTestDB(t)
seedUser(t, database, "timed", "correctPass1", 4)
median := func(username string) time.Duration {
samples := make([]time.Duration, 0, 3)
for range 3 {
// A fresh limiter per sample keeps every attempt inside the
// per-IP route limit and the failure windows.
router := buildAuthRouter(database, auth.NewRateLimiter())
start := time.Now()
rr := send(t, router, http.MethodPost, "/api/v1/auth/login", "", "", "", map[string]string{"username": username, "password": "wrongPass1"})
samples = append(samples, time.Since(start))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", username, rr.Code)
}
}
slices.Sort(samples)
return samples[1]
}
// Warm the dummy hash (sync.Once) so its one-off generation is not timed.
auth.CheckPassword("", "warm")
wrong := median("timed")
unknown := median("nobody")
if unknown < wrong/2 {
t.Fatalf("unknown-user rejection took %v, wrong-password %v: the dummy bcrypt compare is not running on the unknown path", unknown, wrong)
}
}
// Registration refusals that depend on state an attacker wants to probe —
// a taken username versus an unusable invite — share one 400 body.
func TestAuthCharacterization_RegisterRejectionsAreIndistinguishable(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
seedUser(t, database, "taken", "somePass1", 4)
code := seedInvite(t, database)
past := time.Now().Add(-time.Hour)
expired, err := database.CreateInvite(context.Background(), 1, 0, &past)
if err != nil {
t.Fatalf("CreateInvite(expired): %v", err)
}
// One IP per attempt: the register route allows 3/min per IP.
register := func(username, invite, ip string) *httptest.ResponseRecorder {
return send(t, router, http.MethodPost, "/api/v1/auth/register", "", ip, "",
map[string]string{"username": username, "password": "securePass1", "invite_code": invite})
}
ref := register("taken", code, "203.0.113.1")
wantErr(t, ref, http.StatusBadRequest, "INVALID_CREDENTIALS", "invalid invite or credentials")
for name, rr := range map[string]*httptest.ResponseRecorder{
"unknown invite": register("fresh", "no-such-invite", "203.0.113.2"),
"expired invite": register("fresh", expired, "203.0.113.3"),
} {
if rr.Code != ref.Code || rr.Body.String() != ref.Body.String() {
t.Errorf("%s: %d %s, want byte-identical to taken username %d %s", name, rr.Code, rr.Body.String(), ref.Code, ref.Body.String())
}
}
if n := inviteUseCount(t, database, code); n != 0 {
t.Errorf("invite use_count after rejected registrations = %d, want 0", n)
}
}
// ─── Sentinel and failure-path mapping: /register ────────────────────────────
func TestAuthCharacterization_RegisterPolicyAndFailurePaths(t *testing.T) {
body := func(invite string) map[string]string {
return map[string]string{"username": "fresh", "password": "securePass1", "invite_code": invite}
}
t.Run("settings unreadable -> 500, no policy leak", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
hideTable(t, database, "settings")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to load registration policy")
})
t.Run("registration_open unparsable -> 500", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
setSetting(t, database, "registration_open", "maybe")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to load registration policy")
})
t.Run("require_2fa unparsable -> 500", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
setSetting(t, database, "require_2fa", "yes")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to load registration policy")
})
t.Run("settings rows absent -> defaults (open, no 2FA) -> 201", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
if _, err := database.ExecContext(context.Background(), `DELETE FROM settings`); err != nil {
t.Fatalf("clear settings: %v", err)
}
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
})
t.Run("require_2fa true -> 403 before any credential is read", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
setSetting(t, database, "require_2fa", "true")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", []byte(`{not json`))
wantErr(t, rr, http.StatusForbidden, "FORBIDDEN", "registration is unavailable while two-factor authentication is required")
})
t.Run("registration closed -> 403 before any credential is read", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
setSetting(t, database, "registration_open", "0")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", []byte(`{not json`))
wantErr(t, rr, http.StatusForbidden, "FORBIDDEN", "registration is currently closed")
})
t.Run("user insert fails -> 500, invite not consumed", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
failWrite(t, database, "INSERT", "users")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "registration failed — please try again")
if n := inviteUseCount(t, database, code); n != 0 {
t.Errorf("invite use_count = %d, want 0 (transaction rolled back)", n)
}
if userByName(t, database, "fresh") != nil {
t.Error("user row exists after a failed insert")
}
})
t.Run("session insert fails after the user is committed -> 500", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
failWrite(t, database, "INSERT", "sessions")
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "", "", body(code))
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session")
// known: the account and the invite use are already committed when the
// session insert fails, so the caller sees a 500 for a registration
// that succeeded — a retry gets "invalid invite or credentials" while
// a login with the same password works (ledger OC-0376).
if userByName(t, database, "fresh") == nil {
t.Error("user row missing: this row pins the partial-success behaviour, which has changed")
}
if n := inviteUseCount(t, database, code); n != 1 {
t.Errorf("invite use_count = %d, want 1 (pinned partial success)", n)
}
})
}
// ─── Sentinel and failure-path mapping: /login ───────────────────────────────
func TestAuthCharacterization_LoginFailurePaths(t *testing.T) {
login := func(t *testing.T, router http.Handler, password string) *httptest.ResponseRecorder {
t.Helper()
return send(t, router, http.MethodPost, "/api/v1/auth/login", "", "", "", map[string]string{"username": "target", "password": password})
}
t.Run("user lookup fails -> 500, no attempt recorded", func(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
seedUser(t, database, "target", "correctPass1", 4)
hideTable(t, database, "users")
rr := login(t, router, "correctPass1")
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "login temporarily unavailable")
// Only the route's own per-IP window; the failure counters were not
// touched, so an outage cannot lock anyone out.
if w, l := limiter.Len(); w != 1 || l != 0 {
t.Errorf("limiter state = %d windows / %d lockouts, want 1 / 0", w, l)
}
})
t.Run("policy unreadable after a correct password -> 500", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
seedUser(t, database, "target", "correctPass1", 4)
hideTable(t, database, "settings")
rr := login(t, router, "correctPass1")
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to load authentication policy")
})
t.Run("policy unreadable with a wrong password -> 401 (credential guard runs first)", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
seedUser(t, database, "target", "correctPass1", 4)
hideTable(t, database, "settings")
rr := login(t, router, "wrongPass1")
wantErr(t, rr, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials")
})
t.Run("require_2fa unparsable -> 500", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
seedUser(t, database, "target", "correctPass1", 4)
setSetting(t, database, "require_2fa", "yes")
rr := login(t, router, "correctPass1")
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to load authentication policy")
})
t.Run("require_2fa parses case-insensitively with whitespace", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
seedUser(t, database, "target", "correctPass1", 4)
setSetting(t, database, "require_2fa", " TRUE ")
rr := login(t, router, "correctPass1")
wantErr(t, rr, http.StatusForbidden, "FORBIDDEN", "two-factor authentication must be enabled on this account before login")
})
t.Run("require_2fa true and enrolled -> challenge, not 403", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "target", "correctPass1", 4)
enrolTOTP(t, database, uid)
setSetting(t, database, "require_2fa", "true")
loginPartial(t, router, "target", "correctPass1", "", "")
})
t.Run("session insert fails -> 500", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
seedUser(t, database, "target", "correctPass1", 4)
failWrite(t, database, "INSERT", "sessions")
rr := login(t, router, "correctPass1")
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session")
})
}
// ─── Session issue ───────────────────────────────────────────────────────────
func TestAuthCharacterization_SessionShape(t *testing.T) {
hexToken := regexp.MustCompile(`^[0-9a-f]{64}$`)
userKeys := []string{"about", "created_at", "custom_status", "display_name", "id", "role_id", "status", "totp_enabled", "username"}
checkSuccess := func(t *testing.T, rr *httptest.ResponseRecorder, wantStatus int) string {
t.Helper()
if rr.Code != wantStatus {
t.Fatalf("status = %d, want %d; body = %s", rr.Code, wantStatus, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
keys := slices.Sorted(maps.Keys(resp))
if want := []string{"requires_2fa", "token", "user"}; !slices.Equal(keys, want) {
t.Errorf("response keys = %v, want %v", keys, want)
}
token, _ := resp["token"].(string)
if !hexToken.MatchString(token) {
t.Errorf("token = %q, want 64 lowercase hex chars", token)
}
user, _ := resp["user"].(map[string]any)
got := slices.Sorted(maps.Keys(user))
if !slices.Equal(got, userKeys) {
t.Errorf("user keys = %v, want %v (avatar omitted when empty; nullable fields present as null)", got, userKeys)
}
for _, k := range []string{"display_name", "about", "custom_status"} {
if user[k] != nil {
t.Errorf("user.%s = %v, want null when unset", k, user[k])
}
}
return token
}
longUA := strings.Repeat("u", 600)
t.Run("login issues one bearer session bound to device and IP", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "shape", "correctPass1", 4)
rr := send(t, router, http.MethodPost, "/api/v1/auth/login", "", "203.0.113.5", longUA, map[string]string{"username": "shape", "password": "correctPass1"})
token := checkSuccess(t, rr, http.StatusOK)
var n int
if err := database.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM sessions WHERE user_id = ?`, uid).Scan(&n); err != nil || n != 1 {
t.Fatalf("session rows = %d (%v), want exactly one", n, err)
}
s, err := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token))
if err != nil || s == nil {
t.Fatalf("session lookup by the issued token: %v", err)
}
if s.UserID != uid || s.IP != "203.0.113.5" || s.Device != longUA[:512] {
t.Errorf("session = {user %d, ip %q, device len %d}, want {%d, 203.0.113.5, 512}", s.UserID, s.IP, len(s.Device), uid)
}
if me := send(t, router, http.MethodGet, "/api/v1/auth/me", token, "", "", nil); me.Code != http.StatusOK {
t.Errorf("/me with the issued token = %d, want 200", me.Code)
}
})
t.Run("register issues the same shape with 201", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
code := seedInvite(t, database)
rr := send(t, router, http.MethodPost, "/api/v1/auth/register", "", "203.0.113.6", longUA,
map[string]string{"username": "shape", "password": "securePass1", "invite_code": code})
token := checkSuccess(t, rr, http.StatusCreated)
s, err := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token))
if err != nil || s == nil {
t.Fatalf("session lookup: %v", err)
}
if s.IP != "203.0.113.6" || s.Device != longUA[:512] {
t.Errorf("session = {ip %q, device len %d}, want {203.0.113.6, 512}", s.IP, len(s.Device))
}
})
t.Run("verify-totp binds the session to the login request, not the verify request", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "shape", "correctPass1", 4)
secret := enrolTOTP(t, database, uid)
pt := loginPartial(t, router, "shape", "correctPass1", "198.51.100.7", "phone-ua")
rr := send(t, router, http.MethodPost, "/api/v1/auth/verify-totp", pt, "203.0.113.9", "laptop-ua", map[string]string{"code": totpCode(t, secret)})
token := checkSuccess(t, rr, http.StatusOK)
s, err := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token))
if err != nil || s == nil {
t.Fatalf("session lookup: %v", err)
}
if s.IP != "198.51.100.7" || s.Device != "phone-ua" {
t.Errorf("session = {ip %q, device %q}, want the challenge's {198.51.100.7, phone-ua}", s.IP, s.Device)
}
})
}
// ─── Logout ──────────────────────────────────────────────────────────────────
func TestAuthCharacterization_Logout(t *testing.T) {
custom := "in a meeting"
setup := func(t *testing.T) (*db.DB, http.Handler, int64, string) {
t.Helper()
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "out", "correctPass1", 4)
if err := database.UpdateUserCustomStatus(context.Background(), uid, &custom); err != nil {
t.Fatalf("UpdateUserCustomStatus: %v", err)
}
return database, router, uid, seedSession(t, database, uid)
}
t.Run("clears the custom status", func(t *testing.T) {
database, router, uid, token := setup(t)
rr := send(t, router, http.MethodPost, "/api/v1/auth/logout", token, "", "", nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
u, _ := database.GetUserByID(context.Background(), uid)
if u.CustomStatus != nil {
t.Errorf("custom_status after logout = %q, want cleared", *u.CustomStatus)
}
})
t.Run("session delete fails -> 500, session survives", func(t *testing.T) {
database, router, _, token := setup(t)
failWrite(t, database, "DELETE", "sessions")
rr := send(t, router, http.MethodPost, "/api/v1/auth/logout", token, "", "", nil)
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout")
if s, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token)); s == nil {
t.Error("session gone although the delete was refused")
}
})
t.Run("custom-status clear fails -> still 204, session revoked", func(t *testing.T) {
database, router, uid, token := setup(t)
failWrite(t, database, "UPDATE OF custom_status", "users")
rr := send(t, router, http.MethodPost, "/api/v1/auth/logout", token, "", "", nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (status clear is best-effort); body = %s", rr.Code, rr.Body.String())
}
if s, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token)); s != nil {
t.Error("session survived logout")
}
u, _ := database.GetUserByID(context.Background(), uid)
if u.CustomStatus == nil || *u.CustomStatus != custom {
t.Errorf("custom_status = %v, want unchanged %q (the clear was refused)", u.CustomStatus, custom)
}
})
}
// ─── Authenticated routes when the token cannot be resolved ──────────────────
// A database fault while resolving the bearer token is 503, never 401 — a
// 401 would make the client discard a valid session (AuthMiddleware's
// documented reason). Pinned per auth route so the slice cannot change it.
func TestAuthCharacterization_TokenResolutionFaultIs503(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "res", "correctPass1", 4)
token := seedSession(t, database, uid)
hideTable(t, database, "users")
for name, req := range map[string]struct {
method, path string
body any
}{
"GET /me": {http.MethodGet, "/api/v1/auth/me", nil},
"POST /logout": {http.MethodPost, "/api/v1/auth/logout", nil},
"DELETE /account": {http.MethodDelete, "/api/v1/auth/account", map[string]string{"password": "correctPass1"}},
"POST /totp/enable": {http.MethodPost, "/api/v1/users/me/totp/enable", map[string]string{"password": "correctPass1"}},
"POST /totp/confirm": {http.MethodPost, "/api/v1/users/me/totp/confirm", map[string]string{"password": "correctPass1", "code": "000000"}},
"DELETE /users/me/totp": {http.MethodDelete, "/api/v1/users/me/totp", map[string]string{"password": "correctPass1"}},
} {
rr := send(t, router, req.method, req.path, token, "", "", req.body)
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("%s: status = %d, want 503; body = %s", name, rr.Code, rr.Body.String())
continue
}
if e := decodeErr(t, rr); e.Error != "SERVICE_UNAVAILABLE" || e.Message != "authentication service temporarily unavailable" {
t.Errorf("%s: body = %+v", name, e)
}
}
}
// ─── DELETE /account ─────────────────────────────────────────────────────────
func TestAuthCharacterization_DeleteAccountFailurePaths(t *testing.T) {
t.Run("purge fails -> 500, account and session intact", func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "gone", "correctPass1", 4)
token := seedSession(t, database, uid)
failWrite(t, database, "DELETE", "sessions")
rr := send(t, router, http.MethodDelete, "/api/v1/auth/account", token, "", "", map[string]string{"password": "correctPass1"})
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete account")
u, _ := database.GetUserByID(context.Background(), uid)
if u == nil || u.Banned || u.Username != "gone" {
t.Errorf("user after failed delete = %+v, want untouched", u)
}
if me := send(t, router, http.MethodGet, "/api/v1/auth/me", token, "", "", nil); me.Code != http.StatusOK {
t.Errorf("/me after failed delete = %d, want 200 (session must survive the rolled-back transaction)", me.Code)
}
})
t.Run("malformed body -> 400 before the password is checked", func(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
uid := seedUser(t, database, "gone", "correctPass1", 4)
token := seedSession(t, database, uid)
rr := send(t, router, http.MethodDelete, "/api/v1/auth/account", token, "", "", []byte(`{not json`))
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "malformed request body")
// Route window only — a malformed body is not a failed attempt.
if w, l := limiter.Len(); w != 1 || l != 0 {
t.Errorf("limiter state = %d windows / %d lockouts, want 1 / 0", w, l)
}
})
t.Run("wrong password -> 400 incorrect password, counted", func(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
uid := seedUser(t, database, "gone", "correctPass1", 4)
token := seedSession(t, database, uid)
rr := send(t, router, http.MethodDelete, "/api/v1/auth/account", token, "", "", map[string]string{"password": "wrongPass1"})
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "incorrect password")
if w, _ := limiter.Len(); w != 2 {
t.Errorf("limiter windows = %d, want 2 (route + delete_fail)", w)
}
})
}
// ─── POST /verify-totp ───────────────────────────────────────────────────────
func TestAuthCharacterization_VerifyTOTPFailurePaths(t *testing.T) {
setup := func(t *testing.T) (*db.DB, http.Handler, int64, string) {
t.Helper()
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "two", "correctPass1", 4)
secret := enrolTOTP(t, database, uid)
return database, router, uid, secret
}
verify := func(t *testing.T, router http.Handler, pt, code, ip string) *httptest.ResponseRecorder {
t.Helper()
return send(t, router, http.MethodPost, "/api/v1/auth/verify-totp", pt, ip, "", map[string]string{"code": code})
}
const challengeGone = "invalid or expired two-factor challenge"
t.Run("user lookup fails -> 401", func(t *testing.T) {
database, router, _, secret := setup(t)
pt := loginPartial(t, router, "two", "correctPass1", "", "")
hideTable(t, database, "users")
// known: totpChallengeSecret folds a database error into the same 401
// an expired challenge gets, so a DB fault during the second factor
// reads as a bad code and burns the caller's attempt budget (ledger
// OC-0377). The plan's rule is 5xx for a non-sentinel error; pinned
// as-is for B3-2, fixed in B3-9.
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusUnauthorized, "UNAUTHORIZED", challengeGone)
})
t.Run("secret removed after the challenge was issued -> 401", func(t *testing.T) {
database, router, uid, secret := setup(t)
pt := loginPartial(t, router, "two", "correctPass1", "", "")
if err := database.UpdateUserTOTPSecret(context.Background(), uid, nil); err != nil {
t.Fatalf("clear secret: %v", err)
}
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusUnauthorized, "UNAUTHORIZED", challengeGone)
})
t.Run("secret encrypted under another key -> 500", func(t *testing.T) {
database, router, uid, secret := setup(t)
pt := loginPartial(t, router, "two", "correctPass1", "", "")
otherKey := bytes.Repeat([]byte{7}, 32)
enc, err := auth.EncryptTOTPSecret(otherKey, secret)
if err != nil {
t.Fatalf("EncryptTOTPSecret: %v", err)
}
if err := database.UpdateUserTOTPSecret(context.Background(), uid, &enc); err != nil {
t.Fatalf("store foreign secret: %v", err)
}
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusInternalServerError, "INTERNAL_ERROR", "failed to verify two-factor code")
})
t.Run("session insert fails -> 500 and the challenge is consumed", func(t *testing.T) {
database, router, _, secret := setup(t)
pt := loginPartial(t, router, "two", "correctPass1", "", "")
failWrite(t, database, "INSERT", "sessions")
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session")
// known: the partial token was consumed before the session insert, so
// the user must repeat the password step after a transient store
// failure (ledger OC-0378). Pinned as-is; the code is also marked used.
if _, err := database.ExecContext(context.Background(), `DROP TRIGGER fault_insert_sessions`); err != nil {
t.Fatalf("drop trigger: %v", err)
}
wantErr(t, verify(t, router, pt, totpCode(t, secret), "203.0.113.2"), http.StatusUnauthorized, "UNAUTHORIZED", challengeGone)
})
t.Run("per-user failure cap spans challenges -> 429 on the 11th attempt", func(t *testing.T) {
_, router, _, secret := setup(t)
attempt := 0
for range 2 {
pt := loginPartial(t, router, "two", "correctPass1", "", "")
// partialAuthMaxFailures (5) consumes the challenge; the per-user
// totp_fail counter (10) keeps counting across challenges.
for range 5 {
attempt++
rr := verify(t, router, pt, wrongTOTPCode(t, secret), fmt.Sprintf("203.0.113.%d", attempt))
wantErr(t, rr, http.StatusUnauthorized, "UNAUTHORIZED", "invalid two-factor code")
}
}
pt := loginPartial(t, router, "two", "correctPass1", "", "")
rr := verify(t, router, pt, totpCode(t, secret), "203.0.113.99")
wantErr(t, rr, http.StatusTooManyRequests, "RATE_LIMITED", "too many failed attempts, try again later")
})
}
// ─── TOTP management: /enable, /confirm, DELETE /users/me/totp ───────────────
func TestAuthCharacterization_TOTPManagementFailurePaths(t *testing.T) {
setup := func(t *testing.T) (*db.DB, http.Handler, int64, string) {
t.Helper()
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
uid := seedUser(t, database, "mgmt", "correctPass1", 4)
return database, router, uid, seedSession(t, database, uid)
}
pw := map[string]string{"password": "correctPass1"}
t.Run("enable while enrolled -> 409", func(t *testing.T) {
database, router, uid, token := setup(t)
enrolTOTP(t, database, uid)
rr := send(t, router, http.MethodPost, "/api/v1/users/me/totp/enable", token, "", "", pw)
wantErr(t, rr, http.StatusConflict, "TOTP_ALREADY_ENABLED", "disable 2FA before re-enabling")
})
t.Run("enable with malformed body -> 400", func(t *testing.T) {
_, router, _, token := setup(t)
rr := send(t, router, http.MethodPost, "/api/v1/users/me/totp/enable", token, "", "", []byte(`{not json`))
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "malformed request body")
})
t.Run("enable with empty password -> 400 password is required", func(t *testing.T) {
_, router, _, token := setup(t)
rr := send(t, router, http.MethodPost, "/api/v1/users/me/totp/enable", token, "", "", map[string]string{})
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "password is required")
})
t.Run("password-confirmation lockout is shared by enable, confirm and disable", func(t *testing.T) {
_, router, _, token := setup(t)
// pwConfirmFailureThreshold (3) failures are recorded; the 4th trips
// the lockout while still answering 400. Distinct IPs keep the shared
// "totp:" route window (5/min per IP) out of the picture.
for i := range 4 {
rr := send(t, router, http.MethodPost, "/api/v1/users/me/totp/enable", token, fmt.Sprintf("203.0.113.%d", i+1), "", map[string]string{"password": "wrongPass1"})
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "password confirmation failed")
}
const locked = "too many failed attempts, try again later"
wantErr(t, send(t, router, http.MethodPost, "/api/v1/users/me/totp/enable", token, "203.0.113.11", "", pw), http.StatusTooManyRequests, "RATE_LIMITED", locked)
wantErr(t, send(t, router, http.MethodPost, "/api/v1/users/me/totp/confirm", token, "203.0.113.12", "", map[string]string{"password": "correctPass1", "code": "000000"}), http.StatusTooManyRequests, "RATE_LIMITED", locked)
wantErr(t, send(t, router, http.MethodDelete, "/api/v1/users/me/totp", token, "203.0.113.13", "", pw), http.StatusTooManyRequests, "RATE_LIMITED", locked)
})
t.Run("confirm: secret persist fails -> 500, nothing enrolled", func(t *testing.T) {
database, router, uid, token := setup(t)
rr := send(t, router, http.MethodPost, "/api/v1/users/me/totp/enable", token, "", "", pw)
if rr.Code != http.StatusOK {
t.Fatalf("enable: %d %s", rr.Code, rr.Body.String())
}
var enable map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &enable)
secret := extractSecretFromURI(t, enable["qr_uri"].(string))
failWrite(t, database, "UPDATE OF totp_secret", "users")
rr = send(t, router, http.MethodPost, "/api/v1/users/me/totp/confirm", token, "", "", map[string]string{"password": "correctPass1", "code": totpCode(t, secret)})
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to enable two-factor authentication")
if u, _ := database.GetUserByID(context.Background(), uid); u.TOTPSecret != nil {
t.Error("secret persisted although the update was refused")
}
})
t.Run("confirm with malformed body -> 400", func(t *testing.T) {
_, router, _, token := setup(t)
rr := send(t, router, http.MethodPost, "/api/v1/users/me/totp/confirm", token, "", "", []byte(`{not json`))
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "malformed request body")
})
t.Run("disable: policy unreadable -> 500, still enrolled", func(t *testing.T) {
database, router, uid, token := setup(t)
enrolTOTP(t, database, uid)
hideTable(t, database, "settings")
rr := send(t, router, http.MethodDelete, "/api/v1/users/me/totp", token, "", "", pw)
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to load authentication policy")
if u, _ := database.GetUserByID(context.Background(), uid); u.TOTPSecret == nil {
t.Error("secret cleared although the policy read failed")
}
})
t.Run("disable: secret clear fails -> 500, still enrolled", func(t *testing.T) {
database, router, uid, token := setup(t)
enrolTOTP(t, database, uid)
failWrite(t, database, "UPDATE OF totp_secret", "users")
rr := send(t, router, http.MethodDelete, "/api/v1/users/me/totp", token, "", "", pw)
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to disable two-factor authentication")
if u, _ := database.GetUserByID(context.Background(), uid); u.TOTPSecret == nil {
t.Error("secret cleared although the update was refused")
}
})
t.Run("disable when not enrolled -> 204 (idempotent)", func(t *testing.T) {
_, router, _, token := setup(t)
rr := send(t, router, http.MethodDelete, "/api/v1/users/me/totp", token, "", "", pw)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
})
t.Run("disable with an empty body -> 400 password is required", func(t *testing.T) {
_, router, _, token := setup(t)
rr := send(t, router, http.MethodDelete, "/api/v1/users/me/totp", token, "", "", nil)
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "password is required")
})
t.Run("disable with a malformed body -> 400", func(t *testing.T) {
_, router, _, token := setup(t)
rr := send(t, router, http.MethodDelete, "/api/v1/users/me/totp", token, "", "", []byte(`{not json`))
wantErr(t, rr, http.StatusBadRequest, "INVALID_INPUT", "malformed request body")
})
}
// ─── Route-level rate limits ─────────────────────────────────────────────────
// The per-IP RateLimitMiddleware on each auth route: N requests per minute
// pass, the N+1th is 429 with Retry-After, regardless of the credentials.
func TestAuthCharacterization_RouteRateLimits(t *testing.T) {
const slowDown = "too many requests, please slow down"
rows := []struct {
name string
limit int
setup func(t *testing.T, database *db.DB) (token string)
method string
path string
body any
want int // status of the requests inside the limit
}{
{"login 5/min", 5, func(*testing.T, *db.DB) string { return "" },
http.MethodPost, "/api/v1/auth/login", map[string]string{"username": "nobody", "password": "wrongPass1"}, http.StatusUnauthorized},
{"verify-totp 10/min", 10, func(*testing.T, *db.DB) string { return "bogus" },
http.MethodPost, "/api/v1/auth/verify-totp", map[string]string{"code": "000000"}, http.StatusUnauthorized},
{"delete account 5/min", 5, func(t *testing.T, d *db.DB) string { return seedSession(t, d, seedUser(t, d, "rl", "correctPass1", 4)) },
http.MethodDelete, "/api/v1/auth/account", map[string]string{}, http.StatusBadRequest},
{"totp management 5/min shared across the three routes", 5, func(t *testing.T, d *db.DB) string { return seedSession(t, d, seedUser(t, d, "rl", "correctPass1", 4)) },
http.MethodPost, "/api/v1/users/me/totp/enable", []byte(`{not json`), http.StatusBadRequest},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
token := row.setup(t, database)
for i := range row.limit {
if rr := send(t, router, row.method, row.path, token, "", "", row.body); rr.Code != row.want {
t.Fatalf("request %d: status = %d, want %d; body = %s", i+1, rr.Code, row.want, rr.Body.String())
}
}
path := row.path
if strings.Contains(path, "/totp/enable") {
path = "/api/v1/users/me/totp/confirm" // the sibling shares the "totp:" window
}
rr := send(t, router, row.method, path, token, "", "", row.body)
wantErr(t, rr, http.StatusTooManyRequests, "RATE_LIMITED", slowDown)
if ra := rr.Header().Get("Retry-After"); ra != "60" {
t.Errorf("Retry-After = %q, want 60", ra)
}
})
}
}