Files
Crussell/backend/handlers/payments/twofa_test.go
T
popertots 2cdbad0cea feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).

- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
  charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
  a non-empty Square verification_token (charge surfaces, token forwarded to
  Square) skips the gate; anything else is refused 402 verification_required.
  enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
  where the token IS forwarded to Square (charge — Square validates it) from
  card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
  must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
  one-time tokenize-result sends the token as the charge SOURCE (new_card_token
  -> source_id) alongside saved_card_id, never a separate verification_token.
  resolveChargeSource resolves the saved-card branch FIRST (customer from the
  card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
  tip/till/gift-card charge requests, enforced server-side before any fallback
  charge could reach Square and recorded on the 2fa_fallback_charge audit row;
  logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
  (twoFAPepperConfigured / twoFADeliveryChannelConfigured /
  twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
  exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
  .env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
  validation on save vs charge surfaces, completion idempotency, idempotency
  key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
2026-08-22 00:34:50 +01:00

930 lines
41 KiB
Go

//go:build test && dev
package payments
// Tests for the PSD2 SCA gate (twofa.go): the twoFactorEnforced() env matrix,
// requireTwoFactorForCardAccess() gating, and the end-to-end enforcement of the
// saved-card payment paths in CreateBookingPayment / CreateTillSale. Saved-card
// charges are SCA-ONLY: the homegrown 2FA fallback was REMOVED entirely (PSR
// 2017 reg 100 — SCA mandatory and non-waivable for customer-initiated
// stored-credential charges), so a token-less saved-card charge is always
// refused 402 verification_required and no 2FA code can authorise it. Tests
// that flip 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 run before this package's parallel batch, so
// the enforced env never leaks into parallel tests.
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crussell/db"
"crussell/internal/adminnotify"
"crussell/internal/twofa"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
func helperEnvEnforce2FA(t *testing.T) {
t.Helper()
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
// There is no TWO_FACTOR_FALLBACK switch anymore (the 2FA fallback was
// removed — SCA-only); enforcement now only means token-less saved-card
// charges are refused 402 verification_required.
}
// seedTwoFAPendingCode stores a pending 2FA code hash + expiry for a user, the
// state the user package's deliverTwoFACode writes. The hash uses the shared
// twofa.Hash — the same single source of truth the re-issue path uses.
// Used by tests that prove even a VALID pending 2FA code cannot authorise a
// token-less saved-card charge (SCA-only).
func seedTwoFAPendingCode(t *testing.T, q db.Querier, userID, code string) {
t.Helper()
_, err := q.Exec(context.Background(), `
UPDATE users SET
two_factor_enabled = true,
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
WHERE id = $1
`, userID, twofa.Hash(code))
require.NoError(t, err)
}
func TestTwoFactorEnforced(t *testing.T) {
tests := []struct {
name string
require2FA string
squareEnv string
wantEnforced bool
}{
// Fail-closed default: empty/unknown SQUARE_ENVIRONMENT is treated as
// production-enforced, so a mistyped env var can never silently disarm
// the gate.
{"empty_env_fail_closed_enforced", "", "", true},
{"unknown_env_fail_closed_enforced", "", "staging", true},
{"require2fa_false_disables_prod", "false", "production", false},
{"require2fa_false_disables_sandbox", "false", "sandbox", false},
{"require2fa_false_disables_unknown_env", "false", "staging", false},
// REQUIRE_2FA parsing is case-insensitive and alias-tolerant: any of
// false/0/off/no (any casing) disables, nothing else does.
{"require2fa_capitalized_false_disables", "False", "production", false},
{"require2fa_uppercase_false_disables", "FALSE", "production", false},
{"require2fa_zero_disables", "0", "production", false},
{"require2fa_off_disables", "off", "production", false},
{"require2fa_uppercase_off_disables", "OFF", "production", false},
{"require2fa_no_disables", "no", "production", false},
{"require2fa_true_stays_enforced", "true", "production", true},
{"require2fa_one_stays_enforced", "1", "production", true},
{"require2fa_yes_stays_enforced", "yes", "production", true},
{"require2fa_on_stays_enforced", "on", "production", true},
{"require2fa_unknown_stays_enforced", "enable", "production", true},
{"require2fa_off_but_dev_never_enforced", "off", "mock", false},
{"production_enforced", "", "production", true},
{"sandbox_enforced", "", "sandbox", true},
{"require2fa_true_prod_enforced", "true", "production", true},
{"mock_never_enforced", "", "mock", false},
{"dev_never_enforced", "", "dev", false},
{"development_never_enforced", "", "development", false},
{"test_never_enforced", "", "test", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REQUIRE_2FA", tt.require2FA)
t.Setenv("SQUARE_ENVIRONMENT", tt.squareEnv)
require.Equal(t, tt.wantEnforced, twoFactorEnforced())
require.Equal(t, tt.wantEnforced, NewPaymentService().TwoFactorEnforced(), "exported wrapper must match twoFactorEnforced")
})
}
}
// TestRequireTwoFactorForCardAccess_VerificationTokenSkips pins the SCA-primary
// leg of the decision model: when the request carries a Square verification_token
// (the issuer already completed SCA), the gate is skipped entirely — even a
// user with NO 2FA setup passes, no code is demanded, and no fallback is used.
func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false)
require.True(t, allowed, "SCA performed — the gate must be skipped")
require.False(t, fallbackUsed, "SCA is primary — no fallback is ever used")
require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate")
}
// TestRequireTwoFactorForCardAccess_TokenForwardedDistinction pins auth-F1 at
// the gate level: the SAME non-empty verification_token must be treated
// differently by surface. On a token-FORWARDED (charge) surface it is
// Square-validated SCA and skips the gate (tokenForwardedToSquare=true). On a
// card-SAVE surface it is client-asserted and never reaches Square, so it must
// NOT skip the gate (tokenForwardedToSquare=false) — a forged token cannot
// authorise a save, exactly like a token-less request, and no 2FA code can
// authorise it either (SCA-only).
func TestRequireTwoFactorForCardAccess_TokenForwardedDistinction(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
t.Run("forged_token_no_code_refused_402", func(t *testing.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 := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "", "forged-token", true, false)
require.False(t, allowed, "a forged non-empty token must not skip the gate on the save path")
require.False(t, fallbackUsed)
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
})
t.Run("forged_token_with_valid_code_still_refused_402", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedTwoFAPendingCode(t, tx, userID, "424242")
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "424242", "forged-token", true, false)
require.False(t, allowed, "a valid 2FA code cannot authorise a save (SCA-only — the 2FA fallback was removed)")
require.False(t, fallbackUsed, "the 2FA fallback was removed — fallbackUsed is always false")
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
})
t.Run("forged_token_user_not_enabled_refused_402", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := httptest.NewRecorder()
allowed, _ := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "424242", "forged-token", true, false)
require.False(t, allowed, "a user without 2FA setup cannot save a card even with a token")
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
})
t.Run("token_forwarded_variant_still_skips", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false, true)
require.True(t, allowed, "a token-forwarded charge path still skips on a non-empty token (SCA)")
require.False(t, fallbackUsed)
require.Equal(t, http.StatusOK, w.Code)
})
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_Blocked pins
// auth-F1 end-to-end on the booking SAVE gate: enforced + save_card=true + a
// forged non-empty verification_token is denied 402 verification_required with
// no payment row and no saved card. A forged token can never skip the SAVE gate
// (Square never validates it there), and no 2FA code can fall back (SCA-only).
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:test-card-nonce"
forged := "forged-verification-token"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-forge-token",
VerificationToken: &forged,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Zero(t, payCount, "blocked save-card request must not create a payment row")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardCount))
require.Zero(t, cardCount, "blocked save-card request must not persist a card")
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_With2FA_Blocked
// pins that a forged token on the booking SAVE gate cannot authorise a save by
// ANY fallback: even with a valid 2FA code the request is refused 402
// verification_required (SCA-only — the homegrown 2FA fallback was removed).
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_With2FA_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "112233")
cardToken := "cnon:test-card-nonce"
forged := "forged-verification-token"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-forge-token-ok",
VerificationCode: "112233",
VerificationToken: &forged,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Zero(t, payCount, "blocked save-card request must not create a payment row")
}
// TestTwoFactorEnforced_CreateBookingPayment_SCATokenizeResult_SaveGate_Skips
// pins auth-F1 scenario (c): the legitimate SCA-primary tokenize-result flow
// (new_card_token + saved_card_id) still skips BOTH 2FA gates on save+charge —
// the SAVE gate is skipped because the combined path never persists a card
// (resolveChargeSource uses the token as the one-time source) and the
// tokenize-result token is validated by Square as the source_id. A user with
// NO 2FA setup and NO code succeeds: the tokenize-result token is the SCA proof.
func TestTwoFactorEnforced_CreateBookingPayment_SCATokenizeResult_SaveGate_Skips(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-save-2fa", "VISA", "4242")
require.NoError(t, err)
userToken := jwt.GenerateUserToken(userID)
installRecordingClient(t)
// The fixture above already created one saved-card row for the user; the
// combined SCA tokenize-result path must not add another.
var cardsBefore int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardsBefore))
token := "cnon:sca-tokenize-save-2fa"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &token,
UserSavedCardID: &cardID,
SaveCard: true,
IdempotencyKey: "sca-tokenize-save-2fa-" + bookingID,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code,
"an SCA tokenize-result save+charge must skip both 2FA gates, body: %s", w.Body.String())
// The combined path never persists a card — the SCA tokenize-result is the
// one-time charge source, not a new card-on-file.
var cardsAfter int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardsAfter))
require.Equal(t, cardsBefore, cardsAfter, "the SCA tokenize-result save+charge must not persist a new card")
}
// TestRequireTwoFactorForCardAccess_Tokenless_402Structured pins the SCA-only
// posture: a token-less saved-card charge is ALWAYS refused 402 with the
// structured verification_required body — even when the customer holds a valid
// 2FA code, because the homegrown 2FA fallback was removed entirely (PSR 2017
// reg 100: SCA is mandatory and non-waivable for customer-initiated
// stored-credential charges). There is no TWO_FACTOR_FALLBACK switch to flip.
func TestRequireTwoFactorForCardAccess_Tokenless_402Structured(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedTwoFAPendingCode(t, tx, userID, "123456")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
require.False(t, allowed, "SCA-only: no 2FA fallback for a token-less charge")
require.False(t, fallbackUsed, "fallbackUsed must be false — the 2FA fallback was removed")
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestTwoFactorEnforced_BookingSavedCard_Tokenless_402 pins the SCA-only
// posture end-to-end on the booking saved-card charge path: a token-less
// saved-card charge (even with a valid 2FA code) is refused 402
// verification_required, no payment row is created, and no 2fa_fallback_charge
// audit row is written (the fallback audit path is unreachable — the gate never
// authorises a charge by 2FA).
func TestTwoFactorEnforced_BookingSavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "445566")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_audit", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-fallback-audit",
VerificationCode: "445566",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Zero(t, payCount, "a refused token-less charge must not create a payment row")
var auditCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'").Scan(&auditCount))
require.Zero(t, auditCount, "the 2FA fallback was removed — no fallback audit row may be written")
}
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
// authorized charge (verification token present) writes NO 2fa_fallback_charge
// audit row: SCA is primary and the fallback no longer exists at all.
func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_sca_audit", "VISA", "4242")
require.NoError(t, err)
vrf := "vrf_sca_booking_audit"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-sca-audit",
VerificationToken: &vrf,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var auditCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'
`).Scan(&auditCount))
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
}
// TestTwoFactorEnforced_TipSavedCard_Tokenless_402 pins the SCA-only posture on
// the TIP SAVE gate (CreateTipPayment with save_card=true): a token-less save
// is refused 402 verification_required even with a valid 2FA code.
func TestTwoFactorEnforced_TipSavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "556600")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardToken := "cnon:2fa-tip-save-audit"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-tip-save-audit",
VerificationCode: "556600",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestTwoFactorEnforced_TipChargeSavedCard_Tokenless_402 pins the SCA-only
// posture on the TIP CHARGE gate (CreateTipPayment charging an existing saved
// card): a token-less charge is refused 402 verification_required even with a
// valid 2FA code.
func TestTwoFactorEnforced_TipChargeSavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "112211")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_charge_audit", "VISA", "4242")
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 500,
CardID: &cardID,
IdempotencyKey: "2fa-tip-charge-audit",
VerificationCode: "112211",
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_402 pins the SCA-only
// posture on the gift-card purchase saved-card gate (giftcards.go): a token-less
// charge is refused 402 verification_required even with a valid 2FA code.
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "778811")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_gc_audit", "VISA", "4242")
require.NoError(t, err)
key := "2fa-buy-gc-fallback-audit"
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
CardID: &cardID,
IdempotencyKey: key,
VerificationCode: "778811",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", key).Scan(&payCount))
require.Zero(t, payCount, "a refused token-less gift-card purchase must not create a payment row")
}
// TestTwoFactorEnforced_PaymentMethodSave_Tokenless_402 pins the SCA-only
// posture on the add-card save gate (handlers.go CreatePaymentMethod): the
// dedicated add-card endpoint carries no verification_token field, so a save is
// refused 402 verification_required even with a valid 2FA code (SCA-only).
func TestTwoFactorEnforced_PaymentMethodSave_Tokenless_402(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "998877")
req := CreatePaymentMethodRequest{
CardToken: "cnon:2fa-pm-save-audit",
VerificationCode: "998877",
}
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardCount))
require.Zero(t, cardCount, "a refused token-less save must not persist a card")
}
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
// allows every request without touching the DB (no user rows are consulted).
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
// (fail-closed).
func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
t.Setenv("REQUIRE_2FA", "")
t.Setenv("SQUARE_ENVIRONMENT", "mock")
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
require.True(t, allowed, "no response must be written when not enforced")
require.False(t, fallbackUsed, "not enforced — no fallback is ever used")
require.Equal(t, http.StatusOK, w.Code)
}
// TestRequireTwoFactorForCardAccess_Enforced pins the SCA-only refusal in an
// enforced environment: a token-less saved-card charge is refused 402
// verification_required regardless of the user's 2FA state or a submitted code —
// the homegrown 2FA fallback was removed entirely and cannot authorise anything.
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
t.Run("user_not_enabled_writes_402_json", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
require.False(t, ok)
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "402 body must be structured JSON")
require.Equal(t, "verification_required", body["code"])
})
t.Run("user_enabled_but_no_code_writes_402_json", func(t *testing.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)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
require.False(t, ok)
require.Equal(t, http.StatusPaymentRequired, w.Code)
})
t.Run("user_enabled_with_valid_code_still_refused_402", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedTwoFAPendingCode(t, tx, userID, "424242")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.False(t, allowed, "a valid 2FA code cannot authorise a token-less charge (SCA-only)")
require.False(t, fallbackUsed, "fallbackUsed must be false — the 2FA fallback was removed")
require.Equal(t, http.StatusPaymentRequired, w.Code)
})
t.Run("user_enabled_with_wrong_code_writes_402_json", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedTwoFAPendingCode(t, tx, userID, "424242")
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
require.False(t, ok, "the gate does not verify codes anymore — any token-less charge is refused 402")
require.Equal(t, http.StatusPaymentRequired, w.Code)
})
t.Run("unknown_user_writes_402_json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", "", false)
require.False(t, ok)
require.Equal(t, http.StatusPaymentRequired, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "402 body must be structured JSON")
require.Equal(t, "verification_required", body["code"])
})
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
// end-to-end gate on the save-card path: enforced + no SCA → 402
// verification_required with no payment row and no saved card (Square never
// called).
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-blocked",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Zero(t, payCount, "blocked 2FA request must not create a payment row")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardCount))
require.Zero(t, cardCount, "blocked 2FA request must not persist a card")
}
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the
// gate on charging an existing saved card without SCA.
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-saved-card-blocked",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Zero(t, payCount, "blocked saved-card charge must not create a payment row")
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Blocked pins that
// a valid 2FA code cannot unlock the booking SAVE gate (SCA-only — the 2FA
// fallback was removed).
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "112233")
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-ok",
VerificationCode: "112233",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Zero(t, payCount, "a valid 2FA code must not create a payment row (SCA-only)")
}
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Blocked pins
// that a valid 2FA code cannot unlock a saved-card charge (SCA-only — the 2FA
// fallback was removed).
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "334455")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-saved-card-ok",
VerificationCode: "334455",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
// when enforced (SCA is performed by Square's buyer-verification flow).
func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "2fa-new-card-not-gated",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's
// saved-card charge path: an admin charging a customer's saved card without SCA
// is blocked with 402 verification_required and no till_sale is created.
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
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")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "2fa-till-saved-blocked",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
var saleCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount))
require.Zero(t, saleCount, "blocked till saved-card sale must not create a till_sale row")
}
// TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Blocked pins that a
// valid 2FA code cannot unlock the till saved-card gate (SCA-only — the 2FA
// fallback was removed).
func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
_, 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)
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "2fa-till-saved-ok",
VerificationCode: "556677",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestSCASuccess_BookingSavedCard_NeverRequiresConsent pins the SCA-primary
// carve-out: a charge carrying a Square verification_token (SCA performed) is
// never gated on the (removed) fallback consent — with no consent fields and no
// 2FA setup it succeeds and writes no fallback audit row.
func TestSCASuccess_BookingSavedCard_NeverRequiresConsent(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:consent_sca", "VISA", "4242")
require.NoError(t, err)
vrf := "vrf_consent_sca_success"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "consent-sca",
VerificationToken: &vrf,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "SCA-success must never demand consent, body: %s", w.Body.String())
var auditCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'").Scan(&auditCount))
require.Zero(t, auditCount, "an SCA-success charge must not write a fallback audit row")
}
// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test
// delivery predicate: twofa_delivery_dev.go (`dev || test`) always reports a
// delivery channel (the [2FA] log relay), so the 503 "2FA requires an email or
// SMS delivery channel" branch is UNREACHABLE in this build. The production
// predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered by
// twofa_delivery_prod_test.go in a !dev build (see its header for the
// documented limitation).
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
require.True(t, twoFADeliveryAvailable())
}
// TestNotificationsCapExceeded pins Round 2 Loop B finding 1: the GLOBAL cap on
// unacknowledged 'critical_payment_log' admin notifications
// (adminnotify.MaxUnacknowledgedCriticalLogs, the shared cap living in
// crussell/internal/adminnotify) suppresses new inserts once the unacknowledged
// queue reaches the cap, so a hostile flood of attacker-registered accounts
// cannot bury the single-operator notification centre. Acknowledging rows
// re-arms inserts. The same cap is now applied atomically (a conditional
// INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap) at every
// 'critical_payment_log' / 'refresh_token_reuse' insert site (webhooks,
// account erasure, jwt reuse); this test pins the shared count helper. NOTE:
// the REISSUE-FAIL alert (reissueTwoFACodeAfterFailedCharge) is intentionally
// NOT globally capped — Round 2 Loop B finding 2 caps it PER-ISSUE (dedup on
// reason+user_id) so one customer's alert is never suppressed by other users'
// rows.
func TestNotificationsCapExceeded(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// Empty queue → below the cap, inserts allowed.
require.False(t, adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log"))
// Fill the unacknowledged queue to the cap.
_, err = tx.Exec(ctx, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
require.NoError(t, err)
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id, created_at)
VALUES ('critical_payment_log', $1, NOW())
`, userID)
require.NoError(t, err)
}
require.True(t, adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log"), "at the cap the insert must be suppressed")
// Acknowledging one row drops below the cap → inserts re-arm.
_, err = tx.Exec(ctx, `
UPDATE admin_notifications SET acknowledged_at = NOW()
WHERE ctid = (
SELECT ctid FROM admin_notifications
WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL
LIMIT 1
)
`)
require.NoError(t, err)
require.False(t, adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log"), "acknowledging one row must re-arm inserts")
}