Files
Crussell/backend/handlers/payments/handlers_test.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

498 lines
23 KiB
Go

//go:build test && dev
package payments
// Tests for the C2 till-vs-online advisory-lock serialization fix and the
// SCA tokenize-result wire contract (new_card_token + saved_card_id on
// CreateBookingPayment).
//
// These tests COMMIT their setup so the advisory locks work across independent
// pool connections (see cleanupConcurrentTestRows in concurrency_test.go for
// the leak-free deletion order). They swap the SquareClient global and run
// lock-bound waits (~3s each), so they are sequential — never t.Parallel.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// enteredCreatePaymentClient blocks inside CreatePayment until the caller
// confirms entry (closing entered), then delays `delay` before forwarding.
// Because the online handler holds the crussell:payment:<bookingID> advisory
// lock from BEFORE the pending insert through to AFTER CreatePayment returns,
// a closed entered channel proves the lock is provably held — letting the test
// deterministically fire a concurrent till cash payment into that window.
type enteredCreatePaymentClient struct {
square.SquareClient
entered chan struct{}
delay time.Duration
}
func (c *enteredCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
select {
case <-c.entered:
default:
close(c.entered)
}
time.Sleep(c.delay)
return c.SquareClient.CreatePayment(ctx, req)
}
// commitSetupTx commits the per-test transaction so the test operates at pool
// level on independent connections (advisory locks only serialize across
// independent connections; a shared per-test tx would mask the race).
func commitSetupTx(t *testing.T, ctx context.Context) {
t.Helper()
innerTx := db.TxFromContext(ctx)
require.NotNil(t, innerTx, "no transaction in context")
require.NoError(t, innerTx.Commit(ctx))
}
// TestTerminalCash_BlockedByConcurrentOnlineCharge pins the C2 fix: while an
// online CreateBookingPayment charge holds the crussell:payment:<bookingID>
// advisory lock across its Square round-trip, a till CASH payment on the SAME
// booking must fail fast with 409 — never pass its remaining-balance check and
// record a second amount (which buildSplitRecords would carve into a
// non-refundable tip). Before the fix the till branch serialized only on the
// bookings-row FOR UPDATE (a DIFFERENT primitive the online path never takes),
// so both charges would land and the overflow became a silent tip.
func TestTerminalCash_BlockedByConcurrentOnlineCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
commitSetupTx(t, ctx)
// The online charge holds the lock for ~4s (longer than the ~3s bounded
// try-lock), so the concurrent till cash request is guaranteed to hit a
// contended lock and give up with 409.
origClient := SquareClient
slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
cardToken := "cnon:c2-online-inflight"
onlineReq := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "c2-online-inflight-" + bookingID,
}
var wg sync.WaitGroup
var onlineRec *httptest.ResponseRecorder
wg.Add(1)
go func() {
defer wg.Done()
onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool)
}()
// Block until the online charge is inside the Square call — the advisory
// lock is provably held from that point until the handler returns.
<-slow.entered
cashReq := CreateTerminalPaymentRequest{
Amount: 2000,
PaymentType: "full",
PaymentMethod: strPtr("cash"),
}
cashRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", cashReq, adminToken, pool)
require.Equal(t, http.StatusConflict, cashRec.Code,
"till cash payment must 409 while an online charge holds the payment lock, body: %s", cashRec.Body.String())
wg.Wait()
require.Equal(t, http.StatusOK, onlineRec.Code, "the online charge must succeed once the till payment was blocked: %s", onlineRec.Body.String())
// No cash payment row, no tip row (the till payment was never recorded),
// and the booking's real-money ledger is exactly the online charge.
assertNoTillMoneyRecorded(t, pool, bookingID, 5000)
}
// TestTerminalGiftCard_BlockedByConcurrentOnlineCharge is the gift-card half
// of the C2 fix: the till GIFT-CARD branch takes the same advisory lock, so a
// concurrent online charge blocks it too (the pre-fix FOR UPDATE only
// serialized against other row-lock holders).
func TestTerminalGiftCard_BlockedByConcurrentOnlineCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
commitSetupTx(t, ctx)
origClient := SquareClient
slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
cardToken := "cnon:c2-online-inflight-gc"
onlineReq := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "c2-online-inflight-gc-" + bookingID,
}
var wg sync.WaitGroup
var onlineRec *httptest.ResponseRecorder
wg.Add(1)
go func() {
defer wg.Done()
onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool)
}()
<-slow.entered
gcReq := CreateTerminalPaymentRequest{
Amount: 2000,
PaymentType: "full",
PaymentMethod: strPtr("giftcard"),
GiftCardID: strPtr("GC-0000-0000-0000"),
}
gcRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", gcReq, adminToken, pool)
require.Equal(t, http.StatusConflict, gcRec.Code,
"till gift-card payment must 409 while an online charge holds the payment lock, body: %s", gcRec.Body.String())
wg.Wait()
require.Equal(t, http.StatusOK, onlineRec.Code, onlineRec.Body.String())
assertNoTillMoneyRecorded(t, pool, bookingID, 5000)
}
// TestOnlineCharge_BlockedWhileTillHoldsLock is the reverse direction of the
// C2 fix: while the till cash/giftcard branch holds the SAME advisory lock
// (here held directly to deterministically simulate a till charge in flight),
// an online CreateBookingPayment charge must fail fast with 409 — the two
// paths now contend on one primitive instead of passing their independent
// remaining-balance checks.
func TestOnlineCharge_BlockedWhileTillHoldsLock(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
commitSetupTx(t, ctx)
pool := context.Background()
key := "crussell:payment:" + bookingID
// Simulate a till cash/giftcard charge in flight: hold the same advisory
// lock on a dedicated connection for longer than the ~3s try-lock bound.
holder, err := db.Conn.Acquire(pool)
require.NoError(t, err)
defer holder.Release()
_, err = holder.Exec(pool, `SELECT pg_advisory_lock(hashtext($1))`, key)
require.NoError(t, err)
defer func() { _, _ = holder.Exec(pool, `SELECT pg_advisory_unlock(hashtext($1))`, key) }()
cardToken := "cnon:c2-online-blocked"
req := CreateBookingPaymentRequest{
Amount: 2000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "c2-online-blocked-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, pool)
require.Equal(t, http.StatusConflict, w.Code,
"online charge must 409 while the till holds the payment lock, body: %s", w.Body.String())
var payCount int
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
require.Zero(t, payCount, "no payment record may exist when the online charge was blocked")
}
// assertNoTillMoneyRecorded pins the "not silently double-record" half of C2:
// after a till cash/giftcard payment was blocked, the booking must carry no
// till-originated row, no tip row (the pre-fix double-charge carved the
// overflow into a non-refundable tip), and exactly wantPence of real-money
// completed payments.
func assertNoTillMoneyRecorded(t *testing.T, ctx context.Context, bookingID string, wantPence int64) {
t.Helper()
var cashRows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&cashRows))
require.Zero(t, cashRows, "the blocked till cash payment must not be recorded")
var gcRows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&gcRows))
require.Zero(t, gcRows, "the blocked till gift-card payment must not be recorded")
var tipRows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipRows))
require.Zero(t, tipRows, "no non-refundable tip row may be minted by the blocked till payment")
var paidPence int64
require.NoError(t, db.Conn.QueryRow(ctx, `
SELECT COALESCE(ROUND(SUM(amount) * 100), 0) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'
AND payment_method NOT IN ('discount', 'on_the_house')
`, bookingID).Scan(&paidPence))
require.Equal(t, wantPence, paidPence, "the booking ledger must reflect exactly the online charge")
}
// TestCreateBookingPayment_SCATokenizeResult_UsedAsSource pins the SCA wire
// contract: a saved-card charge carrying new_card_token (the SCA
// tokenize-result from card.tokenize(verificationDetails, cardId)) plus
// saved_card_id must call Square with source_id = the tokenize-result token —
// NOT the stored ccof id — and with customer_id derived from the saved card,
// and must NOT require a verification_token for the new path.
func TestCreateBookingPayment_SCATokenizeResult_UsedAsSource(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-test", "VISA", "4242")
require.NoError(t, err)
userToken := jwt.GenerateUserToken(userID)
rec := installRecordingClient(t)
token := "cnon:sca-tokenize-result"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &token,
UserSavedCardID: &cardID,
IdempotencyKey: "sca-tokenize-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
last := rec.lastReq
rec.mu.Unlock()
require.Equal(t, token, last.SourceID, "the SCA tokenize-result token must be the charge source_id")
require.NotEqual(t, "ccof:sca-tokenize-test", last.SourceID, "the stored ccof id must NOT be the source for the tokenize-result flow")
require.NotEmpty(t, last.CustomerID, "customer_id must derive from the saved card row")
require.Empty(t, last.VerificationToken, "the tokenize-result flow must not require a legacy verification_token")
// The recorded payment row must carry the token as its square source and
// reference the saved card row.
var squareSource, uscID sql.NullString
require.NoError(t, tx.QueryRow(ctx, `SELECT square_source_id, user_saved_card_id FROM payments WHERE booking_id = $1`, bookingID).Scan(&squareSource, &uscID))
require.Equal(t, token, squareSource.String, "the payment row must record the tokenize-result token as its square source")
require.Equal(t, cardID, uscID.String, "the payment row must reference the saved card")
}
// TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate pins that the SCA
// tokenize-result flow is SCA-primary: with 2FA enforced, a charge carrying
// new_card_token + saved_card_id (no verification_code, no verification_token)
// succeeds — the tokenize-result token itself proves buyer verification, so no
// homegrown fallback authorization is demanded.
func TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate(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-2fa", "VISA", "4242")
require.NoError(t, err)
userToken := jwt.GenerateUserToken(userID)
installRecordingClient(t)
token := "cnon:sca-tokenize-2fa"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &token,
UserSavedCardID: &cardID,
IdempotencyKey: "sca-tokenize-2fa-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code,
"an SCA tokenize-result charge must skip the 2FA gate, body: %s", w.Body.String())
}
// TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog locks the M8 fix
// for the SYNC path: when the RefundPayment handler's FIRST Square attempt
// returns COMPLETED immediately (no pending row for the sweep to re-issue), the
// synchronous terminal-success path must write the SAME admin_audit_log row the
// sweep's re-issue writes — action 'admin_refund', admin actor, payment id,
// pence amount and reason, via the shared insertManualRefundAudit helper.
// The row is marked completed so the sweep (which only processes still-pending
// rows) can never re-process this refund, guaranteeing exactly one audit row.
// Mirrors TestManualRefund_IssuesAdminAuditLog (refunds_test.go) but drives
// the handler end-to-end instead of the sweep.
func TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
require.NoError(t, err)
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
require.NoError(t, err)
// The mock refunds any non-"pay_mock_" id leniently and returns COMPLETED
// synchronously, so the handler's FIRST attempt completes immediately.
const chargeID = "sqp_m8_sync_audit"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID)
require.NoError(t, err)
// The audit insert runs in its OWN transaction via InsertAdminAuditCharge
// (never the per-test tx), and the refund's advisory-lock serialization
// needs pool-level rows — commit the setup so the sync path executes at
// pool level, exactly like the sweep test.
commitSetupTx(t, ctx)
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_audit_log WHERE action_type = 'admin_refund' AND admin_id = $1`, adminID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
adminToken := jwt.GenerateTestToken(adminID, "admin")
req := RefundRequest{Amount: 5000, Reason: "customer request"}
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, freshCtx)
require.Equal(t, http.StatusOK, rec.Code, "synchronous refund should complete 200, body: %s", rec.Body.String())
var resp RefundResponse
require.NoError(t, parsePaymentResponseBody(rec, &resp))
require.Equal(t, "completed", resp.Status, "the synchronous COMPLETED refund must resolve to 'completed'")
// The refund row must be completed so the sweep can never re-process it
// (and duplicate the audit).
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, resp.ID).Scan(&status))
require.Equal(t, "completed", status)
// Exactly one admin_refund audit row, carrying payment id, pence amount and
// reason.
var auditCount int
require.NoError(t, db.Conn.QueryRow(freshCtx, `
SELECT COUNT(*) FROM admin_audit_log
WHERE admin_id = $1 AND action_type = 'admin_refund'
`, adminID).Scan(&auditCount))
require.Equal(t, 1, auditCount, "expected exactly 1 'admin_refund' audit row for the synchronous refund")
var details string
require.NoError(t, db.Conn.QueryRow(freshCtx, `
SELECT details::text FROM admin_audit_log
WHERE admin_id = $1 AND action_type = 'admin_refund'
`, adminID).Scan(&details))
for _, want := range []string{paymentID, "5000", "customer request"} {
require.True(t, strings.Contains(details, want), "expected audit details to carry %q, got %s", want, details)
}
}
// TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds
// pins the M11 fix: in an ENFORCED (SCA-only) deployment a card save that
// carries a GENUINE SCA tokenize-result as card_token succeeds — the
// STORE-intent SCA performed at tokenization (SquareCardInput.tokenizeForStore)
// IS the verification (PSR 2017 reg 100), so the save skips the 2FA gate at the
// call site exactly like the charge surfaces' scaTokenizedSavedCard path, and
// the card is persisted. Before the fix the handler demanded the (removed) 2FA
// fallback and refused every save 402 verification_required, so add-card could
// not complete in an enforced deployment.
func TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
req := CreatePaymentMethodRequest{CardToken: "cnon:sca-tokenize-store"}
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
require.Equal(t, http.StatusOK, w.Code,
"a genuine SCA tokenize-result card save must succeed in an enforced deployment, body: %s", w.Body.String())
var card SavedCard
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
require.NotEmpty(t, card.ID, "the saved card must be returned")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "the SCA-compliant save must persist exactly one card")
}
// TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402 pins
// auth-F1 on the add-card surface: a verification_token is CLIENT-ASSERTED and
// never forwarded to Square on a SAVE surface (CreateCardOnFile takes no token),
// so it must NOT skip the gate — enforced + a NON-token-like card_token (a raw
// PAN — the only shape the gate still refuses) + a (forged) non-empty
// verification_token is refused 402 verification_required and no card is
// persisted. The M11 fix cannot be a client-asserted token bypass.
func TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
forged := "forged-verification-token"
req := CreatePaymentMethodRequest{
CardToken: "4111111111111111",
VerificationToken: &forged,
}
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code,
"a forged verification_token must not skip the save gate, body: %s", 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 forged-token save must not persist a card")
}
// TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402 pins the M11
// refusal half in the SCA suite's own staging helper: a NON-token-like save
// source (a raw PAN — the gate's only remaining refusal shape) is refused 402
// verification_required in an enforced deployment and no card is persisted. A
// GENUINE token-like source (cnon:/ccof:) is SCA-proven — the STORE-intent SCA
// ran at tokenization — and skips the gate (see
// TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds).
// Companion to TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_402
// (errors_test.go).
func TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
req := CreatePaymentMethodRequest{CardToken: "4111111111111111"}
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code,
"a token-less save must be refused 402 in an enforced deployment, body: %s", 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")
}