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
This commit is contained in:
J3vb
2026-08-29 20:38:39 +02:00
co-authored by Claude Fable 5
parent 0905a94249
commit b7317d03e0
6 changed files with 1024 additions and 8 deletions
+52 -1
View File
@@ -1,5 +1,5 @@
{
"nextId": 376,
"nextId": 379,
"findings": [
{
"id": "OC-0001",
@@ -8484,6 +8484,57 @@
"suggestedFix": "In the loop at Client/src/pages/MainPage.ts:808-829, leave the signature accumulation alone but skip the local user for relabeling: hoist `const selfId = getCurrentUserId();` and wrap both setLabel blocks in `if (uid !== selfId) { ... }` — VideoModeController is the sole writer of the self tiles' \"(You)\" / \"Your Screen\" labels.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0376",
"title": "Register commits the account and burns the invite, then answers 500 when the session insert fails",
"file": "Server/api/auth_handler.go",
"line": 201,
"severity": "low",
"why": "handleRegister hashes the password first so a hashing failure cannot burn an invite (the comment at line 153 states that intent), and CreateUserWithInvite consumes the invite and creates the user in one transaction. The session insert that follows is outside that transaction: when CreateSession fails the handler returns 500 \"failed to create session\", but the user row and the invite use are already committed. The caller sees a failed registration; retrying gets 400 \"invalid invite or credentials\" (username taken, invite exhausted) while a login with the same password succeeds. Same shape on the verify-totp path (OC-0378).",
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_RegisterPolicyAndFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, registers with a valid invite, and pins today's outcome: 500, user row present, invite use_count 1.",
"evidence": "Server/api/auth_handler.go:166 uid, err := database.CreateUserWithInvite(...) // commits user + invite use\nServer/api/auth_handler.go:201 if _, err := database.CreateSession(...); err != nil { // 500 after the commit\nServer/api/auth_handler.go:153 // Hash password before consuming the invite so that a hashing failure\n // does not burn a valid invite code.",
"suggestedFix": "Either answer 201 without a token when the session insert fails after the account commit (the account exists; the client logs in), or move the session insert into the CreateUserWithInvite transaction so registration is atomic. Belongs to the AuthService in B3-2/B3-9, not to the handler.",
"status": "open",
"found": "2026-08-29",
"hunt": "b3-1-auth-characterization-2026-08-29",
"lens": "characterization",
"confidence": "high",
"finder": "claude"
},
{
"id": "OC-0377",
"title": "verify-totp maps a database error while loading the challenged user to 401, indistinguishable from an expired challenge",
"file": "Server/api/totp_handler.go",
"line": 141,
"severity": "low",
"why": "totpChallengeSecret folds `err != nil` from GetUserByID into the same 401 \"invalid or expired two-factor challenge\" that a missing user or missing secret gets. A transient database fault during the second factor therefore reads as a bad challenge: the client drops the partial token and asks the user to log in again, and the attempt has already been recorded against the per-user totp_fail cap by the limiter.Allow call above it. Every sibling path in the slice maps a non-sentinel database error to 5xx (login 500, AuthMiddleware 503) precisely so an outage is not mistaken for a credential failure.",
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/user_lookup_fails' ./api/ — the row renames the users table after the challenge is issued and pins today's 401.",
"evidence": "Server/api/totp_handler.go:140 user, err := database.GetUserByID(r.Context(), challengeUserID)\nServer/api/totp_handler.go:141 if err != nil || user == nil || user.TOTPSecret == nil {\nServer/api/totp_handler.go:142 writeJSON(w, http.StatusUnauthorized, errorResponse{ ... \"invalid or expired two-factor challenge\" })\nServer/api/auth_handler.go:488-497 the login sibling: a non-nil error is a genuine DB failure -> 500 \"login temporarily unavailable\"",
"suggestedFix": "Split the condition: `err != nil` -> 500 INTERNAL_ERROR (\"two-factor verification temporarily unavailable\") without RegisterFailure and with the limiter reservation undone or not made; keep 401 for `user == nil || user.TOTPSecret == nil`. Fix in B3-9 after B3-2 lands, and flip the characterization row with it.",
"status": "open",
"found": "2026-08-29",
"hunt": "b3-1-auth-characterization-2026-08-29",
"lens": "characterization",
"confidence": "high",
"finder": "claude"
},
{
"id": "OC-0378",
"title": "verify-totp consumes the partial challenge before the session insert, so a store failure forces the user back to the password step",
"file": "Server/api/totp_handler.go",
"line": 107,
"severity": "low",
"why": "handleVerifyTOTP calls partialStore.Consume before issueSession. When CreateSession fails the handler answers 500 \"failed to create session\", but the challenge is already gone (and the code is marked used by VerifyTOTPCodeOnce), so the only way forward is a fresh POST /login with the password. A verified second factor is discarded because of a persistence hiccup that has nothing to do with the credential. Same shape as OC-0376 on the register path.",
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, verifies a valid code, pins the 500, drops the trigger and pins that the same partial token is now refused with 401.",
"evidence": "Server/api/totp_handler.go:107 if _, ok := partialStore.Consume(partialToken); !ok { // challenge gone here\nServer/api/totp_handler.go:115 token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP) // fails after it",
"suggestedFix": "Issue the session first and Consume only after CreateSession succeeded (Lookup already proved the challenge; a concurrent second verify is bounded by the per-user cap and the used-code store), or re-issue the challenge on session failure. Belongs to the AuthService in B3-2/B3-9.",
"status": "open",
"found": "2026-08-29",
"hunt": "b3-1-auth-characterization-2026-08-29",
"lens": "characterization",
"confidence": "high",
"finder": "claude"
}
]
}
+965
View File
@@ -0,0 +1,965 @@
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
}
// ─── 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, "000000", 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)
}
})
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ Planning documents are not trackers. Do not read a defect count out of one.
| Phase order and gates | [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) |
| Current measured baseline | [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) |
Ledger at 2026-08-29: **315 fixed / 56 open / 3 declined / 1 duplicate = 375**.
Ledger at 2026-08-29: **315 fixed / 59 open / 3 declined / 1 duplicate = 378**.
All 38 open records still resolved to a live `file:line` at
`5cc0888964e26276d1aca145e83270a2c1b9febd` when that sweep was run — it was a
manual pass, not something a command reproduces. What the tooling does check:
+1 -1
View File
@@ -216,7 +216,7 @@ authority over the leftovers listed below. B1 is unblocked.
which also refutes its own header note: repository-settings writes were **not**
blocked from the agent sandbox.
- Step 8: individual adjudication of the 38 open `OC-*` records. The count was
verified as **315 fixed / 56 open / 3 declined / 1 duplicate = 375**, matching
verified as **315 fixed / 59 open / 3 declined / 1 duplicate = 378**, matching
the register, and a staleness pass confirmed **all 38 still resolve to a live
`file:line`** at this commit — none is superseded by later work, so all 38 are
genuinely open (11 medium, 27 low, all from hunt `general-2026-08-22-b`).
+3 -3
View File
@@ -37,7 +37,7 @@ baseline is **truthful, reproducible, and sufficient to begin B1**.
| Docker build + boot smoke | unavailable | pass | **pass**, 50.1 MB, boots `:8443` | `ENV-02` closed |
| Largest lazy chunk | — | budget in B7 | 1,998.25 kB min / 1,344.96 kB gzip | measured |
| Generated/doc drift | refresh in B0 | 0 | **0**`sqlc-verify`, `protocol-verify` green | CI |
| Ledger path resolution | — | 0 dead | **0 dead paths / 375 records** | re-verified 2026-08-29 |
| Ledger path resolution | — | 0 dead | **0 dead paths / 378 records** | re-verified 2026-08-29 |
| Desktop/browser/device matrix | incomplete | 100% by B10 | **incomplete** | B6B8 |
| 250/100/25 capacity profile | unproven | met by B6 | **unproven** | `S-14`, B6 |
| Upgrade/rollback/restore | unproven | green by B6 | **unproven** | B6 |
@@ -68,10 +68,10 @@ Open ledger, re-verified 2026-08-29:
| Status | Count |
| --------- | ------- |
| fixed | 315 |
| open | **56** |
| open | **59** |
| declined | 3 |
| duplicate | 1 |
| **total** | **375** |
| **total** | **378** |
Of the 56 open records:
@@ -77,10 +77,10 @@ together as if each row were a unique defect:
| Status | Count |
| --------- | ------: |
| Fixed | 315 |
| Open | 56 |
| Open | 59 |
| Declined | 3 |
| Duplicate | 1 |
| **Total** | **375** |
| **Total** | **378** |
The rows below cover `OC-0311``OC-0348` from the 2026-08-22 hunt, nine of
which have since been fixed; `OC-0349``OC-0375` are recorded in the ledger and