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