Files
Crussell/backend/handlers/payments/twofa_test.go
T
popertots 5dae0bba08 feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

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

634 lines
27 KiB
Go

//go:build test && dev
package payments
// Tests for the PSD2 SCA stand-in gate (twofa.go): the twoFactorEnforced() env
// matrix, requireTwoFactorForCardAccess() gating, and the end-to-end
// enforcement of the saved-card payment paths in CreateBookingPayment /
// CreateTillSale. 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"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crussell/db"
"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")
}
// 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 gate verifies with.
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 2FA 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 2FA gate must be skipped")
require.False(t, fallbackUsed, "SCA is primary — the 2FA fallback was not used")
require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate")
}
// TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured pins the
// SCA-only posture (TWO_FACTOR_FALLBACK=false): a token-less saved-card charge
// has no 2FA fallback, so the gate denies 402 with the structured
// verification_required body the frontend keys on to trigger the SCA challenge.
func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing.T) {
helperEnvEnforce2FA(t)
t.Setenv("TWO_FACTOR_FALLBACK", "false")
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, "123456", "", false)
require.False(t, allowed, "SCA-only posture: no 2FA fallback for a token-less charge")
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"])
}
// TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits pins the strict audit
// trail: a 2FA-fallback saved-card charge (no verification token, code verified)
// must write an admin_audit_log row with action_type '2fa_fallback_charge' for
// the customer actor — the operator can distinguish SCA-authorized charges from
// fallback-authorized ones.
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(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.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' AND target_user_id = $1 AND admin_id = $1
`, userID).Scan(&auditCount))
require.Equal(t, 1, auditCount, "a 2FA-fallback saved-card charge must write a strict audit row for the customer actor")
}
// 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 homegrown fallback was not used.
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")
}
// 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 — the 2FA fallback did not authorize anything")
require.Equal(t, http.StatusOK, w.Code)
}
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
t.Run("user_not_enabled_writes_403_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.StatusForbidden, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
require.NotEmpty(t, body["error"])
})
t.Run("user_enabled_but_no_code_writes_403_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()
// B10: the enabled setup flag alone must NOT unlock the gate.
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("user_enabled_with_valid_code_allows", 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.True(t, allowed)
require.True(t, fallbackUsed, "a code-verified token-less charge uses the 2FA fallback")
require.Equal(t, http.StatusOK, w.Code)
})
t.Run("user_enabled_with_wrong_code_writes_400_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)
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "Invalid verification code", body["error"])
})
t.Run("unknown_user_writes_403_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.StatusForbidden, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
require.NotEmpty(t, body["error"])
})
}
// TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume pins the MEDIUM-2
// contract: the charge gate verifies the code WITHOUT consuming it (consume
// happens later, at the charge's terminal SUCCESS state via
// twofa.ConsumePendingCode), so a failed/ambiguous Square charge does NOT burn
// the operator-relayed code — a same-key retry can re-verify the SAME code.
// Only an explicit ConsumePendingCode (the completed-charge path) NULLs it,
// after which the code is dead ("expired").
func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(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.True(t, allowed, "first gate pass must succeed")
require.True(t, fallbackUsed, "the code verification is the 2FA fallback")
require.Equal(t, http.StatusOK, w.Code)
// The code must still be present — the gate verified WITHOUT consuming.
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid, "the gate must NOT consume the code (MEDIUM-2)")
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
w = httptest.NewRecorder()
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.True(t, allowed, "a not-yet-consumed code must pass the gate again on retry")
require.Equal(t, http.StatusOK, w.Code)
// Consumption happens at the charge's terminal SUCCESS state.
require.NoError(t, twofa.ConsumePendingCode(ctx, tx, userID))
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, "ConsumePendingCode must NULL the pending code")
// A further attempt with the consumed code is denied as expired.
w = httptest.NewRecorder()
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
require.False(t, allowed, "a consumed code must not pass the gate")
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "Verification code expired — request a new one", body["error"])
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
// end-to-end gate on the save-card path: enforced + user without 2FA → 403 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.StatusForbidden, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
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.
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.StatusForbidden, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
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")
}
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(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.StatusOK, w.Code, w.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Equal(t, 1, payCount)
}
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(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.StatusOK, w.Code, w.Body.String())
}
// TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
// when enforced.
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 while the
// card's owner has no 2FA is blocked with 403 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.StatusForbidden, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
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")
}
func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(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.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted
// pins the Loop B MEDIUM gate-ordering fix: the save-card 2FA gate runs AFTER
// the idempotency dedup's completed short-circuit. A same-key lost-response
// retry re-sends the SAME single-use verification code that the original
// attempt already consumed; if the gate ran first it would 400 "Verification
// code expired". With the gate below the dedup, the retry returns the
// already-completed payment instead of re-entering the gate.
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "778899")
cardToken := "cnon:2fa-save-card-retry"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-retry",
VerificationCode: "778899",
}
handler := withNonGuest(CreateBookingPayment)
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Same-key retry re-sends the identical request, whose code is now
// consumed. The completed-dedup must return the payment before the gate.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed payment, not re-run the gate: %s", w2.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
require.Equal(t, 1, payCount, "the retry must not create a second payment")
}
// TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted pins
// the Loop B MEDIUM gate-ordering fix on the tip endpoint: the saved-card
// charge 2FA gate runs AFTER the tip idempotency dedup's completed
// short-circuit. A same-key lost-response retry re-sends the SAME single-use
// verification code the original attempt consumed; with the gate first it would
// 400 "expired", with the gate below the dedup the retry returns the completed
// tip instead.
func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedTwoFAPendingCode(t, tx, userID, "667788")
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_tip_retry", "VISA", "4321")
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 500,
CardID: &cardID,
IdempotencyKey: "2fa-tip-saved-card-retry",
VerificationCode: "667788",
}
handler := withNonGuest(CreateTipPayment)
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Same-key retry re-sends the identical request, whose code is now
// consumed. The completed-dedup must return the tip before the gate.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed tip, not re-run the gate: %s", w2.Body.String())
var tipCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount))
require.Equal(t, 1, tipCount, "the retry must not create a second tip")
}