- 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
498 lines
23 KiB
Go
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")
|
|
}
|