- 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
363 lines
14 KiB
Go
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")
|
|
})
|
|
}
|