Files
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

164 lines
6.9 KiB
Go

//go:build test && dev
package payments
import (
"encoding/json"
"net/http"
"testing"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// =============================================================================
// MEDIUM-HIGH: dev mock SCA error-shape parity at the HANDLER level.
//
// square_dev.go's SimulateSavedCardVerificationRequired is exercised heavily in
// internal/square's own suite, but the frontend only ever sees the MOCK's SCA
// rejection through a payment HANDLER — the 402 + {"code":"verification_required"}
// body is what the SCA challenge flow keys on. These tests pin that the mock's
// simulated SCA rejection surfaces the structured body end to end, and that the
// ENFORCED-deployment gate refuses token-less saved-card charges while genuine
// SCA tokenize-results (new_card_token = cnon:sca-...) sail through.
// =============================================================================
// TestBookingPayment_MockSavedCardSCA_Tokenless_402_VerificationRequired drives
// the mock's SimulateSavedCardVerificationRequired toggle through
// CreateBookingPayment: a token-less saved-card (ccof) charge is rejected by
// the mock with CARD_DECLINED_VERIFICATION_REQUIRED, and the handler must
// surface it as 402 + the structured verification_required body — the exact
// shape the frontend's isVerificationRequiredSignal parses to trigger the
// client-side challenge.
func TestBookingPayment_MockSavedCardSCA_Tokenless_402_VerificationRequired(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_sca_booking", "VISA", "4242")
require.NoError(t, err)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.SimulateSavedCardVerificationRequired = true
SquareClient = mc
defer func() { SquareClient = origClient }()
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
IdempotencyKey: "mock-sca-booking-tokenless",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
assertStructuredVerificationRequired(t, w)
// A refused charge must not record a completed payment.
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount))
require.Zero(t, payCount, "a mock-SCA-refused saved-card charge must not record a completed payment")
}
// TestBookingPayment_MockSavedCardSCA_SCATokenizeResult_Succeeds proves the
// other half of the mock parity: under the SAME SimulateSavedCardVerificationRequired
// toggle, a saved-card charge carrying a GENUINE tokenize-result
// (new_card_token = cnon:sca-... — card.tokenize(verificationDetails, cardId))
// is accepted by the mock (the token IS the buyer verification) and the handler
// completes the charge.
func TestBookingPayment_MockSavedCardSCA_SCATokenizeResult_Succeeds(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_sca_booking_ok", "VISA", "4242")
require.NoError(t, err)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.SimulateSavedCardVerificationRequired = true
SquareClient = mc
defer func() { SquareClient = origClient }()
scaToken := "cnon:sca-4242_2500_ok"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
NewCardToken: &scaToken,
IdempotencyKey: "mock-sca-booking-tokenized",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a genuine SCA tokenize-result must complete the saved-card charge, body: %s", w.Body.String())
}
// TestBookingPayment_EnforcedSavedCard_Tokenless_402 pins the SCA-only gate on
// the booking surface in an ENFORCED deployment: a token-less saved-card charge
// is refused 402 verification_required up front (PSR 2017 reg 100 — no homegrown
// 2FA fallback), before any charge reaches Square.
func TestBookingPayment_EnforcedSavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_enforced_booking", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
IdempotencyKey: "enforced-booking-tokenless",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
assertStructuredVerificationRequired(t, w)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"])
}
// TestBookingPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds proves the
// SCA-primary skip in an ENFORCED deployment: a saved-card charge carrying a
// genuine SCA tokenize-result (new_card_token = cnon:sca-...) skips BOTH 2FA
// gates (scaTokenizedSavedCard) and completes — the wire contract the frontend
// sends after a successful tokenizeWithVerification challenge.
func TestBookingPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_enforced_booking_ok", "VISA", "4242")
require.NoError(t, err)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.SimulateSavedCardVerificationRequired = true
SquareClient = mc
defer func() { SquareClient = origClient }()
scaToken := "cnon:sca-4242_2500_ok"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
NewCardToken: &scaToken,
IdempotencyKey: "enforced-booking-tokenized",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result must skip the enforced gate and complete, body: %s", w.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount))
require.Equal(t, 1, payCount, "the SCA-tokenized charge must record exactly one completed payment")
}