Harden Square HTTP client and dev mock: status codes, token validation, deadline wire format

Add StatusCode/Category/Field to squareAPIError and an IsNotFound helper so 400/401/404/429/5xx are distinguishable structurally instead of by substring. Validate cnon:/ccof: token prefixes in createPayment/createCardOnFile (PCI parity with the mock). Reject ccof charges without customer_id in the mock so dev parity catches the production bug. Emit Deadline as the RFC 3339 duration (PT5M) and correct the deprecated-comment.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 54a5b1024e
commit 12af3af3b3
5 changed files with 399 additions and 39 deletions
@@ -106,6 +106,73 @@ func TestCreateCheckoutHTTP_DeviceOptionsWireShape(t *testing.T) {
}
}
// TestCreateCheckoutHTTP_DeadlineWireShape verifies the deadline_duration
// contract on BOTH sides of the wire. The terminal checkout REQUEST omits
// deadline_duration entirely (Square defaults it to 5 minutes; the struct has
// no request-side field), while the RESPONSE's deadline_duration ("PT5M") is
// parsed through checkoutFromSquare into CheckoutResult.Deadline — so callers
// see Square's live deadline instead of a stale default.
func TestCreateCheckoutHTTP_DeadlineWireShape(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
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")
// The real API echoes the checkout with a LIVE deadline_duration.
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_deadline","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"deadline_duration":"PT5M","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()}
res, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "ik-1",
DeviceID: "dvc_test",
}, hc)
if err != nil {
t.Fatalf("createCheckoutHTTP failed: %v", err)
}
// The request must NOT send deadline_duration (the code relies on Square's
// 5-minute default; there is no request-side deadline field).
checkout, ok := captured["checkout"].(map[string]any)
if !ok {
t.Fatalf("expected checkout object in body, got %v", captured)
}
if _, hasDeadline := checkout["deadline_duration"]; hasDeadline {
t.Errorf("expected request to omit deadline_duration (Square default applies), got %v", captured)
}
// The response's deadline_duration is copied through to CheckoutResult.
if res.Deadline != "PT5M" {
t.Errorf("expected CheckoutResult.Deadline = PT5M, got %q", res.Deadline)
}
}
func TestCheckoutFromSquare_Deadline(t *testing.T) {
res := checkoutFromSquare(&sqTerminalCheckout{
ID: "chk_1",
Status: "PENDING",
AmountMoney: sqMoney{Amount: 5000, Currency: "GBP"},
Deadline: "PT5M",
})
if res.Deadline != "PT5M" {
t.Errorf("expected Deadline PT5M, got %q", res.Deadline)
}
// No deadline in the wire response → empty Deadline (Square may omit it).
resEmpty := checkoutFromSquare(&sqTerminalCheckout{ID: "chk_2", Status: "PENDING"})
if resEmpty.Deadline != "" {
t.Errorf("expected empty Deadline when wire omits deadline_duration, got %q", resEmpty.Deadline)
}
}
// TestDoJSON_ErrorParsing covers the doJSON error branches: structured Square
// errors become *squareAPIError (with Code/Detail preserved), while non-JSON
// error bodies fall back to a plain error (so refund classification treats the
@@ -134,6 +201,28 @@ func TestDoJSON_ErrorParsing(t *testing.T) {
if sqErr.Detail != "The refund was declined" {
t.Errorf("expected detail, got %q", sqErr.Detail)
}
// The HTTP status, category, and field must be captured from the wire
// error so callers can distinguish 400/401/429/500 structurally.
if sqErr.StatusCode != http.StatusBadRequest {
t.Errorf("expected StatusCode 400, got %d", sqErr.StatusCode)
}
if sqErr.Category != "PAYMENT_METHOD_ERROR" {
t.Errorf("expected Category PAYMENT_METHOD_ERROR, got %q", sqErr.Category)
}
if sqErr.Field != "payment_id" {
t.Errorf("expected Field payment_id, got %q", sqErr.Field)
}
// Exported accessors surface the same values through wrapped errors.
wrapped := fmt.Errorf("wrap: %w", err)
if got := ErrorStatusCode(wrapped); got != http.StatusBadRequest {
t.Errorf("expected ErrorStatusCode 400 through wrap, got %d", got)
}
if got := ErrorCategory(wrapped); got != "PAYMENT_METHOD_ERROR" {
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR through wrap, got %q", got)
}
if got := ErrorField(wrapped); got != "payment_id" {
t.Errorf("expected ErrorField payment_id through wrap, got %q", got)
}
})
t.Run("non_json_error_body_is_plain_error", func(t *testing.T) {
@@ -1053,3 +1142,97 @@ func TestErrorCode_ErrorDetail(t *testing.T) {
}
})
}
// TestIsNotFound verifies the structured not-found check: the NOT_FOUND code,
// an HTTP 404 status, and a plain non-JSON 404 body all count; 400/500 errors
// and nil do not.
func TestIsNotFound(t *testing.T) {
notFound := &squareAPIError{Code: "NOT_FOUND", Detail: "nope", StatusCode: http.StatusNotFound, err: errors.New("square: boom")}
if !IsNotFound(notFound) {
t.Error("expected structured NOT_FOUND code to be IsNotFound")
}
status404 := &squareAPIError{Code: "OTHER_CODE", Detail: "nope", StatusCode: http.StatusNotFound, err: errors.New("square: boom")}
if !IsNotFound(status404) {
t.Error("expected HTTP 404 status to be IsNotFound regardless of code")
}
wrapped := fmt.Errorf("wrap: %w", notFound)
if !IsNotFound(wrapped) {
t.Error("expected IsNotFound to work through a wrapped error")
}
badRequest := &squareAPIError{Code: "INVALID_VALUE", Detail: "bad", StatusCode: http.StatusBadRequest, err: errors.New("square: boom")}
if IsNotFound(badRequest) {
t.Error("expected HTTP 400 NOT to be IsNotFound")
}
if IsNotFound(errors.New("square: GET /v2/x: HTTP 500: boom")) {
t.Error("expected HTTP 500 message NOT to be IsNotFound")
}
if IsNotFound(nil) {
t.Error("expected nil NOT to be IsNotFound")
}
// Plain non-JSON 404 body (doJSON's fallback error) still counts.
if !IsNotFound(errors.New("square: POST /v2/x: HTTP 404: plain body")) {
t.Error("expected plain HTTP 404 message to be IsNotFound")
}
}
// TestCreatePaymentHTTP_RejectsRawPAN verifies the real HTTP client refuses a
// raw PAN before forwarding to Square (PCI-DSS parity with the dev mock).
func TestCreatePaymentHTTP_RejectsRawPAN(t *testing.T) {
hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}}
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "4111111111111111", IdempotencyKey: "ik-raw-pan",
}, hc)
if err == nil {
t.Fatal("expected raw PAN to be rejected before any HTTP call")
}
if !strings.Contains(err.Error(), "invalid card token") {
t.Errorf("expected 'invalid card token' error, got %q", err.Error())
}
}
// TestCreateCardOnFileHTTP_RejectsRawPAN verifies the real HTTP client refuses
// a raw PAN card token before forwarding to Square (PCI-DSS parity).
func TestCreateCardOnFileHTTP_RejectsRawPAN(t *testing.T) {
hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}}
_, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "4111111111111111", "", hc)
if err == nil {
t.Fatal("expected raw PAN to be rejected before any HTTP call")
}
if !strings.Contains(err.Error(), "invalid card token") {
t.Errorf("expected 'invalid card token' error, got %q", err.Error())
}
}
// TestIsTokenLike covers the token validation single source of truth: cnon:
// nonces and ccof: card IDs are token-like, raw PANs and empty/malformed
// values are not. A bare "cnon:"/ccof:" prefix IS accepted — HasPrefix only
// checks the prefix and Square rejects empty payloads with its own 400.
func TestIsTokenLike(t *testing.T) {
valid := []string{
"cnon:abc",
"cnon:test-card",
"ccof:abc",
"ccof:mock_card_123",
"cnon:",
"ccof:",
}
invalid := []string{
"",
"4111111111111111",
"visa",
"cnon_abc",
"abc:cnon:",
}
for _, tok := range valid {
if !isTokenLike(tok) {
t.Errorf("expected %q to be token-like", tok)
}
}
for _, tok := range invalid {
if isTokenLike(tok) {
t.Errorf("expected %q to NOT be token-like", tok)
}
}
}