Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
555 lines
21 KiB
Go
555 lines
21 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test clients
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// fixedCardClient returns the same Square card id for every CreateCardOnFile
|
|
// call, simulating a card token that tokenizes to the same Square card for two
|
|
// different users (the cross-user saved-card collision scenario).
|
|
type fixedCardClient struct {
|
|
square.SquareClient
|
|
fixedCardID string
|
|
}
|
|
|
|
func (c *fixedCardClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
|
|
return &square.CardOnFile{
|
|
ID: c.fixedCardID,
|
|
CardID: c.fixedCardID,
|
|
Brand: "VISA",
|
|
Last4: "4242",
|
|
ExpMonth: 12,
|
|
ExpYear: 2030,
|
|
Fingerprint: "sqfp_shared",
|
|
}, nil
|
|
}
|
|
|
|
// recordingCustomerClient counts CreateCustomer calls per email and returns a
|
|
// deterministic customer id, so tests can assert provisioning happens exactly
|
|
// once and the stored id is reused.
|
|
type recordingCustomerClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
createCalls []string
|
|
customerSeq int
|
|
customerByID map[string]*square.CustomerResult
|
|
}
|
|
|
|
func (c *recordingCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.customerByID == nil {
|
|
c.customerByID = map[string]*square.CustomerResult{}
|
|
}
|
|
if existing, ok := c.customerByID[email]; ok {
|
|
return existing, nil
|
|
}
|
|
c.customerSeq++
|
|
res := &square.CustomerResult{ID: fmt.Sprintf("cus_mock_%d", c.customerSeq), Email: email}
|
|
c.customerByID[email] = res
|
|
c.createCalls = append(c.createCalls, email)
|
|
return res, nil
|
|
}
|
|
|
|
func (c *recordingCustomerClient) customerCalls() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.createCalls...)
|
|
}
|
|
|
|
// definitiveChargeClient simulates a Square charge rejection that can never
|
|
// succeed (declined) — a definitive failure. createErr carries the structured
|
|
// CARD_DECLINED error the real client produces (see the construction sites), so
|
|
// chargeFailureStatus classifies it as 402 and isDefinitiveChargeFailure claws
|
|
// the funded card back.
|
|
type definitiveChargeClient struct {
|
|
square.SquareClient
|
|
createErr error
|
|
}
|
|
|
|
func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
return nil, c.createErr
|
|
}
|
|
|
|
// ambiguousChargeClient simulates a transport-level charge failure where Square
|
|
// may or may not have processed the payment — an ambiguous failure.
|
|
type ambiguousChargeClient struct {
|
|
square.SquareClient
|
|
createErr error
|
|
}
|
|
|
|
func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
return nil, c.createErr
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cross-user saved-card collision (schema + upsert fix)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreatePaymentMethodFromToken_CrossUserSameCard_DoesNotMutateOtherUserRow(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userA, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
userB, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &fixedCardClient{SquareClient: square.NewDevClient(), fixedCardID: "ccof:shared_card"}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
svc := NewPaymentService()
|
|
|
|
// User A saves the card, then deletes it (soft delete + retention).
|
|
cardA, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.DeletePaymentMethod(ctx, cardA.ID, userA))
|
|
|
|
var aDeletedAt string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&aDeletedAt))
|
|
require.NotEqual(t, "", aDeletedAt, "user A's card must be soft-deleted")
|
|
|
|
// User B tokenizes the SAME card. With the old global UNIQUE(square_card_id)
|
|
// this upsert targeted A's row — reviving A's deleted card, clearing its
|
|
// retention, and returning A's card id to B.
|
|
cardB, err := svc.CreatePaymentMethodFromToken(ctx, userB, "cnon:shared")
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, cardA.ID, cardB.ID, "user B must get their own saved-card row, not user A's")
|
|
|
|
// Exactly two rows for the shared Square card (one per user).
|
|
var rows int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE square_card_id = 'ccof:shared_card'`).Scan(&rows))
|
|
require.Equal(t, 2, rows)
|
|
|
|
// A's row is still owned by A and still deleted — never mutated by B.
|
|
var ownerA, deletedA string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&ownerA, &deletedA))
|
|
require.Equal(t, userA, ownerA)
|
|
require.NotEqual(t, "", deletedA, "user A's deleted card must not be revived by user B")
|
|
|
|
// B's row is active and owned by B.
|
|
var ownerB, deletedB string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardB.ID).Scan(&ownerB, &deletedB))
|
|
require.Equal(t, userB, ownerB)
|
|
require.Equal(t, "", deletedB)
|
|
|
|
// Same-user retry still revives the deleted row (N-8 preserved): user A
|
|
// re-tokenizes the same card → the existing row comes back, not a new one.
|
|
cardARetry, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
|
|
require.NoError(t, err)
|
|
require.Equal(t, cardA.ID, cardARetry.ID, "same-user re-tokenize must revive the existing row")
|
|
var revivedDeleted string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&revivedDeleted))
|
|
require.Equal(t, "", revivedDeleted)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Till sale gift-card clawback on definitive charge failure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreateTillSale_DefinitiveFailure_ClawsBackCreatedGiftCard(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:test-card",
|
|
IdempotencyKey: "till-clawback-create",
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
|
|
|
// Definitive rejection — the sale is marked failed immediately (not left
|
|
// pending for the sweep), so a same-key retry cannot re-complete against a
|
|
// gift card that no longer exists.
|
|
var status string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-create").Scan(&status))
|
|
require.Equal(t, "failed", status)
|
|
|
|
// The created gift card was clawed back (deleted).
|
|
var gcCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM gift_cards gc
|
|
JOIN till_sales ts ON gc.id = ts.item_id
|
|
WHERE ts.idempotency_key = $1
|
|
`, "till-clawback-create").Scan(&gcCount))
|
|
require.Equal(t, 0, gcCount)
|
|
|
|
// And its purchase transaction is gone too.
|
|
var txCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM gift_card_transactions gct
|
|
JOIN till_sales ts ON gct.reference_id = ts.id
|
|
WHERE ts.idempotency_key = $1
|
|
`, "till-clawback-create").Scan(&txCount))
|
|
require.Equal(t, 0, txCount)
|
|
}
|
|
|
|
func TestCreateTillSale_DefinitiveFailure_ClawsBackTopUp(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
var gcID 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(&gcID))
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "topup",
|
|
Amount: 25.00,
|
|
GiftCardID: &gcID,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:test-card",
|
|
IdempotencyKey: "till-clawback-topup",
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
|
|
|
var status string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-topup").Scan(&status))
|
|
require.Equal(t, "failed", status)
|
|
|
|
// The top-up was reversed: the card is back to its pre-sale £50.
|
|
var remaining float64
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, gcID).Scan(&remaining))
|
|
require.Equal(t, 50.00, remaining)
|
|
|
|
// This request's top-up transaction is gone (prior accounting untouched).
|
|
var txCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM gift_card_transactions
|
|
WHERE gift_card_id = $1 AND reference_type = 'till_sale'
|
|
`, gcID).Scan(&txCount))
|
|
require.Equal(t, 0, txCount)
|
|
}
|
|
|
|
func TestCreateTillSale_DefinitiveFailure_ClawsBackRedeemedCard(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
redeemUserID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:test-card",
|
|
RedeemToUserID: &redeemUserID,
|
|
IdempotencyKey: "till-clawback-redeem",
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
|
|
|
// The redeemed-to-account credit was reversed.
|
|
var balance float64
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1`, redeemUserID).Scan(&balance))
|
|
require.Equal(t, 0.00, balance)
|
|
}
|
|
|
|
func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:test-card",
|
|
IdempotencyKey: "till-ambiguous",
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code, "an ambiguous charge failure must classify as 503 (not a definitive 402)")
|
|
|
|
// Ambiguous failure — the sale stays pending for the sweep, NOT failed.
|
|
var status string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-ambiguous").Scan(&status))
|
|
require.Equal(t, "pending", status)
|
|
|
|
// The gift card stays funded so a late retry can complete the sale.
|
|
var remaining float64
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT amount_remaining FROM gift_cards gc
|
|
JOIN till_sales ts ON gc.id = ts.item_id
|
|
WHERE ts.idempotency_key = $1
|
|
`, "till-ambiguous").Scan(&remaining))
|
|
require.Equal(t, 50.00, remaining)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Remaining balance excludes tips
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetBookingRemainingBalancePence_ExcludesTips(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
require.NoError(t, err)
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewPaymentService()
|
|
initial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
require.Positive(t, initial)
|
|
|
|
// £20 partial payment reduces the remaining balance.
|
|
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "partial",
|
|
PaymentMethod: "cash",
|
|
Status: "completed",
|
|
Amount: 20.00,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
afterPartial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, initial-2000, afterPartial)
|
|
|
|
// A £5 tip must NOT reduce the remaining balance — it is not payment toward
|
|
// the booking total.
|
|
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "tip",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: 5.00,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
afterTip, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, afterPartial, afterTip, "a tip must not count toward the paid balance")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Customer provisioning on save (P14)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreatePaymentMethodFromToken_ProvisionsCustomerOnceAndReuses(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
svc := NewPaymentService()
|
|
card1, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:visa")
|
|
require.NoError(t, err)
|
|
card2, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:mastercard")
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, rec.customerCalls(), 1, "customer must be created exactly once and then reused from the stored id")
|
|
|
|
var cid1, cid2 string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card1.ID).Scan(&cid1))
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card2.ID).Scan(&cid2))
|
|
require.NotEmpty(t, cid1)
|
|
require.Equal(t, cid1, cid2, "both saved cards must share the user's Square customer id")
|
|
}
|
|
|
|
// alwaysNewCustomerClient counts every CreateCustomer call and always returns a
|
|
// fresh customer id (unlike recordingCustomerClient, which dedups by email and
|
|
// would mask a second mint for the same user).
|
|
type alwaysNewCustomerClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
createCalls int
|
|
}
|
|
|
|
func (c *alwaysNewCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
|
|
c.mu.Lock()
|
|
c.createCalls++
|
|
id := fmt.Sprintf("cus_cache_%d", c.createCalls)
|
|
c.mu.Unlock()
|
|
return &square.CustomerResult{ID: id, Email: email}, nil
|
|
}
|
|
|
|
func (c *alwaysNewCustomerClient) customerCallCount() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.createCalls
|
|
}
|
|
|
|
// TestInvalidateSquareCustomerCache_DropsCachedID proves the exported cache
|
|
// invalidation: after GDPR erasure NULLs the DB square_customer_id and the
|
|
// customer is deleted at Square, the process-local cache must not keep serving
|
|
// the erased user's stale customer id. Without invalidation a later
|
|
// ensureSquareCustomer would return the cached id without re-minting; after
|
|
// invalidation it re-queries the (NULLed) DB and mints a fresh customer.
|
|
func TestInvalidateSquareCustomerCache_DropsCachedID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
// A saved-card row is the persistence point for the customer id; start
|
|
// with a NULL square_customer_id so ensureSquareCustomer must mint one.
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
|
VALUES ($1, 'sq_card_cache_test', 'Visa', '4242', 12, 2030, 'fp_cache', true)
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
rec := &alwaysNewCustomerClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
|
|
|
|
svc := NewPaymentService()
|
|
|
|
// 1. First ensure mints a customer and caches it.
|
|
c1, err := svc.EnsureSquareCustomer(ctx, userID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, c1)
|
|
require.Equal(t, 1, rec.customerCallCount())
|
|
|
|
// Simulate GDPR erasure NULLing the saved-card square_customer_id.
|
|
_, err = tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = NULL WHERE user_id = $1`, userID)
|
|
require.NoError(t, err)
|
|
|
|
// 2. WITHOUT invalidation the stale cached id is still served (no re-mint).
|
|
c2, err := svc.EnsureSquareCustomer(ctx, userID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, c1, c2, "stale cached customer id must be served when the cache is NOT invalidated")
|
|
require.Equal(t, 1, rec.customerCallCount())
|
|
|
|
// 3. Invalidate, then re-ensure: the entry is gone, so the NULLed DB is
|
|
// re-queried and a fresh customer is minted — the stale identity must not
|
|
// resurface.
|
|
InvalidateSquareCustomerCache(userID)
|
|
c3, err := svc.EnsureSquareCustomer(ctx, userID)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, c1, c3, "after invalidation a fresh customer must be minted, not the stale cached id")
|
|
require.Equal(t, 2, rec.customerCallCount())
|
|
}
|
|
|
|
func TestBuyGiftCard_NoSaveCard_NoCustomerProvisioned(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
newToken := "cnon:test-card"
|
|
reqBody := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "buy-gc-nosave-key",
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", reqBody, token, ctx)
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
|
|
require.Empty(t, rec.customerCalls(), "one-off buy must not provision a Square customer")
|
|
|
|
var cardCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
|
require.Zero(t, cardCount, "one-off buy must not persist a saved card")
|
|
}
|