Files
Crussell/backend/handlers/payments/twofa_gate_consume_test.go
T
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

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

356 lines
17 KiB
Go

//go:build test && dev
package payments
// Tests pinning the Loop B 2FA single-use consume-at-gate fix on the two
// saved-card charge gates that were still consuming AFTER the charge landed:
// the till saved-card charge (till.go) and the gift-card purchase saved-card
// gate (giftcards.go).
//
// Semantics (mirroring CreateBookingPayment, handlers.go):
// - A FRESH charge verifies the 2FA code WITH consumption at the gate
// (consume = !reusePendingRecord). The code is single-use, closing the
// TOCTOU where a verified-but-unconsumed code could authorize a second
// concurrent charge. If Square then fails, a fresh code is re-issued
// (reissueTwoFACodeAfterFailedCharge) so the same-key retry has a live
// code to verify.
// - A PENDING-REUSE retry verifies WITHOUT consuming: the code was re-issued
// for exactly this retry, and the post-charge success path consumes it on
// terminal success, so a retry that fails again keeps its code for one
// more attempt.
//
// These tests flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv and therefore
// must stay sequential (no t.Parallel) — see the note at the top of
// twofa_test.go. They use SQUARE_ENVIRONMENT=staging (NOT production) for
// enforcement: twoFactorEnforced() is fail-closed, so any non-mock/dev value
// enforces the gate, while square.NewDevClient() — which the injected fault
// client and structuredSquareErrorWithCode construct at call time — returns
// the in-memory mock for every env except production/sandbox. The shared
// helperEnvEnforce2FA sets production, which would panic NewDevClient in a
// dev build.
import (
"context"
"database/sql"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// TestTwoFactorEnforced_CreateTillSale_SavedCard_FreshCharge_Failure pins the
// till saved-card gate on a FRESH charge: the 2FA code is consumed AT THE GATE
// (single-use), and a failed Square charge re-issues a fresh code so the
// same-key retry can verify again. The stored hash must differ from the seeded
// one — if the gate still used consume=false the seeded hash would survive
// unchanged and there would be no re-issue.
func TestTwoFactorEnforced_CreateTillSale_SavedCard_FreshCharge_Failure_ConsumesAtGate_Reissues(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
seedTwoFAPendingCode(t, tx, userID, "556677")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "2fa-till-fresh-decline",
VerificationCode: "556677",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a failed fresh saved-card charge must re-issue a live 2FA code for the same-key retry")
require.NotEqual(t, twofa.Hash("556677"), hash.String, "the gate must have consumed the seeded code at verification time (single-use)")
}
// TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Failure pins the
// till saved-card gate on a PENDING-REUSE retry: the code is verified WITHOUT
// consumption, so a retry that fails again keeps its seeded code unchanged and
// no re-issue runs (the fresh-charge-only guard must not fire).
func TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Failure_KeepsCode(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
seedTwoFAPendingCode(t, tx, userID, "667788")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
// Seed a PENDING till_sale with the same key — a prior attempt whose
// Square charge failed after the DB transaction committed (card funded).
key := "2fa-till-pending-reuse"
var giftCardID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id
`, adminID).Scan(&giftCardID))
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
VerificationCode: "667788",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a pending-reuse retry must not consume the code at the gate")
require.Equal(t, twofa.Hash("667788"), hash.String, "a failed pending-reuse retry must keep its seeded code unchanged (no re-issue)")
}
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fresh_Failure pins the gift-card
// purchase saved-card gate on a FRESH charge: consume-at-gate + re-issue on
// failure, exactly as the till gate above.
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fresh_Failure_ConsumesAtGate_Reissues(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
seedTwoFAPendingCode(t, tx, userID, "112233")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
CardID: &cardID,
IdempotencyKey: "2fa-buy-gc-fresh-decline",
VerificationCode: "112233",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a failed fresh saved-card purchase must re-issue a live 2FA code for the same-key retry")
require.NotEqual(t, twofa.Hash("112233"), hash.String, "the gate must have consumed the seeded code at verification time (single-use)")
}
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Failure pins the
// gift-card purchase gate on a PENDING-REUSE retry: verify-without-consume and
// no re-issue, so the seeded code survives a second failed retry unchanged.
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Failure_KeepsCode(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
seedTwoFAPendingCode(t, tx, userID, "334455")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
// Seed a PENDING payment record with the same key — a prior attempt whose
// Square charge failed (mirrors TestBuyGiftCard_RetryPending_ReattemptsCharge).
key := "2fa-buy-gc-pending-reuse"
_, err = tx.Exec(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
VALUES ('full', 'online_square', 'pending', 20.00, $1, NOW(), NOW(), $2)
`, key, userID)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
CardID: &cardID,
IdempotencyKey: key,
VerificationCode: "334455",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a pending-reuse retry must not consume the code at the gate")
require.Equal(t, twofa.Hash("334455"), hash.String, "a failed pending-reuse retry must keep its seeded code unchanged (no re-issue)")
}
// TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Failure_Reissues
// pins the save-gate code-burn re-issue for the BOOKING path: a NEW-card
// (cnon) charge with save_card=true passes the SAVE gate (consume=true — the
// single-use code is burned there), so when the subsequent Square charge is
// definitively declined the re-issue guard must fire (req.SaveCard) and mint a
// fresh code — otherwise every same-key retry hits "Verification code expired"
// forever (finding: 2FA code burned by the SAVE gate never re-issued). The
// stored hash must differ from the seeded one: if the re-issue did not run the
// gate's consumption would leave no pending code at all.
func TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Failure_Reissues(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "999001")
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
cardToken := "cnon:2fa-booking-newcard-savecard-decline"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-booking-newcard-savecard-decline",
VerificationCode: "999001",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a failed new-card + save_card booking charge must re-issue a live 2FA code for the same-key retry")
require.NotEqual(t, twofa.Hash("999001"), hash.String, "the SAVE gate consumed the seeded code at verification time — the failure must re-issue a fresh one")
}
// TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Failure_Reissues pins
// the same save-gate code-burn re-issue for the TIP path (mirrors the booking
// test above): a NEW-card tip with save_card=true burns its code at the SAVE
// gate, so a definitively declined charge must re-issue a fresh code for the
// same-key retry.
func TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Failure_Reissues(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "999002")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
cardToken := "cnon:2fa-tip-newcard-savecard-decline"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-tip-newcard-savecard-decline",
VerificationCode: "999002",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a failed new-card + save_card tip charge must re-issue a live 2FA code for the same-key retry")
require.NotEqual(t, twofa.Hash("999002"), hash.String, "the SAVE gate consumed the seeded code at verification time — the failure must re-issue a fresh one")
}
// TestReissueTwoFACodeAfterFailedCharge_RespectsMintCooldown pins the
// LOW-MEDIUM finding 2 contract on the re-issue path: the re-issue mints a
// live code after a FRESH charge consumed one at the gate, respects the same
// per-user mint cooldown as the interactive mint endpoints (a second re-issue
// inside the window is a no-op), and runs again once the cooldown elapses
// (simulated by clearing the shared stamp the way a successful verify does).
func TestReissueTwoFACodeAfterFailedCharge_RespectsMintCooldown(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
reissueTwoFACodeAfterFailedCharge(ctx, db.Conn, userID, true, true, nil)
var hash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.True(t, hash.Valid, "a failed fresh saved-card charge must re-issue a live code for the same-key retry")
firstHash := hash.String
reissueTwoFACodeAfterFailedCharge(ctx, db.Conn, userID, true, true, nil)
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.Equal(t, firstHash, hash.String, "a re-issue inside the mint cooldown must be a no-op (the stored code is untouched)")
// Round 2 Loop B finding 6b: the cooldown-skipped re-issue must NOT be
// silent — the fresh charge consumed the customer's code at the gate, so
// they have NO live code for the same-key retry until the cooldown lapses.
// The per-issue-capped reissue-fail alert raises so the operator knows the
// customer is stranded (deduped on reason+user_id: one row per customer).
var alertCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1 AND acknowledged_at IS NULL`, userID).Scan(&alertCount))
require.Equal(t, 1, alertCount, "a cooldown-skipped re-issue after a fresh consumed charge must raise the reissue-fail alert (finding 6b)")
st := twofa.StateFor(userID)
st.Mu.Lock()
st.LastMintAt = time.Time{}
st.Mu.Unlock()
reissueTwoFACodeAfterFailedCharge(ctx, db.Conn, userID, true, true, nil)
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
require.NotEqual(t, firstHash, hash.String, "an out-of-window re-issue must mint a fresh code")
}