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
+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)
})
}