Files
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

1472 lines
65 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test
package user
// Tests for the loose-fake 2FA endpoints (GET /api/user/2fa/status,
// POST /api/user/2fa/setup|verify|disable). Every test that flips
// REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv must stay sequential (no
// t.Parallel): os.Getenv is process-global and t.Setenv panics under
// t.Parallel. Sequential tests in this package run before the parallel batch,
// so the enforced env never leaks into parallel tests.
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/twofa"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/require"
)
func twofaEnvEnforced(t *testing.T) {
t.Helper()
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
}
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
// before the pending code is invalidated and a new one must be requested.
const twoFAMaxAttempts = twofa.MaxAttempts
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
// resetting, and doubles as the stale-entry eviction horizon for the map.
const twoFAAttemptWindow = twofa.AttemptWindow
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest.
func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) }
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
// digest, always in constant time. Delegates to the shared implementation.
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
return twofa.VerifyHash(reqCode, storedHash)
}
func twofaEnvUnenforced(t *testing.T) {
t.Helper()
// Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
// (fail-closed), so an unenforced test must opt in via an explicit dev value.
t.Setenv("REQUIRE_2FA", "")
t.Setenv("SQUARE_ENVIRONMENT", "mock")
}
// performUser2FARequest invokes a handler with the authenticated-user context
// injected directly (the profile_test.go pattern). An empty userID simulates an
// unauthenticated request (no mw.UserIDKey in context).
func performUser2FARequest(t *testing.T, handler http.HandlerFunc, ctx context.Context, method, path string, body any, userID string) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != nil {
b, err := json.Marshal(body)
require.NoError(t, err)
req = httptest.NewRequest(method, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if userID != "" {
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
} else {
req = req.WithContext(ctx)
}
w := httptest.NewRecorder()
handler(w, req)
return w
}
// seedPendingTwoFA writes a known verification code's SHA-256 hash plus a fresh
// expiry into the user's pending columns, so enforced-mode verify tests don't
// depend on reading the logged code.
func seedPendingTwoFA(t *testing.T, ctx context.Context, q db.Querier, userID, code string) {
t.Helper()
_, err := q.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, hashTwoFACode(code), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
}
// extractCodeFromLog pulls the 6-digit code out of a captured [2FA] log line.
func extractCodeFromLog(t *testing.T, logOut string) string {
t.Helper()
m := regexp.MustCompile(`\[2FA\].*: (\d{6})`).FindStringSubmatch(logOut)
if len(m) < 2 {
return ""
}
return m[1]
}
func TestTwoFAStatus_NotEnabled(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID)
require.Equal(t, http.StatusOK, w.Code)
var resp TwoFAStatusResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.False(t, resp.Enabled, "fresh user must report 2FA disabled")
require.False(t, resp.Required, "unenforced env must report required=false")
require.Nil(t, resp.Method)
}
func TestTwoFAStatus_Required(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID)
require.Equal(t, http.StatusOK, w.Code)
var resp TwoFAStatusResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.False(t, resp.Enabled)
require.True(t, resp.Required, "enforced env must report required=true")
}
func TestTwoFASetup_InvalidMethod(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "carrier-pigeon"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestTwoFASetup_Valid_StoresHash(t *testing.T) {
twofaEnvUnenforced(t)
// Pin the pepper off so the stored hash assertion below is deterministic
// regardless of the ambient test environment.
t.Setenv("TWO_FACTOR_PEPPER", "")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp struct {
Message string `json:"message"`
Code string `json:"code"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "Code sent", resp.Message)
require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code")
// The DB must hold the digest of exactly the returned code. With the pepper
// pinned off, that is the plain SHA-256 (the legacy fallback).
var pendingHash, method sql.NullString
var expires sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_method, two_factor_pending_code_expires
FROM users WHERE id = $1`, userID).Scan(&pendingHash, &method, &expires))
require.True(t, pendingHash.Valid, "setup must write a pending code hash")
require.Equal(t, "email", method.String)
require.True(t, expires.Valid && expires.Time.After(clock.Now()), "pending code must have a future expiry")
sum := sha256.Sum256([]byte(resp.Code))
require.Equal(t, hex.EncodeToString(sum[:]), pendingHash.String, "stored hash must be the SHA-256 of the returned code")
}
func TestTwoFASetup_AlreadyEnabled_Conflict(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
require.NoError(t, err)
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID)
require.Equal(t, http.StatusConflict, w.Code)
}
func TestTwoFAVerify_WrongCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code)
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "wrong code must not enable 2FA")
}
func TestTwoFAVerify_CorrectCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]bool
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["enabled"])
var enabled bool
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &pendingHash))
require.True(t, enabled, "correct code must enable 2FA")
require.False(t, pendingHash.Valid, "pending code must be cleared after verification")
}
func TestTwoFAVerify_Expired(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// Seed the correct code but backdate the expiry so the handler's
// pendingExpires.After(clock.Now()) check fails.
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = NOW() - INTERVAL '1 minute'
WHERE id = $1`, userID, hashTwoFACode("123456"))
require.NoError(t, err)
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code)
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "expired code must not enable 2FA")
}
func TestTwoFAVerify_Unenforced_AnyCodeSucceeds(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// Dev bypass: in an unenforced env even an empty code with no pending row
// verifies.
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: ""}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled)
}
// TestTwoFADisable_CorrectCode verifies that disabling in an enforced env
// requires the pending code: the correct code clears the flag, method and
// pending fields.
func TestTwoFADisable_CorrectCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
var method sql.NullString
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &method, &pendingHash))
require.False(t, enabled, "disable must clear two_factor_enabled")
require.False(t, method.Valid, "disable must clear the method")
require.False(t, pendingHash.Valid, "disable must clear the pending code")
}
// TestTwoFADisable_WrongCode verifies that a wrong code leaves 2FA enabled:
// the gate cannot be lifted with the password alone.
func TestTwoFADisable_WrongCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled, "wrong code must not disable 2FA")
}
// TestTwoFADisable_NoPendingCode_GeneratesFreshCode verifies that disabling
// with no valid pending code delivers a fresh one via the [2FA] log channel and
// requires it before clearing the flag.
func TestTwoFADisable_NoPendingCode_GeneratesFreshCode(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// No pending code exists: the handler must generate + log a fresh code and
// reject the (empty) submission.
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "disable must log the fresh code as the delivery channel")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid, "disable must persist a fresh pending code when none existed")
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled, "fresh code must be verified before 2FA can be disabled")
}
// TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares
// the 5-attempt lockout with verify: 4 wrong codes 400, the 5th 429s and
// invalidates the pending code.
func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "lockout must invalidate the pending code")
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled, "locked-out user must still have 2FA enabled")
}
// TestTwoFA_VerifyAndDisable_SharedLockoutPersistsAcrossMints pins the shared
// per-user lockout across verify and disable under B11b: 5 wrong VERIFY
// attempts 429 and destroy the pending code; the disable flow mints a FRESH
// code, but the persistent failed-attempt counter is NOT reset by the mint — a
// stale OR the freshly-minted code both stay 429 until the attempt window
// elapses.
func TestTwoFA_VerifyAndDisable_SharedLockoutPersistsAcrossMints(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
// Burn all 5 attempts on VERIFY: 4 wrong 400, the 5th 429 + code destroyed.
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "verify attempt %d", i+1)
}
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
// B11b: the lockout survives the fresh-code mint — the stale submission
// stays throttled (429), it is NOT re-budgeted (400).
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "stale code must stay locked out after verify lockout; body: %s", w.Body.String())
// The freshly minted code is ALSO throttled: the persistent counter caps
// total failed attempts per code lifetime, not per code instance.
freshCode := extractCodeFromLog(t, buf.String())
require.NotEmpty(t, freshCode, "disable must deliver a fresh code after verify lockout")
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: freshCode}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "fresh code must not reset the shared lockout; body: %s", w.Body.String())
// Once the attempt window elapses (the code lifetime), the valid pending
// code verifies and disables 2FA.
st := twoFAAttemptStateFor(userID)
st.SetLastActive(clock.Now().Add(-twoFAAttemptWindow - time.Second))
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: freshCode}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "fresh code must disable 2FA after the shared lockout window elapses")
}
// TestTwoFADisable_Unenforced_NoCodeRequired verifies the dev bypass: in an
// unenforced env disabling works with no code at all.
func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "dev bypass must disable without a code")
}
func TestTwoFA_Unauthenticated(t *testing.T) {
tests := []struct {
name string
method string
path string
body any
}{
{"status", http.MethodGet, "/api/user/2fa/status", nil},
{"setup", http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}},
{"verify", http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}},
{"disable", http.MethodPost, "/api/user/2fa/disable", nil},
}
handlers := map[string]http.HandlerFunc{
"status": GetTwoFAStatusHandler,
"setup": SetupTwoFAHandler,
"verify": VerifyTwoFAHandler,
"disable": DisableTwoFAHandler,
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := performUser2FARequest(t, handlers[tt.name], context.Background(), tt.method, tt.path, tt.body, "")
require.Equal(t, http.StatusUnauthorized, w.Code)
})
}
}
func TestProfile_Get_IncludesTwoFAState(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, GetProfileHandler, ctx, http.MethodGet, "/api/user/profile", nil, userID)
require.Equal(t, http.StatusOK, w.Code)
var profile UserProfile
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &profile))
require.True(t, profile.TwoFactorEnabled)
require.True(t, profile.TwoFactorRequired, "profile must expose the enforced flag")
require.NotNil(t, profile.TwoFactorMethod)
require.Equal(t, "email", *profile.TwoFactorMethod)
}
func TestTwoFA_FailClosedDefaultEnforced(t *testing.T) {
// Empty SQUARE_ENVIRONMENT (a misconfigured prod deploy) must default to
// ENFORCED, never silently disable the gate.
t.Setenv("REQUIRE_2FA", "")
t.Setenv("SQUARE_ENVIRONMENT", "")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID)
require.Equal(t, http.StatusOK, w.Code)
var resp TwoFAStatusResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp.Required, "empty SQUARE_ENVIRONMENT must be treated as enforced (fail-closed)")
}
func TestTwoFASetup_Enforced_NoCodeInResponse(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
_, hasCode := resp["code"]
require.False(t, hasCode, "enforced setup must NOT return the code in the response")
}
func TestTwoFASetup_CodeAlwaysLoggedAsDeliveryChannel(t *testing.T) {
// Capture the standard logger so we can assert on what setup logs.
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
// Enforced (production): the plaintext code MUST be logged — the [2FA] log
// line is the only delivery channel until email/SMS lands, and an operator
// relays it to the user out-of-band. Without it, enforced-mode 2FA is a
// dead-end (every online saved-card charge stays 403).
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "enforced setup must log the plaintext code as the delivery channel")
// Unenforced (dev): the plaintext code is also logged for the loose-fake flow.
buf.Reset()
twofaEnvUnenforced(t)
ctx2, tx2 := testutils.SetupTestTx(t)
userID2, err := fixtures.CreateTestUser(tx2)
require.NoError(t, err)
w = performUser2FARequest(t, SetupTwoFAHandler, ctx2, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID2)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "unenforced setup must log the plaintext code")
}
// TestTwoFASetup_MintThrottled verifies the B11a fix: the setup path applies
// the same per-user mint cooldown as the disable flow, so a setup-spam loop
// cannot mint fresh codes faster than once per twoFAMintCooldown and keep a
// guessing budget alive.
func TestTwoFASetup_MintThrottled(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// First setup is allowed and mints a code.
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// A second setup inside the cooldown is throttled with 429.
w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Wait before requesting a new code.")
// Elapsing the cooldown allows a fresh mint (the test cannot wait a minute).
st := twoFAAttemptStateFor(userID)
st.LastMintAt = clock.Now().Add(-twoFAMintCooldown - time.Second)
w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
func TestTwoFAVerify_LockoutAfterFiveFailedAttempts(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
// Attempts 1-4: plain 400.
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
// Attempt 5: lockout — 429 and the pending code is invalidated.
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "lockout must invalidate the pending code")
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "locked-out user must not be enabled")
// Attempt 6: still 429 (even with the correct code) until a new code is
// requested via setup.
w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "post-lockout attempts must keep returning 429")
}
func TestTwoFAVerify_NewCodeViaSetup_DoesNotResetLockout(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
// Reach lockout: 4 plain 400s, then the 5th failure locks out.
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
// B11b: a fresh code via setup does NOT reset the persistent failed-attempt
// counter (it resets only on a SUCCESSFUL verify). The mint is allowed
// (first mint, no cooldown) but the lockout survives it.
w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// The locked-out user stays locked out even with a fresh code pending.
seedPendingTwoFA(t, ctx, tx, userID, "654321")
w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "654321"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "counter must NOT reset on re-mint; body: %s", w.Body.String())
// Only a SUCCESSFUL verify resets the counter. Elapse the attempt window
// (the code lifetime) so the stale counter is dropped, then a fresh verify
// succeeds and clears it.
st := twoFAAttemptStateFor(userID)
st.SetLastActive(clock.Now().Add(-twoFAAttemptWindow - time.Second))
seedPendingTwoFA(t, ctx, tx, userID, "111111")
w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "111111"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
func TestTwoFAVerify_WrongCodesAnyLengthRejected(t *testing.T) {
// Exercises the constant-time compare path: wrong codes of any length and
// shape fail identically (400) without enabling, while the correct code
// still succeeds — no length-based early exit leaks match information.
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
for _, code := range []string{"12345", "1234567", "abcdef", ""} {
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: code}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "wrong code %q must be rejected", code)
}
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled)
// Four wrong attempts were consumed above; one more would lock out. Use a
// fresh user to prove the correct code still verifies.
userID2, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID2, "123456")
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID2)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// =============================================================================
// Pepper hashing (finding c)
// =============================================================================
// TestTwoFAPepper_HashUsesHMAC verifies that with TWO_FACTOR_PEPPER set the
// stored digest is HMAC-SHA256 keyed by the pepper, NOT the legacy unsalted
// SHA-256 — so a leaked digest cannot be brute-forced offline.
func TestTwoFAPepper_HashUsesHMAC(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
const code = "123456"
got := hashTwoFACode(code)
mac := hmac.New(sha256.New, []byte("test-pepper-secret"))
mac.Write([]byte(code))
want := hex.EncodeToString(mac.Sum(nil))
require.Equal(t, want, got, "stored hash must be HMAC-SHA256 keyed by TWO_FACTOR_PEPPER")
require.NotEqual(t, legacyHashTwoFACode(code), got, "pepper'd hash must differ from the legacy plain SHA-256")
}
// TestTwoFAPepper_UnsetFallback_PlainSHA256 verifies the graceful no-pepper
// fallback keeps the legacy unsalted SHA-256 digest when TWO_FACTOR_PEPPER is
// unset.
func TestTwoFAPepper_UnsetFallback_PlainSHA256(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "")
const code = "654321"
got := hashTwoFACode(code)
sum := sha256.Sum256([]byte(code))
require.Equal(t, hex.EncodeToString(sum[:]), got, "unset pepper must fall back to legacy plain SHA-256")
require.Equal(t, legacyHashTwoFACode(code), got)
}
// TestTwoFAPepper_LegacyHashDetected verifies verifyTwoFACodeHash accepts both
// the pepper'd and the legacy plain forms (the transition window) and flags
// legacy rows for upgrade.
func TestTwoFAPepper_LegacyHashDetected(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
const code = "123456"
match, legacy := verifyTwoFACodeHash(code, hashTwoFACode(code))
require.True(t, match)
require.False(t, legacy, "pepper'd stored hash must not be flagged for upgrade")
match, legacy = verifyTwoFACodeHash(code, legacyHashTwoFACode(code))
require.True(t, match)
require.True(t, legacy, "legacy stored hash must verify and flag the upgrade")
match, legacy = verifyTwoFACodeHash("999999", legacyHashTwoFACode(code))
require.False(t, match)
require.False(t, legacy)
}
// TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify verifies that a legacy
// pre-pepper row still verifies during the migration window AND that the stored
// hash is upgraded to the pepper'd form on the next successful verify (the
// plain digest is retired).
func TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// Seed a legacy row exactly as the pre-pepper code wrote it: plain SHA-256.
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
// checkTwoFACode (the shared verify path) must accept the legacy hash.
st := &twofa.AttemptState{}
st.SetLastActive(clock.Now())
req := httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(ctx)
result, err := checkTwoFACode(req, userID, st, "123456")
require.NoError(t, err)
require.Equal(t, twoFACodeOK, result)
// The stored hash must now be the pepper'd form.
var stored sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&stored))
require.True(t, stored.Valid, "checkTwoFACode alone must not clear the pending hash")
require.Equal(t, hashTwoFACode("123456"), stored.String, "legacy hash must be upgraded to the pepper'd form on successful verify")
}
// TestTwoFAVerify_LegacyHash_StillVerifies pins the end-to-end migration
// window: an enforced env with the pepper set must still accept a user whose
// pending code was hashed the old (pre-pepper) way.
func TestTwoFAVerify_LegacyHash_StillVerifies(t *testing.T) {
twofaEnvEnforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled)
}
// TestTwoFAVerify_LegacyHash_WrongCodeRejected verifies the legacy path still
// enforces the correct code: a wrong code against a legacy-hashed row is
// rejected and 2FA stays off.
func TestTwoFAVerify_LegacyHash_WrongCodeRejected(t *testing.T) {
twofaEnvEnforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled)
}
// =============================================================================
// Disable-flow mint throttle (finding d)
// =============================================================================
// TestTwoFADisable_MintThrottled_BoundsGuessing verifies the unlimited-guess
// loop is closed: a password-only attacker who burns the 5-attempt budget on a
// freshly minted code cannot mint ANOTHER fresh code (which would reset the
// counter) inside the per-user mint cooldown. Exactly one fresh code is minted
// across the whole loop and the throttled request returns 429.
func TestTwoFADisable_MintThrottled_BoundsGuessing(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// Request 1 mints a fresh code (the first mint is allowed) and rejects the
// wrong submission; requests 2-5 reuse that code, reaching the lockout.
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
// Request 6 is inside the cooldown: no fresh code may be minted, so the loop
// stops with 429 instead of minting an unlimited series of fresh codes.
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Wait before requesting a new code.")
// Exactly ONE fresh code was minted across all six requests — the loop can
// no longer reset the attempt budget. Count the fresh-delivery marker
// ("[2FA] code delivery requested") rather than the purpose substring:
// finding 2b added a "[2FA] code reused" log line that also names the
// purpose, so a raw purpose count would over-count.
mints := strings.Count(buf.String(), "[2FA] code delivery requested")
require.Equal(t, 1, mints, "expected exactly 1 fresh-code mint; log:\n%s", buf.String())
}
// TestTwoFADisable_MintThrottle_ExpiresAllowsRecovery verifies the documented
// residual is not a permanent lockout: once the mint cooldown AND the attempt
// window (the code lifetime) elapse, a legitimate code-lost user can mint and
// verify a fresh code again. Under B11b the failed-attempt counter persists
// across mints, so elapsing only the mint cooldown is NOT enough — the user
// must also wait out the 10-minute attempt window.
func TestTwoFADisable_MintThrottle_ExpiresAllowsRecovery(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// Burn the budget: 4 wrong 400s, the 5th locks out (429).
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "mint must be throttled inside the cooldown")
// Elapsing ONLY the mint cooldown still leaves the user locked out: the
// persistent failed-attempt counter (B11b) survives the fresh mint (the
// mint itself writes the new [2FA] log line).
st := twoFAAttemptStateFor(userID)
st.LastMintAt = clock.Now().Add(-twoFAMintCooldown - time.Second)
buf.Reset()
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "persistent counter must keep the user locked until the attempt window elapses")
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "mint cooldown elapse must allow a fresh mint")
// Elapse the attempt window too (simulate a real user waiting it out): the
// valid pending code is reused and the lockout lapses — the wrong code is
// rejected with 400 (a fresh 5-attempt budget), not 429.
st.SetLastActive(clock.Now().Add(-twoFAAttemptWindow - time.Second))
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
// After the successful disable (2FA now off), the counter is cleared — a
// subsequent re-enable + verify starts from a fresh budget.
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// =============================================================================
// Attempt-map eviction (finding e)
// =============================================================================
// TestTwoFAAttemptMap_InLockoutRecordNotEvicted verifies the eviction fix: a
// record still inside its lockout window is NEVER evicted by LRU pressure — a
// hostile flood of new keys cannot reset the victim's attempt counter. Only an
// idle/expired record is dropped to make room.
func TestTwoFAAttemptMap_InLockoutRecordNotEvicted(t *testing.T) {
twofa.MapMu.Lock()
origMap := twofa.Map
origCap := twofa.MaxTrackedAttempts
twofa.Map = make(map[string]*twoFAAttemptState)
twofa.MaxTrackedAttempts = 4
twofa.MapMu.Unlock()
t.Cleanup(func() {
twofa.MapMu.Lock()
twofa.Map = origMap
twofa.MaxTrackedAttempts = origCap
twofa.MapMu.Unlock()
})
now := clock.Now()
for _, id := range []string{"idle_a", "idle_b", "idle_c"} {
st := &twofa.AttemptState{}
st.SetLastActive(now.Add(-time.Minute))
twofa.Map[id] = st
}
victim := &twofa.AttemptState{}
victim.SetLastActive(now.Add(-time.Second))
victim.Count.Store(5)
twofa.Map["victim"] = victim
// A new user hits the cap: the eviction must drop an idle record, never the
// in-lockout victim.
st := twoFAAttemptStateFor("new_user")
require.NotNil(t, st)
if _, ok := twofa.Map["victim"]; !ok {
t.Error("in-lockout record must never be evicted by LRU pressure")
}
if got := twofa.Map["victim"].Count.Load(); got != 5 {
t.Errorf("victim attempt count must survive eviction pressure, got %d", got)
}
if len(twofa.Map) > 4 {
t.Errorf("map must stay within the cap, got %d entries", len(twofa.Map))
}
if _, ok := twofa.Map["new_user"]; !ok {
t.Error("new user must be tracked in the map")
}
}
// TestTwoFAAttemptMap_FullOfLockedOut_ReturnsPermanentlyLocked verifies the
// pathological case: when every entry is a locked-out in-window record (a
// flood), the map does NOT evict one and does NOT grow past the cap — the new
// user gets the SHARED permanently-locked state instead of a transient state
// with a fresh guessing budget (Round 2 Loop A finding 3: the old per-request
// transient silently disabled the 5-attempt lockout exactly under attack).
func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsPermanentlyLocked(t *testing.T) {
twofa.MapMu.Lock()
origMap := twofa.Map
origCap := twofa.MaxTrackedAttempts
twofa.Map = make(map[string]*twoFAAttemptState)
twofa.MaxTrackedAttempts = 3
twofa.MapMu.Unlock()
t.Cleanup(func() {
twofa.MapMu.Lock()
twofa.Map = origMap
twofa.MaxTrackedAttempts = origCap
twofa.MapMu.Unlock()
})
now := clock.Now()
for i := 0; i < 3; i++ {
st := &twofa.AttemptState{}
st.SetLastActive(now.Add(-time.Second))
st.Count.Store(5)
twofa.Map[fmt.Sprintf("locked_%d", i)] = st
}
st := twoFAAttemptStateFor("new_user")
require.NotNil(t, st)
require.True(t, st.LockedOut(clock.Now()), "an untracked user under map saturation must be treated as permanently locked out")
if _, ok := twofa.Map["new_user"]; ok {
t.Error("expected the shared permanently-locked state NOT to be stored when the map is full of in-lockout records")
}
if len(twofa.Map) != 3 {
t.Errorf("expected all 3 locked-out records to survive, got %d", len(twofa.Map))
}
}
// =============================================================================
// Attempt-map concurrency (finding-f — lastAt data race)
// =============================================================================
// TestTwoFAAttemptMap_ConcurrentVerifyAndEviction is a -race smoke test for the
// attempt-map data race: checkTwoFACode's st.mu-guarded writes to count and
// lastAt run concurrently with twoFAAttemptStateFor's mapMu-only eviction scan
// reading them. lastAt is an atomic.Int64 (nanoseconds since the epoch), so the
// scan and lockedOut read it without st.mu — no mutex inversion (mapMu→st.mu is
// forbidden) and no torn 8-byte timestamp. Asserts every concurrent call
// completes (no deadlock), pre-pinned locked-out records survive the eviction
// pressure, and the map never grows past the cap.
func TestTwoFAAttemptMap_ConcurrentVerifyAndEviction(t *testing.T) {
twofa.MapMu.Lock()
origMap := twofa.Map
origCap := twofa.MaxTrackedAttempts
twofa.Map = make(map[string]*twoFAAttemptState)
twofa.MaxTrackedAttempts = 128
twofa.MapMu.Unlock()
t.Cleanup(func() {
twofa.MapMu.Lock()
twofa.Map = origMap
twofa.MaxTrackedAttempts = origCap
twofa.MapMu.Unlock()
})
const verifyWorkers = 6
const evictWorkers = 4
const iters = 25
// Pre-pin locked-out victims so we can assert afterwards that in-window
// lockout records are never evicted under concurrent pressure.
now := clock.Now()
victims := make(map[string]*twoFAAttemptState, verifyWorkers)
twofa.MapMu.Lock()
for i := 0; i < verifyWorkers; i++ {
st := &twofa.AttemptState{}
st.SetLastActive(now.Add(-time.Second))
st.Count.Store(twoFAMaxAttempts)
id := fmt.Sprintf("victim_%d", i)
twofa.Map[id] = st
victims[id] = st
}
twofa.MapMu.Unlock()
var wg sync.WaitGroup
// Verifiers mirror checkTwoFACode's critical section on shared states: take
// st.mu, reset an expired window, bump the counter, stamp lastAt, and read
// lockedOut — overlapping the eviction scan's lock-free atomic reads.
for w := 0; w < verifyWorkers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for iter := 0; iter < iters; iter++ {
st := twoFAAttemptStateFor(fmt.Sprintf("verify_%d_%d", w, iter))
st.Mu.Lock()
if now := clock.Now(); now.Sub(st.LastActive()) > twoFAAttemptWindow {
st.Count.Store(0)
st.SetLastActive(now)
}
_ = st.LockedOut(clock.Now())
st.Count.Add(1)
st.SetLastActive(clock.Now())
st.Mu.Unlock()
}
}(w)
}
// Evictors drive twoFAAttemptStateFor's cap-driven eviction scan, which
// reads count + lastAt WITHOUT st.mu — the access pattern under test.
for w := 0; w < evictWorkers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for iter := 0; iter < 2000; iter++ {
_ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter))
}
}(w)
}
wg.Wait()
twofa.MapMu.Lock()
defer twofa.MapMu.Unlock()
for id, st := range victims {
if _, ok := twofa.Map[id]; !ok {
t.Errorf("in-window lockout record %s was evicted under concurrent pressure", id)
}
if !st.LockedOut(clock.Now()) {
t.Errorf("victim %s must still report locked out", id)
}
}
if len(twofa.Map) > twofa.MaxTrackedAttempts {
t.Errorf("map grew past the cap: %d > %d", len(twofa.Map), twofa.MaxTrackedAttempts)
}
}
// TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock runs the REAL verify path
// concurrently: each goroutine mints its own transaction and user (pgx.Tx is
// not concurrency-safe, so per-goroutine tx avoids sharing one), burns the
// 5-attempt budget to lockout, and asserts lockedOut afterwards — while other
// goroutines hammer the map eviction scan through twoFAAttemptStateFor. The
// test completes only if no goroutine deadlocks on mapMu/st.mu.
func TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock(t *testing.T) {
twofa.MapMu.Lock()
origMap := twofa.Map
origCap := twofa.MaxTrackedAttempts
twofa.Map = make(map[string]*twoFAAttemptState)
// Cap high enough that the ~90 locked-out users this test accumulates
// (6 workers × 15 iters) never saturate the map: under saturation the
// shared permanently-locked fallback (finding 3) would hand fresh users a
// pre-locked state and every "attempt 1" would wrongly report locked out.
// Saturation itself is covered by
// TestTwoFAAttemptMap_FullOfLockedOut_ReturnsPermanentlyLocked.
twofa.MaxTrackedAttempts = 512
twofa.MapMu.Unlock()
t.Cleanup(func() {
twofa.MapMu.Lock()
twofa.Map = origMap
twofa.MaxTrackedAttempts = origCap
twofa.MapMu.Unlock()
})
const workers = 6
const iters = 15
var wg sync.WaitGroup
errCh := make(chan error, workers)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
ctx := context.Background()
tx, err := db.Conn.Pool().Begin(ctx)
if err != nil {
errCh <- fmt.Errorf("worker %d begin: %w", w, err)
return
}
defer tx.Rollback(context.Background())
tctx := db.ContextWithTx(ctx, tx)
for iter := 0; iter < iters; iter++ {
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
errCh <- fmt.Errorf("worker %d iter %d create user: %w", w, iter, err)
return
}
seedPendingTwoFA(t, tctx, tx, userID, "123456")
st := twoFAAttemptStateFor(userID)
for attempt := 1; attempt <= twoFAMaxAttempts; attempt++ {
st.Mu.Lock()
res, err := checkTwoFACode(httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(tctx), userID, st, "999999")
st.Mu.Unlock()
if err != nil {
errCh <- fmt.Errorf("worker %d iter %d check: %w", w, iter, err)
return
}
want := twoFACodeIncorrect
if attempt == twoFAMaxAttempts {
want = twoFACodeLockedOut
}
if res != want {
errCh <- fmt.Errorf("worker %d iter %d attempt %d: got %v, want %v", w, iter, attempt, res, want)
return
}
}
if !st.LockedOut(clock.Now()) {
errCh <- fmt.Errorf("worker %d iter %d: must be locked out after %d wrong codes", w, iter, twoFAMaxAttempts)
return
}
}
}(w)
}
// Concurrent map pressure: twoFAAttemptStateFor reads count + lastAt under
// mapMu only, racing the workers' st.mu-guarded writes (the old data race).
for w := 0; w < 4; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for iter := 0; iter < 1000; iter++ {
_ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter))
}
}(w)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
// =============================================================================
// Disable-flow code mint endpoint (POST /api/user/2fa/disable/code)
// =============================================================================
// TestTwoFADisableCode_MintsFreshCode verifies that with no pending code the
// endpoint mints a fresh one and persists it (hash + unexpired expiry), so the
// frontend's disable code-entry step has a delivered code to verify against.
func TestTwoFADisableCode_MintsFreshCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var pendingHash sql.NullString
var expires sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users WHERE id = $1`, userID).Scan(&pendingHash, &expires))
require.True(t, pendingHash.Valid, "disable/code must mint a pending code hash")
require.True(t, expires.Valid && expires.Time.After(clock.Now()), "minted code must have a future expiry")
}
// TestTwoFADisableCode_ReusesValidPendingCode verifies that a valid unexpired
// pending code is reused by EnsurePendingTwoFACode (the stored hash is
// unchanged) instead of minting a fresh one.
func TestTwoFADisableCode_ReusesValidPendingCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid)
require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted")
}
// TestTwoFADisableCode_MintThrottled verifies the per-user mint cooldown: a
// second code request inside twoFAMintCooldown returns 429. The pending code is
// dropped first (as a lockout does) because a still-valid code is reused by
// EnsurePendingTwoFACode, which short-circuits the cooldown check.
func TestTwoFADisableCode_MintThrottled(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// First request mints a fresh code and stamps the per-user cooldown.
w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Drop the pending code so the next request cannot reuse it and must hit
// the cooldown check instead.
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL
WHERE id = $1`, userID)
require.NoError(t, err)
w = performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Too many attempts. Wait before requesting a new code.")
}
// TestTwoFADisableCode_Unauthorized verifies that an unauthenticated request is
// rejected with 401 before any minting happens.
func TestTwoFADisableCode_Unauthorized(t *testing.T) {
w := performUser2FARequest(t, SendDisableCodeHandler, context.Background(), http.MethodPost, "/api/user/2fa/disable/code", nil, "")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
// TestTwoFADisableCode_UnenforcedStillMints verifies the endpoint mints a
// pending code in unenforced (dev) environments too — the disable handler's dev
// bypass needs no code, but the endpoint still runs unconditionally so the
// code-entry step is exercisable locally.
func TestTwoFADisableCode_UnenforcedStillMints(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SendDisableCodeHandler, ctx, http.MethodPost, "/api/user/2fa/disable/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid, "unenforced env must still mint a pending code")
}
// TestTwoFASendVerificationCode_Enabled_MintsFresh verifies POST
// /api/user/2fa/code: an ENABLED user with no pending code gets a fresh code
// minted + delivered ([2FA] log labelled "saved-card charge"), with only the
// hash + a future expiry persisted and no code in the enforced response.
func TestTwoFASendVerificationCode_Enabled_MintsFresh(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "Code sent", resp["message"])
_, hasCode := resp["code"]
require.False(t, hasCode, "enforced env must NOT return the code in the response")
require.Equal(t, float64(600), resp["remaining_seconds"], "a fresh mint must report the full 10-minute lifetime (LOW 5)")
var pendingHash sql.NullString
var expires sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users WHERE id = $1`, userID).Scan(&pendingHash, &expires))
require.True(t, pendingHash.Valid, "endpoint must mint a pending code hash")
require.True(t, expires.Valid && expires.Time.After(clock.Now()), "minted code must have a future expiry")
require.Contains(t, buf.String(), "saved-card charge", "delivery log must label the charge purpose")
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "endpoint must log the code as the delivery channel")
}
// TestTwoFASendVerificationCode_ReusesValidPendingCode verifies that a valid
// unexpired pending code is reused (the stored hash is unchanged) instead of a
// fresh mint, so a mid-flow charge retry is not throttled — and (finding 2b)
// that the reuse leaves an auditable [2FA] log line instead of being silent.
func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "Code sent", resp["message"])
// LOW 5: the reused pending code's remaining lifetime is reported so the
// client knows a NEW code was NOT minted and can count down the existing one.
rem, ok := resp["remaining_seconds"].(float64)
require.True(t, ok, "response must include remaining_seconds")
require.Greater(t, rem, 0.0, "reused code must still have lifetime remaining")
require.LessOrEqual(t, rem, 600.0, "reused code lifetime must not exceed the 10-minute window")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid)
require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted")
// Finding 2b: reuse must be visible in the [2FA] log stream (no fresh code
// is delivered, so this is the only audit trace of the reuse).
require.Contains(t, buf.String(), "[2FA] code reused", "a reused code must leave a [2FA] log line")
require.NotContains(t, buf.String(), "[2FA] code:", "a reused code must NOT be logged as a fresh delivery")
}
// TestTwoFASendVerificationCode_NotEnabled_409 verifies that a user who has NOT
// enabled 2FA is refused with 409 (the endpoint exists only to re-challenge an
// enabled user's saved-card charge; setup covers the not-enabled path).
func TestTwoFASendVerificationCode_NotEnabled_409(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
}
// TestTwoFASendVerificationCode_MintThrottled verifies the per-user mint
// cooldown applies: a second code request inside twoFAMintCooldown returns 429.
// The pending code is dropped first (as a lockout does) because a still-valid
// code is reused by EnsurePendingTwoFACode, which short-circuits the cooldown.
func TestTwoFASendVerificationCode_MintThrottled(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Drop the pending code so the next request cannot reuse it and must hit
// the cooldown check instead.
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL
WHERE id = $1`, userID)
require.NoError(t, err)
w = performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Too many attempts. Wait before requesting a new code.")
}
// TestTwoFASendVerificationCode_Unenforced_ReturnsCode verifies the dev
// convenience: in an unenforced env the endpoint mints and returns the code in
// the response (matching setup), and the DB holds the digest of exactly it.
func TestTwoFASendVerificationCode_Unenforced_ReturnsCode(t *testing.T) {
twofaEnvUnenforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp struct {
Message string `json:"message"`
Code string `json:"code"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "Code sent", resp.Message)
require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid)
sum := sha256.Sum256([]byte(resp.Code))
require.Equal(t, hex.EncodeToString(sum[:]), pendingHash.String, "stored hash must be the SHA-256 of the returned code")
}
// TestTwoFASendVerificationCode_Unauthorized verifies that an unauthenticated
// request is rejected with 401 before any minting happens.
func TestTwoFASendVerificationCode_Unauthorized(t *testing.T) {
w := performUser2FARequest(t, SendVerificationCodeHandler, context.Background(), http.MethodPost, "/api/user/2fa/code", nil, "")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
// TestTwoFACodeVerifyForUser exercises the exported helper that backs the
// B6/B10 payments gate (the saved-card charge must present a real 2FA
// challenge): correct code → nil, wrong code → twofa.ErrIncorrect, exhausting
// the budget → twofa.ErrLockedOut, and no pending code → twofa.ErrMissingOrExpired.
func TestTwoFACodeVerifyForUser(t *testing.T) {
twofaEnvEnforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
require.NoError(t, VerifyTwoFACodeForUser(ctx, userID, "123456"), "correct code must verify")
// A correct verify clears the counter, so a second correct code also works.
require.NoError(t, VerifyTwoFACodeForUser(ctx, userID, "123456"))
// Wrong code → ErrIncorrect.
err = VerifyTwoFACodeForUser(ctx, userID, "999999")
require.ErrorIs(t, err, twofa.ErrIncorrect)
// Four more wrong codes reach the 5-attempt cap → ErrLockedOut.
for i := 0; i < 4; i++ {
_ = VerifyTwoFACodeForUser(ctx, userID, "999999")
}
err = VerifyTwoFACodeForUser(ctx, userID, "999999")
require.ErrorIs(t, err, twofa.ErrLockedOut, "after 5 consecutive failures the helper must lock out")
// A user with no pending code → ErrMissingOrExpired.
userID2, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
err = VerifyTwoFACodeForUser(ctx, userID2, "123456")
require.ErrorIs(t, err, twofa.ErrMissingOrExpired)
}