- 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
302 lines
14 KiB
Go
302 lines
14 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
// Tests pinning the SCA-only behavior on the till and gift-card saved-card
|
|
// charge gates (till.go / giftcards.go) after the 2FA fallback removal: the
|
|
// gate NEVER verifies or consumes a 2FA code anymore — a token-less saved-card
|
|
// charge is refused 402 verification_required up front (PSR 2017 reg 100), so
|
|
// the customer's pending code is left untouched (not consumed at the gate, no
|
|
// re-issue on failure). The consume-at-gate / re-issue semantics that these
|
|
// tests used to pin are GONE: the homegrown 2FA fallback was removed entirely.
|
|
//
|
|
// These tests flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv and therefore
|
|
// must stay sequential (no t.Parallel) — see the note at the top of
|
|
// twofa_test.go. They use SQUARE_ENVIRONMENT=staging (NOT production) for
|
|
// enforcement: twoFactorEnforced() is fail-closed, so any non-mock/dev value
|
|
// enforces the gate, while square.NewDevClient() — which the injected fault
|
|
// client constructs at call time — returns the in-memory mock for every env
|
|
// except production/sandbox. The shared helperEnvEnforce2FA sets production,
|
|
// which would panic NewDevClient in a dev build.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"crussell/internal/square"
|
|
"crussell/internal/twofa"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Tokenless_RefusedWithoutConsuming
|
|
// pins the till saved-card gate: a token-less charge is refused 402
|
|
// verification_required BEFORE the 2FA code is consulted, so the customer's
|
|
// pending code is never consumed and no re-issue runs (the fallback was
|
|
// removed — the gate has no code path at all).
|
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Tokenless_RefusedWithoutConsuming(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
|
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, "556677")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "saved_card",
|
|
UserSavedCardID: &cardID,
|
|
UserID: &userID,
|
|
IdempotencyKey: "2fa-till-fresh-decline",
|
|
}
|
|
|
|
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less till saved-card charge must be refused 402 verification_required (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"])
|
|
|
|
// The gate never verified the code — the seeded hash must survive untouched
|
|
// (no consumption, no re-issue).
|
|
var hash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
|
require.Equal(t, twofa.Hash("556677"), hash.String, "the gate must not consume the code — it was never consulted")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Tokenless_Refused
|
|
// pins the same SCA-only refusal on a PENDING-REUSE till retry: the gate still
|
|
// refuses 402 verification_required (the code is irrelevant), leaving the
|
|
// seeded code untouched.
|
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Tokenless_Refused(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
|
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, "667788")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
// Seed a PENDING till_sale with the same key — a prior attempt whose
|
|
// Square charge failed after the DB transaction committed (card funded).
|
|
key := "2fa-till-pending-reuse"
|
|
var giftCardID string
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
|
VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id
|
|
`, adminID).Scan(&giftCardID))
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
|
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
|
$2, $3, $4, $5, NOW(), NOW())
|
|
`, giftCardID, userID, cardID, key, adminID)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
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 charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String())
|
|
|
|
var hash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
|
require.Equal(t, twofa.Hash("667788"), hash.String, "the gate must not consume the code — it was never consulted")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_RefusedWithoutConsuming
|
|
// pins the SCA-only refusal on the gift-card purchase saved-card gate: a
|
|
// token-less charge is refused 402 verification_required and the seeded code is
|
|
// untouched (no consumption, no re-issue).
|
|
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_RefusedWithoutConsuming(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateTestToken(userID, "verified_email")
|
|
seedTwoFAPendingCode(t, tx, userID, "112233")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := BuyGiftCardRequest{
|
|
Amount: 2000,
|
|
RecipientType: "self",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-buy-gc-fresh-decline",
|
|
}
|
|
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less gift-card saved-card charge must be refused 402 verification_required (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 hash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
|
require.Equal(t, twofa.Hash("112233"), hash.String, "the gate must not consume the code — it was never consulted")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Tokenless_Refused
|
|
// pins the SCA-only refusal on a PENDING-REUSE gift-card purchase retry: the
|
|
// gate refuses 402 verification_required regardless of the pending state,
|
|
// leaving the seeded code untouched.
|
|
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Tokenless_Refused(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateTestToken(userID, "verified_email")
|
|
seedTwoFAPendingCode(t, tx, userID, "334455")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
// Seed a PENDING payment record with the same key — a prior attempt whose
|
|
// Square charge failed (mirrors TestBuyGiftCard_RetryPending_ReattemptsCharge).
|
|
key := "2fa-buy-gc-pending-reuse"
|
|
_, err = tx.Exec(context.Background(), `
|
|
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
|
|
VALUES ('full', 'online_square', 'pending', 20.00, $1, NOW(), NOW(), $2)
|
|
`, key, userID)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := BuyGiftCardRequest{
|
|
Amount: 2000,
|
|
RecipientType: "self",
|
|
CardID: &cardID,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less gift-card saved-card charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String())
|
|
|
|
var hash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
|
require.Equal(t, twofa.Hash("334455"), hash.String, "the gate must not consume the code — it was never consulted")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Tokenless_Refused
|
|
// pins the SCA-only refusal on the booking NEW-card + save_card SAVE gate: a
|
|
// token-less save is refused 402 verification_required and the seeded code is
|
|
// untouched (no consumption at the save gate, no re-issue — the fallback is
|
|
// gone).
|
|
func TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Tokenless_Refused(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "999001")
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:2fa-booking-newcard-savecard-decline"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "2fa-booking-newcard-savecard-decline",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less new-card + save_card booking charge must be refused 402 verification_required (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 hash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
|
require.Equal(t, twofa.Hash("999001"), hash.String, "the save gate must not consume the code — it was never consulted")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Tokenless_Refused pins
|
|
// the same SCA-only refusal on the TIP new-card + save_card SAVE gate.
|
|
func TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Tokenless_Refused(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "999002")
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:2fa-tip-newcard-savecard-decline"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "2fa-tip-newcard-savecard-decline",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less new-card + save_card tip charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String())
|
|
|
|
var hash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
|
require.Equal(t, twofa.Hash("999002"), hash.String, "the save gate must not consume the code — it was never consulted")
|
|
}
|