Files
Crussell/backend/handlers/payments/terminal_sca_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

363 lines
14 KiB
Go

//go:build test && dev
package payments
// Tests for the CreateTerminalPayment SCA decision model: the VerificationToken
// field (handlers.go:114, validation 440-444, extraction 843-846, gate skip
// 1024, forwarding 1113), the SCA-only token-less refusal (402
// verification_required) on the terminal and till saved-card surfaces, and the
// customer_initiated classification (Square's SCA/liability-shift signal) on
// every saved-card charge site.
//
// Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv stay sequential
// (no t.Parallel) — see the note at the top of twofa_test.go. They use
// SQUARE_ENVIRONMENT=staging (enforced, but square.NewDevClient() returns the
// in-memory mock — helperEnvEnforce2FA's production would panic a dev-build
// NewDevClient, see twofa_gate_consume_test.go).
import (
"encoding/json"
"net/http"
"testing"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env
// that still resolves to the in-memory mock client. Saved-card charges are
// SCA-only (the 2FA fallback was removed entirely), so an enforced env refuses
// token-less saved-card charges 402 verification_required.
func helperEnvEnforce2FAStaging(t *testing.T) {
t.Helper()
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "staging")
}
// installRecordingClient wraps the dev mock in the shared recording client so
// tests can assert the exact CreatePaymentReq the handler sent to Square.
func installRecordingClient(t *testing.T) *recordingPaymentClient {
t.Helper()
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
t.Cleanup(func() { SquareClient = origClient })
return rec
}
// TestTerminalSavedCard_VerificationToken_Passthrough pins (d): a saved-card
// charge through CreateTerminalPayment forwards the SCA verification token
// verbatim on the CreatePaymentReq.
func TestTerminalSavedCard_VerificationToken_Passthrough(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
rec := installRecordingClient(t)
vrf := "vrf_terminal_sca_1"
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-sca-passthrough-" + bookingID,
VerificationToken: &vrf,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.VerificationToken
rec.mu.Unlock()
require.Equal(t, vrf, got, "the SCA verification token must be forwarded to Square on the CreatePaymentReq")
}
// TestTerminalSavedCard_VerificationToken_TooLong_Rejected pins (d): an
// oversized verification_token is rejected with 400 by ValidateVerificationToken
// before any charge branch runs.
func TestTerminalSavedCard_VerificationToken_TooLong_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
big := make([]byte, 600)
for i := range big {
big[i] = 'a'
}
token := string(big)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
IdempotencyKey: "terminal-sca-too-long-" + bookingID,
VerificationToken: &token,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "an oversized verification_token must be rejected with 400, body: %s", w.Body.String())
}
// TestTerminalSavedCard_VerificationToken_Skips2FA pins (d): when the charge
// carries a Square verification_token (SCA performed), the 2FA gate is SKIPPED
// even for a user with NO 2FA setup — a charge that would 403 on the fallback
// path succeeds via the token.
func TestTerminalSavedCard_VerificationToken_Skips2FA(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_skip", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installRecordingClient(t)
vrf := "vrf_terminal_sca_skip_1"
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-sca-skip-" + bookingID,
VerificationToken: &vrf,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "SCA performed — the token must skip the 2FA gate, body: %s", 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 terminal charge must not write a 2FA-fallback audit row")
}
// TestTerminalSavedCard_Tokenless_402 pins the SCA-only posture on the terminal
// saved-card surface: a token-less terminal saved-card charge is refused 402
// verification_required even when the card owner holds a valid 2FA code (the
// homegrown 2FA fallback was removed entirely — PSR 2017 reg 100), and no
// 2fa_fallback_charge audit row is written.
func TestTerminalSavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FAStaging(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
seedTwoFAPendingCode(t, tx, userID, "334411")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_fallback", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installRecordingClient(t)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-tokenless-" + bookingID,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less terminal saved-card charge must be refused 402 (SCA-only), 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 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, "the 2FA fallback was removed — no fallback audit row may be written")
}
// TestTillSavedCard_Tokenless_402 pins the SCA-only posture on the till saved-
// card surface: an admin till saved-card sale is refused 402
// verification_required even with a valid 2FA code (the 2FA fallback was
// removed entirely), and no fallback audit row is written.
func TestTillSavedCard_Tokenless_402(t *testing.T) {
helperEnvEnforce2FAStaging(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")
seedTwoFAPendingCode(t, tx, userID, "665544")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_till_audit", "VISA", "4242")
require.NoError(t, err)
installRecordingClient(t)
key := "2fa-till-fallback-audit"
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less till saved-card sale must be refused 402 (SCA-only), 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 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, "the 2FA fallback was removed — no fallback audit row may be written")
}
// TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated
// flag on the CreatePaymentReq — false for the ADMIN surfaces (terminal
// saved-card handlers.go:1121, till saved-card till.go:1179: merchant-
// initiated) and true for the CUSTOMER surfaces (booking handlers.go:2467,
// tip handlers.go:4708, gift-card giftcards.go:1676).
func TestCustomerInitiated_ChargeClassification(t *testing.T) {
t.Run("admin_terminal_saved_card_merchant_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_terminal", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
rec := installRecordingClient(t)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "ci-terminal-" + bookingID,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd, "an admin saved-card terminal charge must carry customer_details")
require.False(t, cd.CustomerInitiated, "an admin-initiated terminal saved-card charge is merchant-initiated")
})
t.Run("admin_till_saved_card_merchant_initiated", func(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)
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_till", "VISA", "4242")
require.NoError(t, err)
rec := installRecordingClient(t)
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "ci-till",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd, "an admin till saved-card charge must carry customer_details")
require.False(t, cd.CustomerInitiated, "an admin-initiated till saved-card charge is merchant-initiated")
})
t.Run("customer_booking_cardholder_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
rec := installRecordingClient(t)
cardToken := "cnon:ci-booking"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "ci-booking-" + bookingID,
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd)
require.True(t, cd.CustomerInitiated, "a customer booking charge is cardholder-initiated")
})
t.Run("customer_tip_cardholder_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
rec := installRecordingClient(t)
cardToken := "cnon:ci-tip"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
IdempotencyKey: "ci-tip-" + bookingID,
}
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd)
require.True(t, cd.CustomerInitiated, "a customer tip charge is cardholder-initiated")
})
t.Run("customer_gift_card_cardholder_initiated", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_gc", "VISA", "4242")
require.NoError(t, err)
rec := installRecordingClient(t)
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
CardID: &cardID,
IdempotencyKey: "ci-gc",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
rec.mu.Lock()
cd := rec.lastReq.CustomerDetails
rec.mu.Unlock()
require.NotNil(t, cd)
require.True(t, cd.CustomerInitiated, "a customer gift-card purchase is cardholder-initiated")
})
}