Files
OwnCord/Server/api/auth_characterization_test.go
T
J3vbandClaude Fable 5 123c0899e5 fix(b3-9): close the B3-tagged findings — OC-0345, OC-0346, OC-0376, OC-0377, OC-0378 (#1454)
* docs(b3-9): record B3-2's merge (#1450 = 75d64dd4); B3-9 in progress

Plan status line, B3-2 step-table row (DONE) and evidence block carry the
squash SHA; docs/plans/README.md B3 row updated.

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

* fix(api): panic log carries trace_id — tracing ahead of recoverer (OC-0346)

recoverer snapshots telemetry.TraceIDFromContext before dispatch, so it
needs the otelhttp span to exist already; it was mounted two slots ahead
of telemetry.HTTPMiddleware and the trace_id attribute was always dropped.
Move the tracing middleware above it; request-id binding, security headers
and the body cap keep their relative positions.

Test (otel build only — the default build hard-wires TraceIDFromContext to
""): go test -tags otel -run TestRecoverer_PanicLogCarriesTraceID ./api/

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

* fix(admin): owner gate answers 503 on a role read fault, not 403 (OC-0345)

ownerOnlyMiddleware collapsed `err != nil || role == nil` into 403 "role not
found", so a transient GetRoleByID failure told the Owner they lack the
Owner role. Split the outcomes: a store error logs and answers 503
SERVICE_UNAVAILABLE (the perimeter's contract); a genuinely missing role
still answers 403. The existing whitebox tests, which inject only the user
into the context, are unchanged.

Test: TestOwnerOnlyMiddleware_RoleLookupFailureIs503 (roles table renamed,
whitebox — through the full stack the perimeter would answer its own 503).

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

* fix(service): verify-totp reports a store fault as 500, uncounted (OC-0377)

challengeSecret folded a GetUserByID error into the same 401 "invalid or
expired two-factor challenge" an expired challenge earns, and the attempt
had already been charged to the per-user totp_fail cap. Split the outcome:
a store error logs and returns the new service.ErrTOTPUnavailable
(ErrInternal, "two-factor verification temporarily unavailable"); an unknown
user or a missing secret still answers 401. The limiter reservation moves
after the store read — the rule authenticate already applies — and still
precedes the code compare, so the check-then-act it closes stays closed.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
user lookup fails -> 500, challenge kept, attempt not counted` — after the
fault ten wrong codes still answer 401 (the tenth would be 429 had the
fault counted), then the eleventh is refused. `per-user failure cap spans
challenges` unchanged.

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

* fix(service): verify-totp keeps the verified second factor when the session insert fails (OC-0378)

VerifyTOTP consumed the partial challenge before issueSession, so a store
fault on the session insert discarded a verified second factor and sent the
user back to the password step; the code was also marked used, so an
immediate retry would have been refused as a replay.

The claim stays atomic and first (two concurrent verifies can never both
reach issueSession). On issueSession failure the challenge is restored under
the same partial token — the client still holds it — and the accepted code
is released, so the retry completes the login without another password
step. auth gains PartialAuthStore.Restore and UsedTOTPCodeStore.Unmark, each
tested in the leaf package.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
session insert fails -> 500, the challenge and the code survive` — once the
trigger is dropped the same token and the same code answer 200 with a token.

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

* fix(service): registration commits the account, the invite use and the first session together (OC-0376)

CreateUserWithInvite committed the user and burned the invite; the session
insert ran outside that transaction, so a store fault there answered 500
with a half-registered account — a retry got "invalid invite or
credentials" while a login with the same password worked. Option B from
the ledger: the session token is generated first and the session row is
inserted inside the same transaction (db.insertSession through
dbgen.Queries.WithTx; no query or migration change, so no sqlc regen). A
fault at any step rolls the whole registration back and the caller simply
retries. The H-6 cap needs no eviction for a user with no sessions.

Characterization row flipped in the same commit: `RegisterPolicyAndFailurePaths/
session insert fails -> 500, nothing committed` — user row absent, invite
use_count 0, message "registration failed — please try again". db tests
pass the three new arguments; the happy-path test asserts the session row.

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

* refactor(service): one session-token path for Register and issueSession

OC-0376 gave Register its own auth.GenerateToken failure branch — a
duplicate of the unreachable one issueSession already carried — and the
auth slice's statement coverage dipped from 91.8% to 91.7% on that one
statement. newSessionToken generates the token and hands its hash to a
persist callback: CreateSession for login and verify-totp, the
CreateUserWithInvite transaction for registration. Behaviour identical
(the characterization file is green before and after); slice coverage
402/437 = 92.0%, service/auth.go 250/263 = 95.1%.

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

* chore(ledger): close the five B3-tagged findings; counts re-derived (PR #1454)

OC-0346 → 775eba50, OC-0345 → fb1afb8a, OC-0377 → f7015809,
OC-0378 → be37d7ee, OC-0376 → 85d86dc7: status fixed, fix.test,
revertProof pass (hand reverse-apply per commit + verify-fixes.mjs).
Ledger 315 fixed / 59 open → 320 / 54 (3 declined, 1 duplicate, 378).

The four count-carrying documents are re-derived around every number,
not just the totals (obs #100): docs/plans/README.md, hp-0-scorecard
(54 open = 1 high / 12 medium / 41 low; three hunts; 53 of 54 resolve;
Client 33 / Server 21), repo-health-issue-register (table, "eleven of
which", OC-0345/OC-0346 rows marked fixed with this PR), b0-baseline.
OC-0323 stays open — it rides B3-8's message/read-state family.

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

* fix(service): verify-totp — cap check before the store read; release the code on a lost claim (Codex P2s, #1454)

Two P2s from Codex on PR #1454, both verified against the code and fixed
test-first:

1. An exhausted totp_fail window is refused by the read-only limiter.Check
   before challengeSecret, so rotating source IPs cannot drive user reads
   and secret decryptions past the per-user cap. The atomic Allow that
   records the attempt still runs after the store read (OC-0377: an outage
   charges nothing); the cap boundary is unchanged.
   Test: api/totp_cap_before_store_test.go — budget filled through the
   limiter, users table hidden, expects 429 (RED: 500 "temporarily
   unavailable", the store was read first).

2. A verify whose claim loses at Consume releases the code it marked, so a
   winner mid-recovery (Consume → issueSession failed → Restore) is not
   left with a live token behind a dead code until the authenticator rolls
   over.
   Test: service/auth_lost_claim_test.go — forces the interleaving through
   the store's GetUserByID (RED: "the losing claim left its code marked as
   used").

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

* docs(b3-9): evidence block, status line, step-table row; README B3 row (PR #1454)

Per finding: pre-squash SHA, RED and GREEN lines, revert-proof, the two
negative controls, what changed; the ledger diff and the re-derived count
paragraphs; auth-slice coverage 402/437 = 92.0% (floor 392/427 = 91.8%),
service/auth.go 250/263 = 95.1%; the otel-tagged run. OC-0323 recorded as
riding B3-8. hp-3-scorecard untouched — the owner signs it as drafted.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:56:04 +02:00

1019 lines
47 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 -> 500, nothing committed", 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))
// OC-0376 (fixed in B3-9): the first session is inserted inside the
// registration transaction, so a store fault leaves no half-registered
// account and does not burn the invite — the caller simply retries.
if userByName(t, database, "fresh") != nil {
t.Error("user row exists after the session insert failed")
}
if n := inviteUseCount(t, database, code); n != 0 {
t.Errorf("invite use_count = %d, want 0 (transaction rolled back)", n)
}
wantErr(t, rr, http.StatusInternalServerError, "INTERNAL_ERROR", "registration failed — please try again")
})
}
// ─── 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 -> 500, challenge kept, attempt not counted", func(t *testing.T) {
database, router, _, secret := setup(t)
pt := loginPartial(t, router, "two", "correctPass1", "", "")
hideTable(t, database, "users")
// OC-0377 (fixed in B3-9): a store fault while loading the challenged
// user is an outage, not a bad challenge — 5xx, the challenge stays
// live, and the attempt is not charged to the per-user totp_fail cap.
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusInternalServerError, "INTERNAL_ERROR", "two-factor verification temporarily unavailable")
if _, err := database.ExecContext(context.Background(), `ALTER TABLE users_gone RENAME TO users`); err != nil {
t.Fatalf("restore users: %v", err)
}
// The cap is 10 per user: ten wrong codes must all still answer 401
// "invalid two-factor code" — had the faulted attempt counted, the
// tenth would be the 429. The first five ride the surviving challenge
// (which proves it was kept), the next five a fresh one.
attempt := 0
for _, token := range []string{pt, loginPartial(t, router, "two", "correctPass1", "", "")} {
for range 5 {
attempt++
wantErr(t, verify(t, router, token, wrongTOTPCode(t, secret), fmt.Sprintf("203.0.113.%d", attempt)), http.StatusUnauthorized, "UNAUTHORIZED", "invalid two-factor code")
}
}
// Negative control: the counter is live — the eleventh attempt is refused.
pt = loginPartial(t, router, "two", "correctPass1", "", "")
wantErr(t, verify(t, router, pt, totpCode(t, secret), "203.0.113.99"), http.StatusTooManyRequests, "RATE_LIMITED", "too many failed attempts, try again later")
})
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, the challenge and the code survive", func(t *testing.T) {
database, router, _, secret := setup(t)
pt := loginPartial(t, router, "two", "correctPass1", "", "")
failWrite(t, database, "INSERT", "sessions")
code := totpCode(t, secret)
wantErr(t, verify(t, router, pt, code, ""), http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session")
// OC-0378 (fixed in B3-9): the verified second factor is not discarded
// by a store fault — the challenge is restored under the same partial
// token and the accepted code is released, so once the store is back
// the same token and the same code complete the login. (Restore alone
// would refuse the retry as a replay.)
if _, err := database.ExecContext(context.Background(), `DROP TRIGGER fault_insert_sessions`); err != nil {
t.Fatalf("drop trigger: %v", err)
}
rr := verify(t, router, pt, code, "203.0.113.2")
if rr.Code != http.StatusOK {
t.Fatalf("retry status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var res struct {
Token string `json:"token"`
Requires2FA bool `json:"requires_2fa"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil || res.Token == "" || res.Requires2FA {
t.Fatalf("retry body = %s (err %v), want a session token", rr.Body.String(), err)
}
})
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)
}
})
}
}