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:
@@ -2029,3 +2029,64 @@ func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) {
|
||||
t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePaymentHTTP_SCASavedCard_WireShape locks the CURRENT saved-card SCA
|
||||
// wire contract on the real client: a card-on-file charge sends source_id = the
|
||||
// card.tokenize(verificationDetails, cardId) tokenize-result (a cnon:-style
|
||||
// one-time token) plus customer_id from the saved card — Square requires
|
||||
// customer_id for a card-on-file source — and emits NO legacy verification_token
|
||||
// (the token IS the buyer verification). The dev mock's saved-card gate accepts
|
||||
// this exact shape, and the byte-identity contract test in square_dev_test.go
|
||||
// pins the mock's wire body identical to this client's.
|
||||
func TestCreatePaymentHTTP_SCASavedCard_WireShape(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_sca","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"ON_FILE"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", locationID: "loc", http: srv.Client()}
|
||||
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:sca-tokenize-result",
|
||||
CustomerID: "cus_sca_123",
|
||||
IdempotencyKey: "sca-wire-shape-key",
|
||||
ReferenceID: "booking-sca-1",
|
||||
Note: "full",
|
||||
LocationID: "loc",
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createPaymentHTTP failed: %v", err)
|
||||
}
|
||||
|
||||
if captured["source_id"] != "cnon:sca-tokenize-result" {
|
||||
t.Errorf("expected source_id = the tokenize-result token, got %v", captured["source_id"])
|
||||
}
|
||||
if captured["customer_id"] != "cus_sca_123" {
|
||||
t.Errorf("expected customer_id from the saved card, got %v", captured["customer_id"])
|
||||
}
|
||||
if _, present := captured["verification_token"]; present {
|
||||
t.Errorf("the SCA tokenize-result path must NOT emit a legacy verification_token, got %v", captured["verification_token"])
|
||||
}
|
||||
amt, ok := captured["amount_money"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected amount_money object, got %v", captured["amount_money"])
|
||||
}
|
||||
if amt["amount"] != float64(5000) || amt["currency"] != "GBP" {
|
||||
t.Errorf("expected amount_money {5000 GBP} in integer pence, got %v", amt)
|
||||
}
|
||||
if captured["idempotency_key"] != "sca-wire-shape-key" {
|
||||
t.Errorf("expected idempotency_key, got %v", captured["idempotency_key"])
|
||||
}
|
||||
if captured["reference_id"] != "booking-sca-1" {
|
||||
t.Errorf("expected reference_id, got %v", captured["reference_id"])
|
||||
}
|
||||
if captured["location_id"] != "loc" {
|
||||
t.Errorf("expected location_id, got %v", captured["location_id"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user