fix: dev-mock/prod parity + fail-closed config gates — 402 verification, cnon:sca binding validation, refund reconcile parity, SNAPSHOT_ENC_KEY fail-closed, webhook config gates, access-token startup validation, ClientIP validation

- mock: 400->402 for CARD_DECLINED_VERIFICATION_REQUIRED, cnon:sca- tokenize-result binding validated (prefix/amount/deny), RefundPayment exact-amount reconcile parity, ReplayPaymentByKey snapshot sanity, verify_mock_ legacy widening removed, listRefunds zero-time omits begin_time
- main.go: SNAPSHOT_ENC_KEY log.Fatalf in non-mock, webhook key-set-URL-unset log.Fatalf, SQUARE_ACCESS_TOKEN/LOCATION startup validation, empty-env base URL matches 2FA production interpretation
- mw: ClientIP rejects garbage/comma/port XFF values, documented trusted-proxy requirement

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 62dca184df
commit e9b34d0ad6
8 changed files with 854 additions and 119 deletions
+196 -26
View File
@@ -451,24 +451,138 @@ 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-") || strings.HasPrefix(sourceID, "verify_mock_")
return strings.HasPrefix(sourceID, "cnon:sca-")
}
// parseSCATokenizeResult parses the deterministic mock tokenize-result encoding
// the dev frontend mints for saved-card SCA: cnon:sca-<prefix>_<amount>[_ok|_deny]
// (outcome suffix defaults to ok). The prefix is the first four characters of
// the saved card's ccof: id, the amount is the pence charge the token was bound
// to, and the outcome encodes the buyer's challenge decision
// (MockCardForm.tokenizeSavedCard / verifySavedCard, square.ts's
// tokenizeSavedCardWithVerification mock fallback). Reuses parsedVerifyToken's
// shape — same binding + outcome semantics as the legacy verify_mock_ encoding.
// Returns ok=false for anything that is not parseable (an arbitrary cnon:sca-...
// string — NOT a genuine tokenize-result).
func parseSCATokenizeResult(token string) (parsedVerifyToken, bool) {
const marker = "cnon:sca-"
if !strings.HasPrefix(token, marker) {
return parsedVerifyToken{}, false
}
rest := strings.TrimPrefix(token, marker)
if rest == "" {
return parsedVerifyToken{}, false
}
parts := strings.Split(rest, "_")
denied := false
if n := len(parts); n > 1 {
switch parts[n-1] {
case "ok", "deny":
denied = parts[n-1] == "deny"
parts = parts[:n-1]
}
}
if len(parts) == 0 {
return parsedVerifyToken{}, false
}
amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return parsedVerifyToken{}, false
}
prefix := strings.Join(parts[:len(parts)-1], "_")
if prefix == "" {
return parsedVerifyToken{}, false
}
return parsedVerifyToken{prefix: prefix, amount: amount, denied: denied}, true
}
// tokenizeResultVerificationRequiredError is the refusal for a cnon:sca-
// tokenize-result that does not prove buyer verification for THIS charge — the
// same structured 402 CARD_DECLINED_VERIFICATION_REQUIRED rejection a raw nonce
// in the tokenize-result slot gets. The buyer must re-verify (mint a token bound
// to this card + amount), never retry the same request.
func tokenizeResultVerificationRequiredError(sourceID, reason string) error {
return &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "tokenize-result token does not prove buyer verification for this charge; complete buyer verification (tokenizeWithVerification) for this card and amount",
StatusCode: http.StatusPaymentRequired,
err: fmt.Errorf("square: tokenize-result source %s is not valid for this saved-card charge (%s)", tokenPrefix(sourceID), reason),
}
}
// scaTokenizePrefixKnown reports whether the card prefix embedded in a
// cnon:sca- tokenize-result is one the mock can bind the token to: the
// deterministic prefixes the mock assigns to its test cards, the "test" fallback
// the frontend uses for a degenerate ccof id, or the prefix of a saved card in
// the mock's ledger (the ccof: token prefix the frontend derives the minted
// prefix from — square.ts / MockCardForm). A prefix outside this set cannot come
// from a tokenize-result minted for a card this mock knows, so it is a forged or
// wrong-card binding.
func (m *MockClient) scaTokenizePrefixKnown(prefix string) bool {
if prefix == "test" {
return true
}
switch prefix {
case "4242", "4111", "5555", "3782":
return true
}
for _, card := range m.cardByToken {
if verificationTokenPrefixForSource(card.CardID) == prefix {
return true
}
}
return false
}
// validateSCATokenizeResult validates the deterministic binding the dev frontend
// encodes into a cnon:sca- tokenize-result source against the charge: the
// embedded amount must match the charge amount (Square binds buyer verification
// to the exact amount), the encoded outcome must not be a buyer denial, and the
// embedded card prefix must be one the mock can bind to. An arbitrary
// cnon:sca-... string that does not carry the deterministic encoding is NOT a
// genuine tokenize-result (the frontend always mints the bound shape), so it is
// refused exactly like a raw nonce in the tokenize-result slot. Caller holds
// m.mu; the source is already known to carry the cnon:sca- marker.
func (m *MockClient) validateSCATokenizeResult(sourceID string, amount int64) error {
parsed, ok := parseSCATokenizeResult(sourceID)
if !ok {
return tokenizeResultVerificationRequiredError(sourceID, "does not carry the deterministic tokenize-result binding")
}
if parsed.denied {
return tokenizeResultVerificationRequiredError(sourceID, "the buyer denied the SCA challenge")
}
if parsed.amount != amount {
return tokenizeResultVerificationRequiredError(sourceID, fmt.Sprintf("bound to a different amount (%d vs %d)", parsed.amount, amount))
}
if !m.scaTokenizePrefixKnown(parsed.prefix) {
return tokenizeResultVerificationRequiredError(sourceID, fmt.Sprintf("bound to an unknown card prefix %q", parsed.prefix))
}
return nil
}
// 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.
// SAME production token shapes as the real client (cnon: nonces / ccof: card
// ids — isTokenLike). The legacy verify_mock_<prefix>_<amount> source widening
// is REMOVED: the transition to genuine cnon:sca- tokenize-results is complete
// and real Square never accepts verify_mock_ as a source_id, so accepting it in
// the mock would mask a bug that prod would reject. verify_mock_ tokens remain
// valid in the VerificationToken field (the deprecated verifyBuyer() contract) —
// only the source-slot widening is removed.
func isTokenLikeMock(s string) bool {
return isTokenLike(s) || strings.HasPrefix(s, "verify_mock_")
return isTokenLike(s)
}
// legacyVerifyMockSourceError is the mock's explicit rejection of the
// deprecated verify_mock_<prefix>_<amount>[_ok|_deny] charge-source shape the
// dev frontend used during the token-shape transition. Real Square never
// accepts verify_mock_ as a source_id (it is not a cnon:/ccof: token), so the
// mock refuses it with a clear "legacy shape not supported" error instead of
// silently accepting it — the old source widening (isTokenLikeMock) masked the
// divergence.
func legacyVerifyMockSourceError(s string) error {
return fmt.Errorf("square: %s is the legacy verify_mock_ transition shape and is NOT a valid charge source — real Square rejects it; mint a genuine tokenize-result (cnon:sca-<prefix>_<amount>_ok) or use a cnon:/ccof: token", tokenPrefix(s))
}
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
@@ -580,9 +694,12 @@ 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). The mock's token
// predicate additionally accepts the verify_mock_* transition shape the dev
// frontend mints (isTokenLikeMock).
// mock behaves identically to production (PCI-DSS parity). The legacy
// verify_mock_* transition shape is rejected explicitly — real Square never
// accepts it as a source_id (isTokenLikeMock).
if strings.HasPrefix(body.SourceID, "verify_mock_") {
return nil, legacyVerifyMockSourceError(body.SourceID)
}
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))
}
@@ -695,7 +812,11 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
// Square's documented wire status for verification-required is
// 402 Payment Required, not 400 — the mock must match the WIRE
// (the handler's code-keyed classification is unchanged, and
// chargeFailureStatus maps any definitive 4xx to 402 anyway).
StatusCode: http.StatusPaymentRequired,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
}
default:
@@ -747,11 +868,22 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "unverified card nonce cannot be used as a saved-card (card-on-file) charge source; complete buyer verification (tokenizeWithVerification) first",
StatusCode: http.StatusBadRequest,
// 402 on the wire, matching Square's documented status for
// verification-required.
StatusCode: http.StatusPaymentRequired,
err: fmt.Errorf("square: unverified cnon nonce %s cannot be used as a saved-card (card-on-file) charge source — a genuine tokenizeWithVerification result is required", tokenPrefix(body.SourceID)),
}
}
// (a) genuine tokenize-result — the token IS the buyer verification.
// The mock validates the deterministic binding the dev frontend
// encodes into the token (cnon:sca-<prefix>_<amount>_ok|_deny): the
// embedded amount must match the charge, the prefix must bind to a
// card the mock knows, and a _deny outcome is refused. An arbitrary
// cnon:sca-... string that does not prove verification for THIS
// charge is rejected exactly like real Square's SCA enforcement.
if err := m.validateSCATokenizeResult(body.SourceID, body.AmountMoney.Amount); err != nil {
return nil, err
}
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA satisfied by tokenize-result: source %s (no verification_token needed)", tokenPrefix(body.SourceID))
case body.VerificationToken == "":
if m.grandfatheredCards[body.SourceID] {
@@ -770,7 +902,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
// 402 on the wire, matching Square's documented status for
// verification-required.
StatusCode: http.StatusPaymentRequired,
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
}
}
@@ -1084,6 +1218,17 @@ func (m *MockClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
}
// Mirror prod's replay-by-key sanity validation
// (replayPaymentByKeyHTTPWithClient, square_http_client.go): a snapshot
// missing source_id or idempotency_key is CORRUPT — never replay it as if
// it could succeed. Prod returns a plain (ambiguous) error here, so the
// sweep leaves the row PENDING for manual reconciliation; the mock must NOT
// answer ErrReplayKeyNotRetained (which the sweep treats as "the charge
// provably never happened" and definitively fails the row) — that is the
// OPPOSITE money decision on a snapshot we cannot trust.
if req.SourceID == "" || req.IdempotencyKey == "" {
return nil, fmt.Errorf("square: replay-by-key snapshot missing source_id/idempotency_key")
}
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s, source=%s", req.IdempotencyKey, tokenPrefix(req.SourceID))
m.mu.RLock()
@@ -1201,9 +1346,16 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
// Known payments get the real Square over-refund rejection: refunding more
// than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square
// returns that SAME code for an already-refunded payment, so — exactly like
// the real client — the mock reconciles: an existing refund (money already
// moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is
// genuinely invalid → ErrRefundDeclined.
// the real client (refundPaymentHTTPWithClient's REFUND_AMOUNT_INVALID
// reconciliation via paymentRefundedExactlyWithClient) — the mock
// reconciles: an existing COMPLETED refund for the EXACT requested amount
// (that money provably already moved) → ErrRefundAlreadyProcessed; anything
// else → the amount is genuinely invalid → ErrRefundDeclined. A different
// amount is NEVER AlreadyProcessed: only an exact-amount refund covers the
// requested money, so an over-refund on top of a PARTIAL prior refund
// surfaces as ErrRefundDeclined (the row is marked failed, alerting the
// over-refund guard bug) instead of being resolved 'completed' and hiding
// it.
if payment, ok := m.payments[req.PaymentID]; ok {
remaining := payment.Amount
for _, r := range m.refunds {
@@ -1219,7 +1371,7 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID),
}
if remaining < payment.Amount {
if m.refundExistsExact(req.PaymentID, req.Amount) {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr)
}
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr)
@@ -1269,6 +1421,22 @@ func (m *MockClient) RefundKeyCount() int {
return len(m.refundByKey)
}
// refundExistsExact reports whether the mock holds a COMPLETED refund for the
// payment in the EXACT amount requested — the same exact-match reconciliation
// the real client runs when Square returns REFUND_AMOUNT_INVALID
// (paymentRefundedExactlyWithClient: status COMPLETED && amount == requested).
// Only an exact-amount COMPLETED refund proves THIS requested money already
// moved; a partial refund does not cover it. Caller holds m.mu (RefundPayment
// holds the write lock).
func (m *MockClient) refundExistsExact(paymentID string, amount int64) bool {
for _, r := range m.refunds {
if r.PaymentID == paymentID && r.Status == "COMPLETED" && r.Amount == amount {
return true
}
}
return false
}
// PaymentWasRefunded mirrors the real client's reconciliation source: true when
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
@@ -1346,9 +1514,11 @@ 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. The mock's token predicate
// additionally accepts the verify_mock_* transition shape the dev frontend
// mints (isTokenLikeMock).
// mock behaves identically to production. The legacy verify_mock_* shape is
// rejected explicitly — real Square never accepts it as a source.
if strings.HasPrefix(cardToken, "verify_mock_") {
return nil, legacyVerifyMockSourceError(cardToken)
}
if !isTokenLikeMock(cardToken) {
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
}
+288 -18
View File
@@ -891,6 +891,64 @@ func TestDevClient_ReplayPaymentByKey_UnknownKey_NotRetained(t *testing.T) {
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "unknown-key cnon replay must surface ErrReplayKeyNotRetained, got %v", err)
}
func TestDevClient_ReplayPaymentByKey_CorruptSnapshot_PlainError(t *testing.T) {
// Identical-body replay contract (FIX 4): a snapshot missing source_id or
// idempotency_key is CORRUPT. Prod (replayPaymentByKeyHTTPWithClient,
// square_http_client.go) returns a plain (ambiguous) error for such a
// snapshot — the sweep leaves the row PENDING for manual reconciliation.
// The mock must mirror that, NOT answer ErrReplayKeyNotRetained (which the
// sweep treats as "the charge provably never happened" and definitively
// fails the row) — that is the OPPOSITE money decision on a snapshot we
// cannot trust.
client := NewDevClient().(*MockClient)
ctx := context.Background()
t.Run("empty_source_is_plain_error", func(t *testing.T) {
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "",
IdempotencyKey: "key-empty-source",
}))
require.Error(t, err)
assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "a corrupt snapshot must NOT be treated as proof the charge never happened, got %v", err)
assert.Contains(t, err.Error(), "missing source_id/idempotency_key")
assert.Equal(t, "", ErrorCode(err), "a corrupt snapshot is an ambiguous plain error, not a structured Square rejection")
})
t.Run("empty_key_is_plain_error", func(t *testing.T) {
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "",
}))
require.Error(t, err)
assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "a corrupt snapshot must NOT be treated as proof the charge never happened, got %v", err)
assert.Contains(t, err.Error(), "missing source_id/idempotency_key")
})
t.Run("empty_source_must_not_charge", func(t *testing.T) {
// Regression guard for the OLD mock behaviour: with a corrupt snapshot
// (empty source) the mock must not fall through to the unknown-key
// "attempt a real charge" path at all — a ccof-ish empty source must
// never mint a payment.
client.mu.RLock()
payCount := len(client.payments)
client.mu.RUnlock()
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "",
IdempotencyKey: "key-empty-source-2",
}))
require.Error(t, err)
client.mu.RLock()
defer client.mu.RUnlock()
assert.Equal(t, payCount, len(client.payments), "a corrupt snapshot must never be replayed into a charge")
})
}
func TestDevClient_ReplayPaymentByKey_UnknownKey_CcofSavedCard_ChargesAndRescues(t *testing.T) {
// B3 dev/prod parity: an unknown key with a STILL-VALID ccof: saved-card
// token makes real Square attempt a REAL charge that succeeds — the sweep
@@ -1419,6 +1477,53 @@ func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) {
}
}
// TestDevClient_CreatePayment_RejectsLegacyVerifyMockSource locks the FIX 5
// removal of the verify_mock_ source widening: the transition to genuine
// cnon:sca- tokenize-results is complete and real Square NEVER accepts the
// verify_mock_<prefix>_<amount> shape as a source_id — so the mock rejects it
// explicitly with a clear legacy-shape error (a raw PAN-style "invalid
// source_id" message would obscure the reason). verify_mock_ tokens remain
// valid in the VerificationToken field (the deprecated verifyBuyer() contract)
// — only the source-slot widening is removed.
func TestDevClient_CreatePayment_RejectsLegacyVerifyMockSource(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
t.Run("create_payment_rejects_verify_mock_source", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "verify_mock_4242_5000_ok",
IdempotencyKey: "verify-mock-source",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "legacy verify_mock_ transition shape")
assert.False(t, errors.Is(err, ErrRefundDeclined))
})
t.Run("create_card_on_file_rejects_verify_mock_source", func(t *testing.T) {
card, err := client.CreateCardOnFile(ctx, "user-verify-mock", "verify_mock_4242_5000_ok", "cus_test123")
require.Error(t, err)
assert.Nil(t, card)
assert.Contains(t, err.Error(), "legacy verify_mock_ transition shape")
})
t.Run("verify_mock_verification_token_still_accepted", func(t *testing.T) {
// The legacy verifyBuyer() contract (ccof: + verification_token) is
// retained for backward-compat — only the SOURCE widening is removed.
client.SimulateSavedCardVerificationRequired = true
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "ccof:mock_saved", CustomerID: "cus_test123",
IdempotencyKey: "verify-mock-token-still-ok",
VerificationToken: "verify_mock_mock_5000_ok",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
})
}
// TestDevClient_CreatePayment_CardOnFileRequiresCustomerID verifies the mock
// mirrors Square's real enforcement: charging a ccof: (card-on-file) token
// without a customer_id is rejected with a structured 400 INVALID_REQUEST_ERROR
@@ -2047,10 +2152,13 @@ func TestDevClient_RefundPayment_UnknownPayMockID_NotFound(t *testing.T) {
// TestDevClient_RefundPayment_OverRefund locks the mock's real Square
// over-refund rejection: refunding more than the remaining balance answers 400
// REFUND_AMOUNT_INVALID. Square returns that SAME code for an already-refunded
// payment, so — exactly like the real client — the mock reconciles: no refund
// recorded yet → the amount is genuinely invalid → ErrRefundDeclined; an
// existing refund (money already moved) → ErrRefundAlreadyProcessed. The
// exact-remaining boundary refund succeeds.
// payment, so — exactly like the real client (paymentRefundedExactlyWithClient)
// — the mock reconciles: an EXACT-amount COMPLETED refund for the requested
// amount → ErrRefundAlreadyProcessed (that money provably moved); anything else
// (no refund, or a PARTIAL prior refund that does not cover the requested
// amount) → the amount is genuinely invalid → ErrRefundDeclined. A different
// amount is NEVER AlreadyProcessed — the over-refund guard bug must surface in
// dev too. The exact-remaining boundary refund succeeds.
func TestDevClient_RefundPayment_OverRefund(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
@@ -2096,7 +2204,7 @@ func TestDevClient_RefundPayment_OverRefund(t *testing.T) {
assert.Equal(t, "COMPLETED", second.Status)
})
t.Run("over_refund_with_existing_refund_is_already_processed", func(t *testing.T) {
t.Run("over_refund_after_partial_refund_is_declined", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-existing",
})
@@ -2109,15 +2217,41 @@ func TestDevClient_RefundPayment_OverRefund(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "COMPLETED", first.Status)
// Over-refunding with a DIFFERENT amount (8000) is a genuine decline:
// no EXACT-amount refund for 8000 exists, so prod classifies it
// ErrRefundDeclined (the row is marked failed — the over-refund guard
// bug surfaces) — never ErrRefundAlreadyProcessed, which would hide it.
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing",
})
require.Error(t, err)
assert.Nil(t, result)
// The REFUND_AMOUNT_INVALID squareAPIError is hidden behind the %v
// sentinel wrap (ErrorCode returns ""), so the observable contract is
// the ErrRefundAlreadyProcessed sentinel.
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund on a payment that already has refunds must reconcile to ErrRefundAlreadyProcessed, got %v", err)
assert.True(t, errors.Is(err, ErrRefundDeclined), "an over-refund on top of a PARTIAL prior refund must reconcile to ErrRefundDeclined (no exact-amount refund covers 8000), got %v", err)
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed))
})
t.Run("over_refund_exact_amount_already_refunded_is_already_processed", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-exact",
})
require.NoError(t, err)
// An 8000 refund leaves 2000 remaining.
first, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-exact-move",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", first.Status)
// Re-requesting the SAME 8000 (now an over-refund) matches an EXACT-
// amount COMPLETED refund — that money provably already moved, exactly
// like prod's paymentRefundedExactlyWithClient reconciliation.
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-exact",
})
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund matching an existing EXACT-amount refund must reconcile to ErrRefundAlreadyProcessed, got %v", err)
assert.False(t, errors.Is(err, ErrRefundDeclined))
})
}
@@ -2162,7 +2296,9 @@ func TestDevClient_CreatePayment_SimulateVerificationRequired(t *testing.T) {
assert.Nil(t, result)
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
// The WIRE status must be 402 (Square's documented status for
// verification-required), not 400.
assert.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
})
@@ -2281,7 +2417,9 @@ func TestDevClient_CreatePayment_SavedCardVerificationRequired(t *testing.T) {
assert.Nil(t, result)
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
// The WIRE status must be 402 (Square's documented status for
// verification-required), not 400.
assert.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
})
@@ -2674,7 +2812,7 @@ func TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical(t *testing.T) {
req := CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:sca-tokenize-result",
SourceID: "cnon:sca-4242_5000_ok",
IdempotencyKey: "sca-contract-key-1",
ReferenceID: "booking-contract-1",
Note: "full",
@@ -2700,7 +2838,7 @@ func TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical(t *testing.T) {
// contract, not a shared-but-wrong shape.
var wire map[string]any
require.NoError(t, json.Unmarshal(clientWire, &wire))
require.Equal(t, "cnon:sca-tokenize-result", wire["source_id"], "the tokenize-result token must be the source_id")
require.Equal(t, "cnon:sca-4242_5000_ok", wire["source_id"], "the tokenize-result token must be the source_id")
require.Equal(t, "cus_sca_123", wire["customer_id"], "customer_id must come from the saved card")
require.NotContains(t, wire, "verification_token", "the SCA tokenize-result path must not emit a legacy verification_token")
amt, ok := wire["amount_money"].(map[string]any)
@@ -2747,7 +2885,7 @@ func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted(t *testing
t.Run("tokenize_result_with_customer_is_sca_compliant", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-tokenize-1", CustomerID: "cus_sca_1",
SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_sca_1",
IdempotencyKey: "sca-accept-1",
})
require.NoError(t, err)
@@ -2780,7 +2918,7 @@ func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn(t *test
t.Run("tokenize_result_with_customer_passes_both_gates", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-both-on", CustomerID: "cus_sca_both",
SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_sca_both",
IdempotencyKey: "both-on-accept",
})
require.NoError(t, err)
@@ -2826,14 +2964,16 @@ func TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected(t *te
require.Nil(t, result)
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
require.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
// The WIRE status must be 402 (Square's documented status for
// verification-required), not 400.
require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err), "a forged unverified nonce must be a definitive payment error")
})
t.Run("genuine_tokenize_result_still_accepted", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-tokenize-fresh", CustomerID: "cus_genuine",
SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_genuine",
IdempotencyKey: "genuine-tokenize-result",
})
require.NoError(t, err)
@@ -2863,7 +3003,7 @@ func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse(t *testin
client.SimulateSourceUsed = true
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
source := "cnon:sca-single-use-booking"
source := "cnon:sca-4242_5000_ok"
// Booking A's charge consumes the tokenize-result.
first, err := client.CreatePayment(ctx, CreatePaymentReq{
@@ -2887,3 +3027,133 @@ func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse(t *testin
require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
require.Contains(t, client.UsedSources(), source, "the consumed tokenize-result must be reported by UsedSources")
}
// TestParseSCATokenizeResult pins the deterministic cnon:sca- tokenize-result
// encoding the dev frontend mints (MockCardForm.tokenizeSavedCard /
// verifySavedCard, square.ts's tokenizeSavedCardWithVerification fallback):
// cnon:sca-<prefix>_<amount>[_ok|_deny], outcome suffix defaults to approval,
// "_deny" sets denied=true, and anything malformed is not parseable.
func TestParseSCATokenizeResult(t *testing.T) {
tests := []struct {
name string
token string
wantOK bool
wantDenied bool
wantAmount int64
wantPrefix string
}{
{"explicit ok suffix", "cnon:sca-4242_5000_ok", true, false, 5000, "4242"},
{"deny suffix sets denied", "cnon:sca-4242_5000_deny", true, true, 5000, "4242"},
{"no suffix defaults to approval", "cnon:sca-mock_2500", true, false, 2500, "mock"},
{"prefix with underscores", "cnon:sca-visa_card_3000_deny", true, true, 3000, "visa_card"},
{"marker with empty rest", "cnon:sca-", false, false, 0, ""},
{"non-numeric amount", "cnon:sca-4242_abc_ok", false, false, 0, ""},
{"missing amount", "cnon:sca-4242_ok", false, false, 0, ""},
{"not a tokenize-result", "cnon:test-card", false, false, 0, ""},
{"raw tokenize-result marker", "cnon:sca-tokenize-result", false, false, 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed, ok := parseSCATokenizeResult(tt.token)
require.Equal(t, tt.wantOK, ok)
if ok {
require.Equal(t, tt.wantDenied, parsed.denied)
require.Equal(t, tt.wantAmount, parsed.amount)
require.Equal(t, tt.wantPrefix, parsed.prefix)
}
})
}
}
// TestDevClient_CreatePayment_SCATokenizeResult_BindingValidation locks the
// FIX 2 contract: the mock validates the deterministic binding the dev frontend
// encodes into a cnon:sca- tokenize-result. A token bound to the correct card
// prefix + amount with an _ok outcome passes; a wrong-prefix, wrong-amount,
// buyer-denied (_deny) or unparseable cnon:sca- token is refused with the
// documented 402 CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment
// error) — an arbitrary cnon:sca-... string must never pass the gate.
func TestDevClient_CreatePayment_SCATokenizeResult_BindingValidation(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
t.Run("valid_token_passes", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-4242_5000_ok", CustomerID: "cus_valid",
IdempotencyKey: "sca-binding-valid",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
})
t.Run("wrong_prefix_is_refused", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-9999_5000_ok", CustomerID: "cus_wrong_prefix",
IdempotencyKey: "sca-binding-wrong-prefix",
})
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err))
})
t.Run("wrong_amount_is_refused", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-4242_9999_ok", CustomerID: "cus_wrong_amount",
IdempotencyKey: "sca-binding-wrong-amount",
})
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err))
})
t.Run("deny_outcome_is_refused", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-4242_5000_deny", CustomerID: "cus_denied",
IdempotencyKey: "sca-binding-deny",
})
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err))
})
t.Run("unparseable_cnon_sca_is_refused", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-garbage", CustomerID: "cus_forged",
IdempotencyKey: "sca-binding-forged",
})
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
require.Equal(t, http.StatusPaymentRequired, ErrorStatusCode(err))
require.True(t, IsDefinitivePaymentError(err))
})
t.Run("mock_ledger_card_prefix_is_accepted", func(t *testing.T) {
// The frontend derives the token prefix from the SAVED card's ccof id
// (first 4 chars after ccof:). A card this mock created in its ledger
// ("ccof:mock_...") mints tokens bound to the "mock" prefix — the mock
// must recognise that prefix as valid.
card, err := client.CreateCardOnFile(ctx, "user-sca-ledger", "cnon:token-ledger", "cus_ledger")
require.NoError(t, err)
require.True(t, strings.HasPrefix(card.CardID, "ccof:mock_"), "mock-created cards use the ccof:mock_ shape")
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:sca-mock_5000_ok", CustomerID: "cus_ledger",
IdempotencyKey: "sca-binding-ledger",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
})
}
+36 -2
View File
@@ -94,7 +94,14 @@ func SquareLocationID() string {
func newHTTPClient() *httpClient {
env := SquareEnvironment()
baseURL := squareSandboxURL
if env == "production" {
// Empty/unknown SQUARE_ENVIRONMENT is production-ENFORCED, mirroring
// payments.IsExplicitDevOrMockEnv()'s fail-closed interpretation (an
// unset/mistyped env var must never downgrade to a less safe default), so
// the production base URL is used for both "production" and "". Only an
// explicit sandbox or dev/mock value resolves to the sandbox base URL (the
// dev/mock values never construct a real HTTP client anyway).
switch env {
case "production", "":
baseURL = squareProductionURL
}
return &httpClient{
@@ -106,6 +113,24 @@ func newHTTPClient() *httpClient {
}
}
// ValidateCredentials is the fail-closed startup validation for the REAL
// Square API credentials. It is called from main.go's startup gate ONLY for
// non-dev/mock deployments (main.go checks payments.IsExplicitDevOrMockEnv
// first — the env interpretation stays in the payments package to avoid a
// drifting duplicate read). With a real deployment selected, a missing
// SQUARE_ACCESS_TOKEN or SQUARE_LOCATION_ID would make every Square API call
// fail at runtime (401 auth, or request bodies without location_id), breaking
// payments/refunds — so the process refuses to start, mirroring the
// SQUARE_WEBHOOK_SIGNATURE_KEY gate. Never includes the secret values.
func ValidateCredentials() {
if strings.TrimSpace(os.Getenv("SQUARE_ACCESS_TOKEN")) == "" {
log.Fatalf("FATAL: SQUARE_ACCESS_TOKEN environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) — every Square API call would be rejected (401) and online payment/refund processing is broken. Set the access token from the Square Developer Dashboard.", SquareEnvironment())
}
if strings.TrimSpace(SquareLocationID()) == "" {
log.Fatalf("FATAL: SQUARE_LOCATION_ID environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) — Square payment requests would carry no location_id and fail at runtime. Set the location ID from the Square Developer Dashboard.", SquareEnvironment())
}
}
func (c *httpClient) doJSON(ctx context.Context, method, path string, body, target any) error {
if c.token == "" {
return fmt.Errorf("square: SQUARE_ACCESS_TOKEN is not set")
@@ -1008,7 +1033,16 @@ func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time)
}
func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime time.Time, hc *httpClient) ([]RefundResult, error) {
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
// begin_time is optional at Square; a ZERO time must OMIT the param
// entirely. Formatting the zero time would send Square a year-1 timestamp
// (0001-01-01T00:00:00Z) that Square may reject. The reconciliation callers
// (PaymentWasRefunded / paymentRefundedExactlyWithClient) pass time.Time{}
// deliberately — they want ALL refunds, so the param must be absent, not
// "before year 1".
base := "/v2/refunds?limit=100"
if !beginTime.IsZero() {
base = "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
}
path := base
results := []RefundResult{}
for page := 0; page < 20; page++ {
@@ -12,10 +12,15 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
// TestReplayPaymentByKeyHTTP_IdenticalBody verifies the replay-by-key sends the
@@ -783,6 +788,54 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
})
}
// TestListRefundsHTTP_ZeroBeginTime_OmitsParam locks the FIX 6 parity: a ZERO
// begin_time must OMIT the begin_time query param entirely — formatting the
// zero time would send Square a year-1 timestamp (0001-01-01T00:00:00Z) it may
// reject. The reconciliation callers (PaymentWasRefunded /
// paymentRefundedExactlyWithClient) pass time.Time{} deliberately to list ALL
// refunds, so the param must be absent, not "before year 1". A non-zero
// begin_time still emits begin_time as before.
func TestListRefundsHTTP_ZeroBeginTime_OmitsParam(t *testing.T) {
var queries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queries = append(queries, r.URL.RawQuery)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"refunds":[],"cursor":""}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
// Zero time (the reconciliation shape) → begin_time must be absent.
_, err := listRefundsHTTPWithClient(context.Background(), "pay_zero", time.Time{}, hc)
if err != nil {
t.Fatalf("listRefundsHTTPWithClient failed: %v", err)
}
if len(queries) != 1 {
t.Fatalf("expected 1 request, got %d", len(queries))
}
if strings.Contains(queries[0], "begin_time=") {
t.Errorf("expected NO begin_time for a zero begin_time, got %q", queries[0])
}
if !strings.Contains(queries[0], "limit=100") {
t.Errorf("expected limit=100 in the zero-time query, got %q", queries[0])
}
// Non-zero time → begin_time is emitted with the RFC3339 timestamp.
queries = nil
begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
_, err = listRefundsHTTPWithClient(context.Background(), "pay_begin", begin, hc)
if err != nil {
t.Fatalf("listRefundsHTTPWithClient failed: %v", err)
}
if len(queries) != 1 {
t.Fatalf("expected 1 request, got %d", len(queries))
}
if !strings.Contains(queries[0], "begin_time="+url.QueryEscape("2026-07-01T00:00:00Z")) {
t.Errorf("expected begin_time=2026-07-01T00:00:00Z in query, got %q", queries[0])
}
}
// TestCreateCardOnFileHTTP_IdempotencyKey verifies the deterministic SHA-256
// idempotency key derivation and the request wire shape (source_id + card).
func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
@@ -1889,8 +1942,12 @@ func TestPaymentWasRefunded(t *testing.T) {
if r.URL.Path != "/v2/refunds" {
t.Errorf("expected /v2/refunds, got %s", r.URL.Path)
}
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
t.Errorf("expected begin_time in query, got %q", r.URL.RawQuery)
if strings.Contains(r.URL.RawQuery, "begin_time=") {
// The reconciliation passes a ZERO begin_time (time.Time{})
// to mean "all refunds"; the param must be ABSENT (FIX 6) —
// sending begin_time=0001-01-01T00:00:00Z is a year-1
// timestamp Square may reject.
t.Errorf("expected NO begin_time in the zero-time reconciliation query, got %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
if tc.statusCode != 0 {
@@ -1946,8 +2003,13 @@ func TestRefundPaymentHTTP_RefundAmountInvalidReconciliation(t *testing.T) {
if r.URL.Path != "/v2/refunds" {
t.Errorf("expected reconciliation GET /v2/refunds, got %s", r.URL.Path)
}
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
t.Errorf("expected begin_time in reconciliation query, got %q", r.URL.RawQuery)
if !strings.Contains(r.URL.RawQuery, "limit=100") {
t.Errorf("expected limit=100 in reconciliation query, got %q", r.URL.RawQuery)
}
if strings.Contains(r.URL.RawQuery, "begin_time=") {
// The reconciliation passes a ZERO begin_time to mean
// "all refunds"; the param must be ABSENT (FIX 6).
t.Errorf("expected NO begin_time in the zero-time reconciliation query, got %q", r.URL.RawQuery)
}
_, _ = w.Write([]byte(tc.refundList))
return
@@ -2090,3 +2152,72 @@ func TestCreatePaymentHTTP_SCASavedCard_WireShape(t *testing.T) {
t.Errorf("expected location_id, got %v", captured["location_id"])
}
}
// TestNewHTTPClient_BaseURLSelection locks the SQUARE_ENVIRONMENT → base URL
// resolution (FIX 3b): an empty SQUARE_ENVIRONMENT is production-ENFORCED
// (mirroring payments.IsExplicitDevOrMockEnv's fail-closed interpretation —
// an unset env var must never downgrade to the sandbox), so empty resolves to
// the production base URL, exactly like "production". Only an explicit
// "sandbox" or a dev/mock value resolves to the sandbox base URL.
func TestNewHTTPClient_BaseURLSelection(t *testing.T) {
cases := []struct {
name string
env string
want string
}{
{name: "production_env", env: "production", want: squareProductionURL},
{name: "empty_env_is_production_enforced", env: "", want: squareProductionURL},
{name: "sandbox_env", env: "sandbox", want: squareSandboxURL},
{name: "mock_env", env: "mock", want: squareSandboxURL},
{name: "dev_env", env: "dev", want: squareSandboxURL},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", tc.env)
if got := newHTTPClient().baseURL; got != tc.want {
t.Errorf("SQUARE_ENVIRONMENT=%q: expected base URL %s, got %s", tc.env, tc.want, got)
}
})
}
}
// TestValidateCredentials_FatalBranch_Exits covers the fail-closed startup
// validation of the REAL Square API credentials (FIX 3a): when the
// production-enforced gate in main.go (non-dev/mock SQUARE_ENVIRONMENT) calls
// ValidateCredentials and SQUARE_ACCESS_TOKEN or SQUARE_LOCATION_ID is
// missing, the process must refuse to start (log.Fatalf → os.Exit). log.Fatalf
// cannot run in-process, so the test re-executes the test binary with marker
// env vars and asserts the subprocess exits non-zero with a FATAL message
// naming the missing credential.
func TestValidateCredentials_FatalBranch_Exits(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") == "1" {
t.Setenv("SQUARE_ENVIRONMENT", "production")
switch os.Getenv("SQUARE_CRED_BRANCH") {
case "MISSING_TOKEN":
t.Setenv("SQUARE_ACCESS_TOKEN", "")
t.Setenv("SQUARE_LOCATION_ID", "L_TEST")
case "MISSING_LOCATION":
t.Setenv("SQUARE_ACCESS_TOKEN", "tok")
t.Setenv("SQUARE_LOCATION_ID", "")
}
ValidateCredentials()
return
}
for _, tc := range []struct {
name string
branch string
wantIn string
}{
{name: "missing_token_exits", branch: "MISSING_TOKEN", wantIn: "SQUARE_ACCESS_TOKEN"},
{name: "missing_location_exits", branch: "MISSING_LOCATION", wantIn: "SQUARE_LOCATION_ID"},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=TestValidateCredentials_FatalBranch_Exits")
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "SQUARE_CRED_BRANCH="+tc.branch)
out, err := cmd.CombinedOutput()
require.Error(t, err, "expected the branch to exit (log.Fatalf); output: %s", out)
require.Contains(t, string(out), tc.wantIn, "the fatal log must name the missing credential: %s", out)
require.False(t, strings.Contains(string(out), "unexpected argument"), "the helper must not fail on argument parsing: %s", out)
})
}
}