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
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
//go:build test
|
||||
|
||||
package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetCardsOnFileHTTP_MultiPage_Combined verifies cursor-based pagination of
|
||||
// the saved-cards list: multiple pages are fetched and combined into one
|
||||
// result, with the cursor threaded through to each subsequent request. This
|
||||
// mirrors the ListRefunds two-page test — a regression dropping the cursor loop
|
||||
// would silently return only the first 25-card page for a user with more.
|
||||
func TestGetCardsOnFileHTTP_MultiPage_Combined(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.RawQuery)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.RawQuery, "cursor=page2") {
|
||||
_, _ = w.Write([]byte(`{"cards":[{"id":"ccof_3","card_brand":"VISA","last_4":"9999","exp_month":12,"exp_year":2030,"fingerprint":"fp3","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}],"cursor":""}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"cards":[{"id":"ccof_1","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"},{"id":"ccof_2","card_brand":"MASTERCARD","last_4":"1111","exp_month":6,"exp_year":2029,"fingerprint":"fp2","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}],"cursor":"page2"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_1", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("getCardsOnFileHTTP failed: %v", err)
|
||||
}
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("expected 2 pages fetched, got %d: %v", len(paths), paths)
|
||||
}
|
||||
if !strings.Contains(paths[0], "reference_id=user_1") {
|
||||
t.Errorf("expected reference_id filter on the first request, got %q", paths[0])
|
||||
}
|
||||
if !strings.Contains(paths[1], "cursor=page2") {
|
||||
t.Errorf("expected the cursor threaded into the second request, got %q", paths[1])
|
||||
}
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("expected 3 cards combined across pages, got %d: %+v", len(cards), cards)
|
||||
}
|
||||
// Page 1's cards must come first, page 2's appended after.
|
||||
if cards[0].CardID != "ccof_1" || cards[2].CardID != "ccof_3" {
|
||||
t.Errorf("unexpected combined card order: %+v", cards)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCardsOnFileHTTP_NoCards_EmptySlice verifies the empty case returns a
|
||||
// non-nil empty slice (callers range over the result without a nil guard).
|
||||
func TestGetCardsOnFileHTTP_NoCards_EmptySlice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"cards":[],"cursor":""}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_none", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("getCardsOnFileHTTP failed: %v", err)
|
||||
}
|
||||
if cards == nil {
|
||||
t.Fatal("expected a non-nil empty slice for a user with no cards")
|
||||
}
|
||||
if len(cards) != 0 {
|
||||
t.Errorf("expected 0 cards, got %d", len(cards))
|
||||
}
|
||||
}
|
||||
@@ -451,8 +451,24 @@ func verificationTokenPrefixForSource(sourceID string) string {
|
||||
// frontend's MockCardForm mints for saved-card verification). A raw nonce like
|
||||
// "cnon:test-card" — whatever customer_id rides along — is NOT a tokenize-result,
|
||||
// and real Square rejects it as a card-on-file charge source.
|
||||
//
|
||||
// During the token-shape transition the frontend mock may still emit a
|
||||
// verify_mock_<prefix>_<amount>[_ok|_deny] verification token in the
|
||||
// tokenize-result source slot; that shape is accepted here too so dev remains
|
||||
// walkable either way (parseVerifyToken resolves its binding).
|
||||
func isSCATokenizeResultSource(sourceID string) bool {
|
||||
return strings.HasPrefix(sourceID, "cnon:sca-")
|
||||
return strings.HasPrefix(sourceID, "cnon:sca-") || strings.HasPrefix(sourceID, "verify_mock_")
|
||||
}
|
||||
|
||||
// isTokenLikeMock is the dev mock's PCI-DSS token predicate: it accepts the
|
||||
// production token shapes (cnon: nonces / ccof: card ids — isTokenLike) PLUS
|
||||
// the verify_mock_<prefix>_<amount> verification-token shape the dev frontend
|
||||
// mints, so dev remains walkable while the frontend's card form transitions to
|
||||
// minting cnon:sca- tokenize-results. The PRODUCTION client stays strict
|
||||
// (square_http_client.go isTokenLike — cnon:/ccof: only); this mock-only
|
||||
// widening never reaches real Square.
|
||||
func isTokenLikeMock(s string) bool {
|
||||
return isTokenLike(s) || strings.HasPrefix(s, "verify_mock_")
|
||||
}
|
||||
|
||||
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
|
||||
@@ -564,8 +580,10 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
body := mockPaymentWireBody(req)
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||
// mock behaves identically to production (PCI-DSS parity).
|
||||
if !isTokenLike(body.SourceID) {
|
||||
// mock behaves identically to production (PCI-DSS parity). The mock's token
|
||||
// predicate additionally accepts the verify_mock_* transition shape the dev
|
||||
// frontend mints (isTokenLikeMock).
|
||||
if !isTokenLikeMock(body.SourceID) {
|
||||
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(body.SourceID))
|
||||
}
|
||||
// Square's CreatePayment requires a positive amount_money — a missing or
|
||||
@@ -1328,8 +1346,10 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
|
||||
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||
// mock behaves identically to production.
|
||||
if !isTokenLike(cardToken) {
|
||||
// mock behaves identically to production. The mock's token predicate
|
||||
// additionally accepts the verify_mock_* transition shape the dev frontend
|
||||
// mints (isTokenLikeMock).
|
||||
if !isTokenLikeMock(cardToken) {
|
||||
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user