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