feat: Square SCA tokenize-result wire contract — token as source_id, byte-identical dev mock

The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails,
cardId)) returns a one-time tokenize-result that must be sent as the charge
SOURCE (source_id), not a separate verification_token.

- square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an
  independently assembled copy of buildCreatePaymentBody) so it accepts exactly
  the request shape the real client emits. SimulateSavedCardVerificationRequired
  now demands SCA on every saved-card charge in both wire shapes: (a) a genuine
  tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id +
  customer_id is ACCEPTED (the token IS the buyer verification); a RAW
  card.tokenize() nonce in the tokenize-result slot is REJECTED
  CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement
  point that stops the forged shape); (b) legacy ccof: + verification_token is
  kept for backward-compat.
- square_http_client.go: byte-identical body assembly shared with the mock, so
  TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the
  real client emit identical CreatePayment bodies (a wire drift fails the test
  before reaching prod).
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 2cdbad0cea
commit d25ba16aa7
5 changed files with 571 additions and 124 deletions
+238
View File
@@ -2649,3 +2649,241 @@ func TestDevClient_VerifyMockDenyTokenSuffix(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status, "the _ok encoding of the same binding must succeed (control)")
}
// =============================================================================
// CURRENT saved-card SCA contract: tokenize-result as source_id + customer_id
// =============================================================================
// TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical is the CRITICAL wire
// contract guard. For the same logical saved-card SCA charge, the real client
// (buildCreatePaymentBody, square_http_client.go) and the dev mock
// (mockPaymentWireBody) must emit BYTE-IDENTICAL CreatePayment request bodies:
// source_id = the card.tokenize(verificationDetails, cardId) tokenize-result
// (a fresh cnon:-style one-time token) + customer_id resolved from the saved
// card, with NO legacy verification_token. The constructors are written
// independently on purpose — if either side drifts (a renamed field, a token
// misrouted into verification_token, amount handling drift), the byte
// comparison fails here before the divergence can reach prod, exactly like the
// legacy verification_token + ccof: drift this rebuild replaces.
func TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical(t *testing.T) {
ctx := context.Background()
// The same logical saved-card SCA charge the frontend + handler produce:
// SourceID is the fresh tokenize-result, CustomerID derives from the saved
// card row, and NO verification_token rides along (the token IS the SCA).
req := CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:sca-tokenize-result",
IdempotencyKey: "sca-contract-key-1",
ReferenceID: "booking-contract-1",
Note: "full",
CustomerID: "cus_sca_123",
BuyerEmail: "buyer@example.com",
}
// Real client: buildCreatePaymentBody with an explicit location matching
// the mock's default (location_id is the only env-dependent wire field —
// buildCreatePaymentBody defaults it to SQUARE_LOCATION_ID, the mock to
// L_MOCK).
clientWire, err := json.Marshal(buildCreatePaymentBody(req, &httpClient{locationID: "L_MOCK"}))
require.NoError(t, err)
// Dev mock: its OWN independently-written wire-body constructor.
mockWire, err := json.Marshal(mockPaymentWireBody(req))
require.NoError(t, err)
require.Equal(t, string(clientWire), string(mockWire),
"mock and real client must emit byte-identical CreatePayment bodies for the saved-card SCA charge — wire drift re-slipped")
// Sanity-pin the wire shape so the byte-identity is provably the CURRENT
// 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, "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)
require.True(t, ok, "amount_money object expected, got %v", wire["amount_money"])
require.Equal(t, float64(5000), amt["amount"], "amount_money.amount must be integer pence")
require.Equal(t, "GBP", amt["currency"])
require.Equal(t, "sca-contract-key-1", wire["idempotency_key"])
require.Equal(t, "L_MOCK", wire["location_id"])
// Functional acceptance: the mock must ACCEPT this exact charge end-to-end
// even with the saved-card SCA gate enforced — the tokenize-result IS the
// buyer verification, so no verification_token is demanded.
mock := NewDevClient().(*MockClient)
mock.SimulateSavedCardVerificationRequired = true
pr, err := mock.CreatePayment(ctx, req)
require.NoError(t, err, "the mock must accept the client's SCA tokenize-result charge end-to-end")
require.Equal(t, "COMPLETED", pr.Status)
// And the LEGACY verifyBuyer() shape (ccof: + verification_token) stays
// accepted for backward-compat — the current contract replaces the legacy
// primary path, it does not remove it.
legacyReq := req
legacyReq.SourceID = "ccof:mock_saved"
legacyReq.VerificationToken = "verify_mock_mock_5000_ok"
legacyReq.IdempotencyKey = "sca-contract-key-2"
pr, err = mock.CreatePayment(ctx, legacyReq)
require.NoError(t, err, "the legacy ccof + verification_token shape must stay accepted")
require.Equal(t, "COMPLETED", pr.Status)
}
// TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted locks the
// mock's saved-card gate on the CURRENT contract: under
// SimulateSavedCardVerificationRequired, a charge with a fresh cnon:-style
// tokenize-result as source_id + the saved card's customer_id is ACCEPTED
// without any verification_token (the token only exists because the buyer
// completed issuer verification — it IS the SCA proof). A cnon: charge with NO
// customer_id is NOT a saved-card charge (the one-off new-card flow never sets
// one), so the saved-card gate leaves it to the separate new-card gate.
func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
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",
IdempotencyKey: "sca-accept-1",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
require.Equal(t, "cus_sca_1", result.CustomerID)
})
t.Run("tokenize_result_without_customer_is_not_a_saved_card_charge", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:new-card-nonce", IdempotencyKey: "sca-newcard-1",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
})
}
// TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn locks the
// interaction between the new-card and saved-card SCA gates: with BOTH toggles
// on, a cnon:+customer_id tokenize-result is accepted (the token IS the buyer
// verification, so it is exempt from the new-card verification-token
// requirement), while a plain cnon: new-card nonce without a verification token
// is still rejected by the new-card gate.
func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateVerificationRequired = true
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
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",
IdempotencyKey: "both-on-accept",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
})
t.Run("new_card_nonce_without_token_still_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:new-card-nonce", IdempotencyKey: "both-on-reject",
})
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
require.True(t, IsDefinitivePaymentError(err))
})
}
// TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected locks
// the money-F2 fix: under SimulateSavedCardVerificationRequired, a RAW
// card.tokenize() nonce (e.g. "cnon:test-card") sent in the SCA tokenize-result
// slot (source_id + customer_id) is REJECTED with the structured 400
// CARD_DECLINED_VERIFICATION_REQUIRED — real Square rejects an unverified nonce
// as a card-on-file charge source, so the mock must too (the handler treats any
// non-empty new_card_token + saved-card ref as an SCA tokenize-result, skipping
// the 2FA and consent gates; the mock is the enforcement point that stops the
// forged shape). A GENUINE tokenize-result (cnon:sca-..., the marker the dev
// frontend mints) still passes — the token IS the buyer verification. With the
// gate off (default) the raw-nonce shape keeps charging, so existing dev flows
// are unaffected.
func TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
t.Run("raw_nonce_with_customer_is_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:test-card", CustomerID: "cus_forge",
IdempotencyKey: "forge-raw-nonce",
})
require.Error(t, err)
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))
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",
IdempotencyKey: "genuine-tokenize-result",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status)
})
t.Run("gate_off_keeps_legacy_raw_nonce_shape", func(t *testing.T) {
client.SimulateSavedCardVerificationRequired = false
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: "cnon:test-card", CustomerID: "cus_legacy",
IdempotencyKey: "raw-nonce-gate-off",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", result.Status, "with the saved-card SCA gate off (default), the raw-nonce shape must keep charging")
})
}
// TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse locks the
// single-use contract for the saved-card SCA shape under SimulateSourceUsed: a
// genuine tokenize-result (cnon:sca-...) sent with a customer_id is consumed on
// its first CreatePayment, so reusing the SAME token for a DIFFERENT booking
// (a different idempotency key) is rejected with CARD_TOKEN_USED — Square
// consumes a nonce regardless of which endpoint or charge used it.
func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSourceUsed = true
client.SimulateSavedCardVerificationRequired = true
ctx := context.Background()
source := "cnon:sca-single-use-booking"
// Booking A's charge consumes the tokenize-result.
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: source, CustomerID: "cus_single",
IdempotencyKey: "booking-A-charge",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", first.Status)
// Booking B's charge reuses the SAME token under a DIFFERENT key — Square's
// CARD_TOKEN_USED rejection.
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP",
SourceID: source, CustomerID: "cus_single",
IdempotencyKey: "booking-B-charge",
})
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "CARD_TOKEN_USED", ErrorCode(err))
require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
require.Contains(t, client.UsedSources(), source, "the consumed tokenize-result must be reported by UsedSources")
}