Files
Crussell/backend/handlers/user/admin_twofa_test.go
T
popertots 9a182db932 fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
Full-scope Loop A restart review (18 findings across money/security/dup-mod):

MONEY:
- HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking
- MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount
- MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit
- MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx)
- LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded

SECURITY:
- 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure)
- Admin 2FA mint now writes admin_audit_log + logs code reuse
- Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account
- Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts)
- family-alive cache invalidated on password change / GDPR erasure
- Login lockout keyed per user+IP with a capped ceiling

FRONTEND/DUP-MOD:
- OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware)
- PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard)
- requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode)
- BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
2026-08-22 00:34:50 +01:00

311 lines
14 KiB
Go

//go:build test
package user
// Tests for admin 2FA management: the 2FA fields exposed by
// GET /api/admin/users/{id} (AdminUserDetail) and the admin-only recovery route
// POST /api/admin/users/{id}/2fa/remove (AdminRemoveUser2FAHandler), plus the
// two_factor_last_used_at stamping on successful verification. Sequential only
// (no t.Parallel): the enforced-mode test flips process-global env vars, and
// the package shares db.Conn state.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"os"
"regexp"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
)
// makeAdmin2FARequest builds a request with the given role in context (admin
// for the happy paths, verified_email for the RequireAdmin gate test) and the
// {id} route param parsed from the path (extractAdminUserID, from
// admin_handlers_test.go).
func makeAdmin2FARequest(handler http.Handler, method, path, role string, ctx context.Context) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
rctx := chi.NewRouteContext()
if id, ok := extractAdminUserID(path); ok {
rctx.URLParams.Add("id", id)
}
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-id")
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// seedUser2FATurnedOn enables 2FA for a user with a method, a pending code, and
// a last-used stamp so admin view/remove tests start from a fully-populated row.
func seedUser2FATurnedOn(t *testing.T, ctx context.Context, q db.Querier, userID string) {
t.Helper()
_, err := q.Exec(ctx, `
UPDATE users
SET two_factor_enabled = true,
two_factor_method = 'sms',
two_factor_pending_code_hash = 'abcdef',
two_factor_pending_code_expires = $2,
two_factor_last_used_at = $3
WHERE id = $1
`, userID, clock.Now().Add(5*time.Minute), clock.Now().Add(-24*time.Hour))
require.NoError(t, err)
}
// TestAdminUsers_Get_IncludesTwoFAState verifies GET /api/admin/users/{id}
// exposes two_factor_enabled, two_factor_method and two_factor_last_used_at.
func TestAdminUsers_Get_IncludesTwoFAState(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedUser2FATurnedOn(t, ctx, tx, userID)
handler := http.HandlerFunc(GetAdminUserHandler)
w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, true, resp["twoFactorEnabled"])
require.Equal(t, "sms", resp["twoFactorMethod"])
lastUsed, ok := resp["twoFactorLastUsedAt"].(string)
require.True(t, ok, "twoFactorLastUsedAt should serialize as a string")
require.NotEmpty(t, lastUsed)
}
// TestAdminUsers_Get_TwoFADisabledIsFalse verifies the 2FA fields serialize
// sanely for a user who never enabled 2FA (false, nil method/last-used).
func TestAdminUsers_Get_TwoFADisabledIsFalse(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
handler := http.HandlerFunc(GetAdminUserHandler)
w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, false, resp["twoFactorEnabled"])
_, hasMethod := resp["twoFactorMethod"]
require.False(t, hasMethod, "twoFactorMethod should be omitted when nil")
_, hasLastUsed := resp["twoFactorLastUsedAt"]
require.False(t, hasLastUsed, "twoFactorLastUsedAt should be omitted when nil")
}
// TestAdminUsers_Remove2FA_ClearsAllColumns verifies the admin recovery route
// clears the enabled flag, method, pending code fields and last-used stamp.
func TestAdminUsers_Remove2FA_ClearsAllColumns(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedUser2FATurnedOn(t, ctx, tx, userID)
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "admin", ctx)
require.Equal(t, http.StatusOK, w.Code)
var enabled bool
var method, pendingHash sql.NullString
var pendingExpires, lastUsed sql.NullTime
err = tx.QueryRow(ctx, `
SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash,
two_factor_pending_code_expires, two_factor_last_used_at
FROM users WHERE id = $1
`, userID).Scan(&enabled, &method, &pendingHash, &pendingExpires, &lastUsed)
require.NoError(t, err)
require.False(t, enabled, "two_factor_enabled should be false after admin removal")
require.False(t, method.Valid, "two_factor_method should be NULL after admin removal")
require.False(t, pendingHash.Valid, "two_factor_pending_code_hash should be NULL after admin removal")
require.False(t, pendingExpires.Valid, "two_factor_pending_code_expires should be NULL after admin removal")
require.False(t, lastUsed.Valid, "two_factor_last_used_at should be NULL after admin removal")
}
// TestAdminUsers_Remove2FA_UnknownUser_NotFound verifies a valid-format ID that
// matches no user row returns 404.
func TestAdminUsers_Remove2FA_UnknownUser_NotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/000000000000/2fa/remove", "admin", ctx)
require.Equal(t, http.StatusNotFound, w.Code)
}
// TestAdminUsers_Remove2FA_InvalidID_NotFound verifies a malformed ID is
// rejected before any query runs.
func TestAdminUsers_Remove2FA_InvalidID_NotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/nothex/2fa/remove", "admin", ctx)
require.Equal(t, http.StatusNotFound, w.Code)
}
// TestAdminUsers_Remove2FA_NonAdmin_Forbidden verifies the route is admin-gated:
// a non-admin role gets 403 from RequireAdmin before the handler runs.
func TestAdminUsers_Remove2FA_NonAdmin_Forbidden(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedUser2FATurnedOn(t, ctx, tx, userID)
handler := mw.RequireAdmin(http.HandlerFunc(AdminRemoveUser2FAHandler))
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "verified_email", ctx)
require.Equal(t, http.StatusForbidden, w.Code)
// The user's 2FA must be untouched by the rejected request.
var enabled bool
err = tx.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
require.NoError(t, err)
require.True(t, enabled, "2FA should remain enabled after a 403")
}
// TestTwoFAVerify_Enforced_UpdatesLastUsedAt verifies a successful enforced-mode
// code check stamps two_factor_last_used_at.
func TestTwoFAVerify_Enforced_UpdatesLastUsedAt(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)
var enabled bool
var lastUsed sql.NullTime
err = tx.QueryRow(ctx, `SELECT two_factor_enabled, two_factor_last_used_at FROM users WHERE id = $1`, userID).Scan(&enabled, &lastUsed)
require.NoError(t, err)
require.True(t, enabled)
require.True(t, lastUsed.Valid, "two_factor_last_used_at should be set after a successful verify")
}
// TestAdminSendVerificationCode_MintsForCustomer verifies the admin-scoped mint
// keys the code to the TARGET user (the customer whose saved card is being
// charged), NOT the admin session. The [2FA] delivery log line must carry the
// customer's userID — so the code is delivered to the customer and can satisfy
// the card-owner gate — and must never carry the admin's ID.
func TestAdminSendVerificationCode_MintsForCustomer(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
customerID, 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`, customerID)
require.NoError(t, err)
// Admin session userID differs from the target customer.
w := makeAdmin2FARequest(http.HandlerFunc(AdminSendVerificationCodeHandler), http.MethodPost, "/api/admin/users/"+customerID+"/2fa/code", "admin", ctx)
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")
logOut := buf.String()
require.Contains(t, logOut, customerID, "delivery log must key the code to the CUSTOMER's userID")
require.NotContains(t, logOut, "admin-test-id", "delivery log must NOT key the code to the admin session")
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), logOut, "endpoint must log the code as the delivery channel")
// The customer now has a pending code that would satisfy the card-owner gate.
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, `SELECT two_factor_pending_code_hash FROM users WHERE id = $1`, customerID).Scan(&pendingHash))
require.True(t, pendingHash.Valid, "admin mint must persist a pending code for the customer")
}
// TestAdminSendVerificationCode_UnknownCustomer_NotFound verifies the admin
// mint 404s for a nonexistent target user (the route-level RequireAdmin
// middleware — applied in main.go — gates role access; the handler owns the
// target-user contract).
func TestAdminSendVerificationCode_UnknownCustomer_NotFound(t *testing.T) {
twofaEnvEnforced(t)
ctx, _ := testutils.SetupTestTx(t)
w := makeAdmin2FARequest(http.HandlerFunc(AdminSendVerificationCodeHandler), http.MethodPost, "/api/admin/users/no-such-user/2fa/code", "admin", ctx)
require.Equal(t, http.StatusNotFound, w.Code, "unknown target user must 404")
}
// TestAdminSendVerificationCode_WritesAuditTrail verifies finding 2a: an
// admin-scoped 2FA mint writes an admin_audit_log row (action_type
// '2fa_code_mint') keyed to the ADMIN, with details recording whether the code
// was FRESH or REUSED and the remaining lifetime. Uses a real admin user so the
// admin_id FK is satisfied (the synthetic "admin-test-id" elsewhere would make
// the best-effort audit write a no-op).
func TestAdminSendVerificationCode_WritesAuditTrail(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
customerID, 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`, customerID)
require.NoError(t, err)
// Fresh mint: audit row must record reused=false + the full 10-minute life.
req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+customerID+"/2fa/code", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", customerID)
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
req = req.WithContext(context.WithValue(req.Context(), mw.UserIDKey, adminID))
w := httptest.NewRecorder()
AdminSendVerificationCodeHandler(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var auditCount int
var actionType string
var targetUserID sql.NullString
var details sql.NullString
err = tx.QueryRow(ctx, `
SELECT COUNT(*), MAX(action_type), MAX(target_user_id), MAX(details::text)
FROM admin_audit_log WHERE admin_id = $1 AND action_type = '2fa_code_mint'
`, adminID).Scan(&auditCount, &actionType, &targetUserID, &details)
require.NoError(t, err)
require.Equal(t, 1, auditCount, "a fresh admin mint must write exactly one audit row")
require.Equal(t, "2fa_code_mint", actionType)
require.True(t, targetUserID.Valid && targetUserID.String == customerID, "audit must target the customer, not the admin")
var freshDetails map[string]any
require.NoError(t, json.Unmarshal([]byte(details.String), &freshDetails), "audit details must be parseable JSON")
require.Equal(t, false, freshDetails["reused"], "a fresh mint must be audited as fresh")
require.Equal(t, float64(600), freshDetails["remaining_seconds"], "a fresh mint reports the full 10-minute lifetime")
// Second request reuses the still-valid code: audit must record reuse. (Both
// rows share the transaction's NOW(), so select by the distinguishing detail
// rather than created_at.)
w2 := httptest.NewRecorder()
AdminSendVerificationCodeHandler(w2, req)
require.Equal(t, http.StatusOK, w2.Code, w2.Body.String())
err = tx.QueryRow(ctx, `
SELECT details::text FROM admin_audit_log
WHERE admin_id = $1 AND action_type = '2fa_code_mint'
AND (details->>'reused')::boolean = true
LIMIT 1
`, adminID).Scan(&details)
require.NoError(t, err)
var reuseDetails map[string]any
require.NoError(t, json.Unmarshal([]byte(details.String), &reuseDetails))
require.Equal(t, true, reuseDetails["reused"], "a reused code must be audited as reused")
}