- 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
164 lines
6.9 KiB
Go
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")
|
|
}
|