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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user