Files
Crussell/backend/handlers/user/twofa_test.go
T
popertots e9b0f0f2a7 fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
2026-08-22 00:34:49 +01:00

453 lines
18 KiB
Go

//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/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"os"
"regexp"
"testing"
"crussell/clock"
"crussell/db"
"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")
}
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)
}
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)
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 SHA-256 digest of exactly the returned code.
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)
}
func TestTwoFADisable(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, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", nil, userID)
require.Equal(t, http.StatusOK, w.Code)
var enabled bool
var method sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method FROM users WHERE id = $1", userID).Scan(&enabled, &method))
require.False(t, enabled, "disable must clear two_factor_enabled")
require.False(t, method.Valid, "disable must clear the method")
}
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_CodeLoggedOnlyWhenUnenforced(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: the [2FA] log line must carry the delivery note but never the
// plaintext code — a production misconfig must not leak codes to stdout.
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())
out := buf.String()
require.Contains(t, out, "NOT SENT, fake delivery")
require.NotContains(t, out, "verification code for user", "enforced setup must NOT log the plaintext code")
// Unenforced (dev): the plaintext code IS 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")
}
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_NewCodeViaSetupResetsLockout(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())
// Requesting a new code via setup resets the attempt counter, so
// verification is possible again.
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 setup-generated code is unknown (enforced), so seed a fresh known
// code and confirm the reset allows verification.
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.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())
}