Covers DeleteCustomer (success + NOT_FOUND no-op), doJSON truncation (oversized bodies + rune-safe capBody), validCardID rejection without leaking the token, paymentFromSquare empty-card consistency, and mock-side customer-ID redaction in logs.
1532 lines
64 KiB
Go
1532 lines
64 KiB
Go
//go:build test
|
|
|
|
package square
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
|
p := &sqPayment{
|
|
ID: "pay_1",
|
|
Status: "COMPLETED",
|
|
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
|
CardDetails: &sqCardDetails{
|
|
Card: sqCard{
|
|
ID: "",
|
|
CardBrand: "VISA",
|
|
Last4: "4242",
|
|
ExpMonth: 12,
|
|
ExpYear: 2030,
|
|
},
|
|
},
|
|
}
|
|
|
|
result := paymentFromSquare(p)
|
|
if result.CardBrand != "VISA" {
|
|
t.Errorf("expected CardBrand VISA, got %s", result.CardBrand)
|
|
}
|
|
if result.CardLast4 != "4242" {
|
|
t.Errorf("expected CardLast4 4242, got %s", result.CardLast4)
|
|
}
|
|
// When the card object is empty (ID == "") the card-details fields must be
|
|
// left nil consistently — a 0/0 expiry with a nil fingerprint was the old
|
|
// inconsistent behaviour.
|
|
if result.ExpMonth != nil {
|
|
t.Errorf("expected nil ExpMonth when card ID is empty, got %d", *result.ExpMonth)
|
|
}
|
|
if result.ExpYear != nil {
|
|
t.Errorf("expected nil ExpYear when card ID is empty, got %d", *result.ExpYear)
|
|
}
|
|
if result.CardFingerprint != "" {
|
|
t.Errorf("expected empty CardFingerprint when card ID is empty, got %s", result.CardFingerprint)
|
|
}
|
|
}
|
|
|
|
func TestPaymentFromSquare_NilCardDetails(t *testing.T) {
|
|
p := &sqPayment{
|
|
ID: "pay_2",
|
|
Status: "COMPLETED",
|
|
TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"},
|
|
CardDetails: nil,
|
|
}
|
|
|
|
result := paymentFromSquare(p)
|
|
if result.CardBrand != "" {
|
|
t.Errorf("expected empty CardBrand when no card details, got %s", result.CardBrand)
|
|
}
|
|
if result.Amount != 2500 {
|
|
t.Errorf("expected amount 2500, got %d", result.Amount)
|
|
}
|
|
}
|
|
|
|
// TestCreateCheckoutHTTP_DeviceOptionsWireShape verifies the terminal checkout
|
|
// request puts device_id inside checkout.device_options (Square's required
|
|
// shape), not at the top level. A top-level device_id is rejected with 400 by
|
|
// Square's real API.
|
|
func TestCreateCheckoutHTTP_DeviceOptionsWireShape(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 r.URL.Path != "/v2/terminals/checkouts" {
|
|
t.Errorf("expected /v2/terminals/checkouts, got %s", r.URL.Path)
|
|
}
|
|
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(`{"checkout":{"id":"chk_test","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"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()}
|
|
|
|
_, 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)
|
|
}
|
|
|
|
checkout, ok := captured["checkout"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout object in body, got %v", captured)
|
|
}
|
|
// device_id must NOT be at the top level
|
|
if _, hasTopLevel := captured["device_id"]; hasTopLevel {
|
|
t.Errorf("device_id must not be top-level in terminal checkout request: %v", captured)
|
|
}
|
|
// device_id must live under checkout.device_options
|
|
devOpts, ok := checkout["device_options"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
|
}
|
|
if devOpts["device_id"] != "dvc_test" {
|
|
t.Errorf("expected checkout.device_options.device_id = dvc_test, got %v", devOpts)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// failure as ambiguous).
|
|
func TestDoJSON_ErrorParsing(t *testing.T) {
|
|
t.Run("structured_square_error_becomes_squareAPIError", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"REFUND_DECLINED","detail":"The refund was declined","field":"payment_id"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()}
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/refunds", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var sqErr *squareAPIError
|
|
if !errors.As(err, &sqErr) {
|
|
t.Fatalf("expected *squareAPIError, got %T", err)
|
|
}
|
|
if sqErr.Code != "REFUND_DECLINED" {
|
|
t.Errorf("expected code REFUND_DECLINED, got %s", sqErr.Code)
|
|
}
|
|
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) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("upstream blew up"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()}
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/refunds", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
t.Fatalf("expected plain error, got *squareAPIError with code %s", sqErr.Code)
|
|
}
|
|
if !strings.Contains(err.Error(), "HTTP 500") {
|
|
t.Errorf("expected HTTP 500 in error, got %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("http_3xx_is_treated_as_error", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusPermanentRedirect)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()}
|
|
if err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil); err == nil {
|
|
t.Fatal("expected error for 3xx status")
|
|
}
|
|
})
|
|
|
|
t.Run("empty_token_returns_error_before_http", func(t *testing.T) {
|
|
hc := &httpClient{baseURL: "http://unused", token: "", http: &http.Client{}}
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "SQUARE_ACCESS_TOKEN is not set") {
|
|
t.Fatalf("expected token error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCreatePaymentHTTP_WireShape verifies the exact request body and headers
|
|
// sent to POST /v2/payments: source_id, idempotency_key, amount_money,
|
|
// location_id fallback, tip_money, and the auth/Square-Version headers.
|
|
func TestCreatePaymentHTTP_WireShape(t *testing.T) {
|
|
var captured map[string]any
|
|
var gotAuth, gotVersion, gotContentType string
|
|
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 r.URL.Path != "/v2/payments" {
|
|
t.Errorf("expected /v2/payments, got %s", r.URL.Path)
|
|
}
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotVersion = r.Header.Get("Square-Version")
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
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_1","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_env","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "secret-token", locationID: "loc_env", http: srv.Client()}
|
|
tip := int64(500)
|
|
res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
|
Amount: 5000,
|
|
Currency: "GBP",
|
|
SourceID: "cnon:test-card",
|
|
IdempotencyKey: "ik-payment-1",
|
|
ReferenceID: "bk_123",
|
|
Note: "deposit",
|
|
TipMoney: &tip,
|
|
BuyerEmail: "buyer@example.com",
|
|
}, hc)
|
|
if err != nil {
|
|
t.Fatalf("createPaymentHTTP failed: %v", err)
|
|
}
|
|
|
|
if gotAuth != "Bearer secret-token" {
|
|
t.Errorf("expected Authorization 'Bearer secret-token', got %q", gotAuth)
|
|
}
|
|
if gotVersion != squareAPIVersion {
|
|
t.Errorf("expected Square-Version %q, got %q", squareAPIVersion, gotVersion)
|
|
}
|
|
if gotContentType != "application/json" {
|
|
t.Errorf("expected Content-Type application/json, got %q", gotContentType)
|
|
}
|
|
|
|
if captured["source_id"] != "cnon:test-card" {
|
|
t.Errorf("expected source_id cnon:test-card, got %v", captured["source_id"])
|
|
}
|
|
if captured["idempotency_key"] != "ik-payment-1" {
|
|
t.Errorf("expected idempotency_key ik-payment-1, got %v", captured["idempotency_key"])
|
|
}
|
|
if captured["location_id"] != "loc_env" {
|
|
t.Errorf("expected location_id loc_env (env fallback), got %v", captured["location_id"])
|
|
}
|
|
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}, got %v", amt)
|
|
}
|
|
tipMoney, ok := captured["tip_money"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected tip_money object, got %v", captured["tip_money"])
|
|
}
|
|
if tipMoney["amount"] != float64(500) {
|
|
t.Errorf("expected tip_money amount 500, got %v", tipMoney["amount"])
|
|
}
|
|
|
|
if res.ID != "pay_1" || res.Amount != 5000 || res.CardBrand != "VISA" || res.CardLast4 != "4242" {
|
|
t.Errorf("unexpected payment result: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestCreatePaymentHTTP_TipMoneyAbsentWhenNil verifies tip_money is omitted
|
|
// when not set (it's omitempty in the wire struct).
|
|
func TestCreatePaymentHTTP_TipMoneyAbsentWhenNil(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_2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"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", http: srv.Client()}
|
|
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
|
Amount: 1000, Currency: "GBP", SourceID: "ccof:existing", IdempotencyKey: "ik-2",
|
|
}, hc)
|
|
if err != nil {
|
|
t.Fatalf("createPaymentHTTP failed: %v", err)
|
|
}
|
|
if _, present := captured["tip_money"]; present {
|
|
t.Errorf("expected tip_money to be absent when nil, got %v", captured["tip_money"])
|
|
}
|
|
}
|
|
|
|
// TestRefundPaymentHTTP_CodeClassification verifies definitive refund rejection
|
|
// codes (Square's documented list: REFUND_DECLINED, REFUND_AMOUNT_INVALID,
|
|
// PAYMENT_NOT_REFUNDABLE) map to ErrRefundDeclined, the money-in-flight codes
|
|
// (PAYMENT_ALREADY_REFUNDED, REFUND_ALREADY_PENDING) map to
|
|
// ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped.
|
|
func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
code string
|
|
wantErrIs error // nil = no sentinel expected
|
|
wantErrNil bool
|
|
}{
|
|
{name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined},
|
|
{name: "amount_invalid", code: "REFUND_AMOUNT_INVALID", wantErrIs: ErrRefundDeclined},
|
|
{name: "payment_not_refundable", code: "PAYMENT_NOT_REFUNDABLE", wantErrIs: ErrRefundDeclined},
|
|
{name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", wantErrIs: ErrRefundAlreadyProcessed},
|
|
{name: "already_pending", code: "REFUND_ALREADY_PENDING", wantErrIs: ErrRefundAlreadyProcessed},
|
|
{name: "ambiguous_code", code: "INTERNAL_SERVER_ERROR", wantErrIs: nil},
|
|
{name: "ambiguous_non_json", code: "", wantErrIs: nil}, // raw text body
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
if tc.code == "" {
|
|
_, _ = w.Write([]byte("plain text failure"))
|
|
} else {
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":%q,"detail":"boom"}]}`, tc.code)))
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{
|
|
PaymentID: "pay_1", Amount: 1000, IdempotencyKey: "ik-refund",
|
|
}, hc)
|
|
if tc.wantErrNil {
|
|
if err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if tc.wantErrIs == nil {
|
|
if errors.Is(err, ErrRefundDeclined) || errors.Is(err, ErrRefundAlreadyProcessed) {
|
|
t.Fatalf("expected NO sentinel for ambiguous error, got %v", err)
|
|
}
|
|
return
|
|
}
|
|
if !errors.Is(err, tc.wantErrIs) {
|
|
t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRefundPaymentHTTP_WireShape verifies the refund request body and success
|
|
// response parsing.
|
|
func TestRefundPaymentHTTP_WireShape(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 r.URL.Path != "/v2/refunds" {
|
|
t.Errorf("expected /v2/refunds, got %s", r.URL.Path)
|
|
}
|
|
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(`{"refund":{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
res, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{
|
|
PaymentID: "pay_1", Amount: 1000, IdempotencyKey: "ik-refund", Reason: "cancellation",
|
|
}, hc)
|
|
if err != nil {
|
|
t.Fatalf("refundPaymentHTTP failed: %v", err)
|
|
}
|
|
if captured["payment_id"] != "pay_1" {
|
|
t.Errorf("expected payment_id pay_1, got %v", captured["payment_id"])
|
|
}
|
|
if captured["idempotency_key"] != "ik-refund" {
|
|
t.Errorf("expected idempotency_key ik-refund, got %v", captured["idempotency_key"])
|
|
}
|
|
if res.ID != "ref_1" || res.Status != "COMPLETED" || res.Amount != 1000 || res.PaymentID != "pay_1" {
|
|
t.Errorf("unexpected refund result: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestGetCheckoutHTTP_StatusBranches covers the checkout polling state machine:
|
|
// PENDING and IN_PROGRESS → ErrCheckoutPending, CANCELED → generic error,
|
|
// COMPLETED without payment IDs → generic error, COMPLETED with payment IDs →
|
|
// fetches the payment.
|
|
func TestGetCheckoutHTTP_StatusBranches(t *testing.T) {
|
|
t.Run("pending_returns_ErrCheckoutPending", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_1","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := getCheckoutHTTPWithClient(context.Background(), "chk_1", hc)
|
|
if !errors.Is(err, ErrCheckoutPending) {
|
|
t.Fatalf("expected ErrCheckoutPending, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("in_progress_returns_ErrCheckoutPending", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_2","status":"IN_PROGRESS","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := getCheckoutHTTPWithClient(context.Background(), "chk_2", hc)
|
|
if !errors.Is(err, ErrCheckoutPending) {
|
|
t.Fatalf("expected ErrCheckoutPending, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("cancelled_is_generic_error", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_3","status":"CANCELED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := getCheckoutHTTPWithClient(context.Background(), "chk_3", hc)
|
|
if err == nil || errors.Is(err, ErrCheckoutPending) {
|
|
t.Fatalf("expected generic non-pending error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "CANCELED") {
|
|
t.Errorf("expected status in error, got %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("completed_without_payment_ids_is_error", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_4","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := getCheckoutHTTPWithClient(context.Background(), "chk_4", hc)
|
|
if err == nil || !strings.Contains(err.Error(), "no payment IDs") {
|
|
t.Fatalf("expected 'no payment IDs' error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("completed_fetches_payment", func(t *testing.T) {
|
|
var checkoutPath, paymentPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case r.URL.Path == "/v2/terminals/checkouts/chk_5":
|
|
checkoutPath = r.URL.Path
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_5","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_ids":["pay_5"],"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
case r.URL.Path == "/v2/payments/pay_5":
|
|
paymentPath = r.URL.Path
|
|
_, _ = w.Write([]byte(`{"payment":{"id":"pay_5","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":"EMV"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
res, err := getCheckoutHTTPWithClient(context.Background(), "chk_5", hc)
|
|
if err != nil {
|
|
t.Fatalf("getCheckoutHTTP failed: %v", err)
|
|
}
|
|
if checkoutPath == "" || paymentPath == "" {
|
|
t.Fatal("expected both checkout and payment fetches")
|
|
}
|
|
if res.ID != "pay_5" || res.Amount != 5000 || res.EntryMethod != "EMV" {
|
|
t.Errorf("unexpected payment result: %+v", res)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestListRefundsHTTP_Pagination verifies cursor-based pagination: multiple
|
|
// pages are fetched, filtered by paymentID, and the 20-page guard triggers.
|
|
func TestListRefundsHTTP_Pagination(t *testing.T) {
|
|
t.Run("two_pages_combined_and_filtered", func(t *testing.T) {
|
|
var paths []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
paths = append(paths, r.URL.String())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if strings.Contains(r.URL.RawQuery, "cursor=page2") {
|
|
_, _ = w.Write([]byte(`{"refunds":[{"id":"ref_3","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_target","created_at":"2026-07-31T00:00:00Z"},{"id":"ref_4","status":"COMPLETED","amount_money":{"amount":2000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}],"cursor":""}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":500,"currency":"GBP"},"payment_id":"pay_target","created_at":"2026-07-31T00:00:00Z"},{"id":"ref_2","status":"COMPLETED","amount_money":{"amount":900,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}],"cursor":"page2"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_target", begin, hc)
|
|
if err != nil {
|
|
t.Fatalf("listRefundsHTTP failed: %v", err)
|
|
}
|
|
if len(paths) != 2 {
|
|
t.Fatalf("expected 2 pages, got %d: %v", len(paths), paths)
|
|
}
|
|
if !strings.Contains(paths[0], "begin_time=") {
|
|
t.Errorf("expected begin_time in first request, got %q", paths[0])
|
|
}
|
|
if !strings.Contains(paths[0], "limit=100") {
|
|
t.Errorf("expected limit=100 in first request, got %q", paths[0])
|
|
}
|
|
// Only pay_target refunds survive the client-side filter.
|
|
if len(refunds) != 2 {
|
|
t.Fatalf("expected 2 filtered refunds (ref_1 + ref_3), got %d: %+v", len(refunds), refunds)
|
|
}
|
|
})
|
|
|
|
t.Run("page_guard_returns_partial_results", func(t *testing.T) {
|
|
// The 20-page guard must not discard what was already collected: it
|
|
// logs a truncation warning and returns the partial results instead
|
|
// of failing the reconcile with an error.
|
|
calls := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"refunds":[{"id":"ref_x","status":"COMPLETED","amount_money":{"amount":100,"currency":"GBP"},"payment_id":"pay_partial","created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc)
|
|
if err != nil {
|
|
t.Fatalf("expected partial results (nil error), got %v", err)
|
|
}
|
|
if calls != 20 {
|
|
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
|
|
}
|
|
if len(refunds) != 20 {
|
|
t.Errorf("expected 20 refunds collected across pages (one per page), got %d", len(refunds))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCreateCardOnFileHTTP_IdempotencyKey verifies the deterministic SHA-256
|
|
// idempotency key derivation and the request wire shape (source_id + card).
|
|
func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
|
var captured map[string]any
|
|
var gotAuth string
|
|
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 r.URL.Path != "/v2/cards" {
|
|
t.Errorf("expected /v2/cards, got %s", r.URL.Path)
|
|
}
|
|
gotAuth = r.Header.Get("Authorization")
|
|
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(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
|
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "", hc)
|
|
if err != nil {
|
|
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte("user_1|cnon:test-card"))
|
|
wantIK := "card-" + fmt.Sprintf("%x", sum)[:38]
|
|
if captured["idempotency_key"] != wantIK {
|
|
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
|
}
|
|
// Square's documented idempotency-key limit for /v2/cards is 45 chars —
|
|
// the truncated key must never exceed it (C-1 regression guard).
|
|
if len(wantIK) > 45 {
|
|
t.Errorf("idempotency_key %q is %d chars, exceeds Square's 45-char limit", wantIK, len(wantIK))
|
|
}
|
|
if captured["source_id"] != "cnon:test-card" {
|
|
t.Errorf("expected source_id cnon:test-card, got %v", captured["source_id"])
|
|
}
|
|
card, ok := captured["card"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected card object, got %v", captured["card"])
|
|
}
|
|
// The local user ID goes in reference_id (free-form); customer_id is
|
|
// emitted only when the app has provisioned a Square customer for the user
|
|
// (empty customerID → omitted via omitempty).
|
|
if card["reference_id"] != "user_1" {
|
|
t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"])
|
|
}
|
|
if _, present := card["customer_id"]; present {
|
|
t.Errorf("expected card.customer_id to be ABSENT when customerID is empty, got %v", card["customer_id"])
|
|
}
|
|
if gotAuth != "Bearer secret" {
|
|
t.Errorf("expected Authorization 'Bearer secret', got %q", gotAuth)
|
|
}
|
|
if res.CardID != "ccof_x" || res.Brand != "VISA" || res.Last4 != "4242" {
|
|
t.Errorf("unexpected card result: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestCreateCardOnFileHTTP_CustomerIDEmitted verifies card.customer_id is sent
|
|
// when the app has provisioned a Square customer for the user (Square marks
|
|
// customer_id Required on the Card object for saved-card flows).
|
|
func TestCreateCardOnFileHTTP_CustomerIDEmitted(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 r.URL.Path != "/v2/cards" {
|
|
t.Errorf("expected /v2/cards, got %s", r.URL.Path)
|
|
}
|
|
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(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"cus_1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
|
_, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "cus_1", hc)
|
|
if err != nil {
|
|
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
|
}
|
|
|
|
card, ok := captured["card"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected card object, got %v", captured["card"])
|
|
}
|
|
if card["customer_id"] != "cus_1" {
|
|
t.Errorf("expected card.customer_id cus_1, got %v", card["customer_id"])
|
|
}
|
|
// The idempotency key is derived solely from user|card, so it is identical
|
|
// whether or not a customer_id accompanies the request.
|
|
sum := sha256.Sum256([]byte("user_1|cnon:test-card"))
|
|
wantIK := "card-" + fmt.Sprintf("%x", sum)[:38]
|
|
if captured["idempotency_key"] != wantIK {
|
|
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
|
}
|
|
}
|
|
|
|
// TestGetCardsOnFileHTTP_ReferenceIDFilter verifies the List Cards request uses
|
|
// the native reference_id filter (the local user ID) — not the invalid
|
|
// customer_id — and that cards are returned unfiltered server-side.
|
|
func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
|
|
var gotRawQuery string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("expected GET, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/v2/cards" {
|
|
t.Errorf("expected /v2/cards, got %s", r.URL.Path)
|
|
}
|
|
gotRawQuery = r.URL.RawQuery
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"cards":[{"id":"ccof_1","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"},{"id":"ccof_2","card_brand":"MASTERCARD","last_4":"1111","exp_month":6,"exp_year":2029,"fingerprint":"fp2","reference_id":"user_other","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_1", hc)
|
|
if err != nil {
|
|
t.Fatalf("getCardsOnFileHTTP failed: %v", err)
|
|
}
|
|
|
|
// Native reference_id filter — never the invalid customer_id, never a
|
|
// limit that Square's List Cards API doesn't support.
|
|
if !strings.Contains(gotRawQuery, "reference_id=user_1") {
|
|
t.Errorf("expected reference_id=user_1 in query, got %q", gotRawQuery)
|
|
}
|
|
if strings.Contains(gotRawQuery, "customer_id") {
|
|
t.Errorf("expected NO customer_id in query (local IDs are not Square customers), got %q", gotRawQuery)
|
|
}
|
|
if strings.Contains(gotRawQuery, "limit") {
|
|
t.Errorf("expected NO limit param (List Cards has no limit; server filters by reference_id), got %q", gotRawQuery)
|
|
}
|
|
|
|
if len(cards) != 2 {
|
|
t.Fatalf("expected 2 cards returned, got %d", len(cards))
|
|
}
|
|
if cards[0].ReferenceID != "user_1" || cards[1].ReferenceID != "user_other" {
|
|
t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1])
|
|
}
|
|
}
|
|
|
|
// TestCreateCheckoutHTTP_TipSettings verifies AllowTipping is emitted as
|
|
// checkout.device_options.tip_settings.allow_tipping (Square's wire shape for
|
|
// enabling terminal tips) and omitted entirely when not set.
|
|
func TestCreateCheckoutHTTP_TipSettings(t *testing.T) {
|
|
t.Run("allow_tipping_true_emits_tip_settings", func(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(`{"checkout":{"id":"chk_tip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
|
Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-tip", DeviceID: "dvc_1", AllowTipping: true,
|
|
}, hc)
|
|
if err != nil {
|
|
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
|
}
|
|
checkout, ok := captured["checkout"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout object, got %v", captured)
|
|
}
|
|
// tip_settings must NOT be at the checkout top level — a top-level
|
|
// tip_settings is silently ignored by Square (terminal tip loss).
|
|
if _, hasTopLevel := checkout["tip_settings"]; hasTopLevel {
|
|
t.Errorf("tip_settings must not be top-level in terminal checkout request: %v", checkout)
|
|
}
|
|
// tip_settings must live under checkout.device_options
|
|
devOpts, ok := checkout["device_options"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
|
}
|
|
tipSettings, ok := devOpts["tip_settings"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected device_options.tip_settings when AllowTipping is true, got %v", devOpts)
|
|
}
|
|
if tipSettings["allow_tipping"] != true {
|
|
t.Errorf("expected tip_settings.allow_tipping=true, got %v", tipSettings)
|
|
}
|
|
})
|
|
|
|
t.Run("allow_tipping_false_omits_tip_settings", func(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(`{"checkout":{"id":"chk_notip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
|
Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-notip", DeviceID: "dvc_1", AllowTipping: false,
|
|
}, hc)
|
|
if err != nil {
|
|
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
|
}
|
|
checkout, ok := captured["checkout"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout object, got %v", captured)
|
|
}
|
|
// device_options is always present (device_id is required); only the
|
|
// tip_settings sub-object must be absent.
|
|
devOpts, ok := checkout["device_options"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
|
}
|
|
if _, present := devOpts["tip_settings"]; present {
|
|
t.Errorf("expected device_options.tip_settings ABSENT when AllowTipping is false, got %v", devOpts["tip_settings"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGetPaymentHTTP verifies GET /v2/payments/{id} maps via paymentFromSquare
|
|
// and that an empty payment ID errors before any HTTP call.
|
|
func TestGetPaymentHTTP_WireShape(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("expected GET, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/v2/payments/pay_1" {
|
|
t.Errorf("expected /v2/payments/pay_1, got %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"payment":{"id":"pay_1","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":"EMV"},"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", http: srv.Client()}
|
|
res, err := getPaymentHTTPWithClient(context.Background(), "pay_1", hc)
|
|
if err != nil {
|
|
t.Fatalf("getPaymentHTTP failed: %v", err)
|
|
}
|
|
if res.ID != "pay_1" || res.Amount != 5000 || res.EntryMethod != "EMV" {
|
|
t.Errorf("unexpected payment result: %+v", res)
|
|
}
|
|
|
|
_, err = getPaymentHTTPWithClient(context.Background(), "", hc)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid payment id") {
|
|
t.Fatalf("expected empty-ID error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetPaymentHTTP_NotFound(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Payment not found"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
_, err := getPaymentHTTPWithClient(context.Background(), "pay_missing", hc)
|
|
if err == nil {
|
|
t.Fatal("expected error for not-found payment")
|
|
}
|
|
}
|
|
|
|
// TestCreateCustomerHTTP_WireShape verifies the CreateCustomer request body:
|
|
// deterministic "customer-" + sha256(email) idempotency key (≤45 chars),
|
|
// email_address, and given_name.
|
|
func TestCreateCustomerHTTP_WireShape(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 r.URL.Path != "/v2/customers" {
|
|
t.Errorf("expected /v2/customers, got %s", r.URL.Path)
|
|
}
|
|
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(`{"customer":{"id":"cus_1","email_address":"jane@example.com","given_name":"Jane","created_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
res, err := createCustomerHTTPWithClient(context.Background(), "Jane", "jane@example.com", hc)
|
|
if err != nil {
|
|
t.Fatalf("createCustomerHTTP failed: %v", err)
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte("jane@example.com"))
|
|
wantIK := "customer-" + fmt.Sprintf("%x", sum)[:35]
|
|
if captured["idempotency_key"] != wantIK {
|
|
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
|
}
|
|
// Square's documented idempotency-key limit is 45 chars — the truncated
|
|
// key must never exceed it.
|
|
if len(wantIK) > 45 {
|
|
t.Errorf("idempotency_key %q is %d chars, exceeds Square's 45-char limit", wantIK, len(wantIK))
|
|
}
|
|
if captured["email_address"] != "jane@example.com" {
|
|
t.Errorf("expected email_address jane@example.com, got %v", captured["email_address"])
|
|
}
|
|
if captured["given_name"] != "Jane" {
|
|
t.Errorf("expected given_name Jane, got %v", captured["given_name"])
|
|
}
|
|
if res.ID != "cus_1" || res.Email != "jane@example.com" || res.CreatedAt == "" {
|
|
t.Errorf("unexpected customer result: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestCancelCheckoutHTTP_NonFatalErrors verifies CancelCheckout treats
|
|
// already-completed/unknown checkouts as a no-op: structured NOT_FOUND, plain
|
|
// HTTP 404, and success all return nil. Genuine failures propagate.
|
|
func TestCancelCheckoutHTTP_NonFatalErrors(t *testing.T) {
|
|
t.Run("success_is_nil", func(t *testing.T) {
|
|
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 r.URL.Path != "/v2/terminals/checkouts/chk_1/cancel" {
|
|
t.Errorf("expected cancel path, got %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_1","status":"CANCELED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_1", hc); err != nil {
|
|
t.Fatalf("expected nil for successful cancel, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("structured_not_found_is_nil", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Checkout not found or already completed"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_missing", hc); err != nil {
|
|
t.Fatalf("expected nil for NOT_FOUND (already completed is a no-op), got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("plain_404_is_nil", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte("checkout not found"))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_404", hc); err != nil {
|
|
t.Fatalf("expected nil for plain HTTP 404, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("other_error_propagates", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INVALID_VALUE","detail":"bad"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_bad", hc); err == nil {
|
|
t.Fatal("expected non-nil error for genuine failure")
|
|
}
|
|
})
|
|
|
|
t.Run("noop_code_is_error", func(t *testing.T) {
|
|
// "NOOP" is NOT a confirmed Square error code, so it must propagate as
|
|
// an error — only NOT_FOUND is treated as an idempotent no-op.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOOP","detail":"nothing to cancel"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
err := cancelCheckoutHTTPWithClient(context.Background(), "chk_noop", hc)
|
|
if err == nil {
|
|
t.Fatal("expected NOOP code to propagate as an error (NOOP is not a confirmed Square code)")
|
|
}
|
|
if code := ErrorCode(err); code != "NOOP" {
|
|
t.Errorf("expected NOOP code on error, got %q", code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPaymentFromSquare_ExpiryPointers verifies exp_month/exp_year are set as
|
|
// pointers when card details are present and left nil when absent.
|
|
func TestPaymentFromSquare_ExpiryPointers(t *testing.T) {
|
|
t.Run("card_details_sets_pointers", func(t *testing.T) {
|
|
p := &sqPayment{
|
|
ID: "pay_exp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
|
CardDetails: &sqCardDetails{
|
|
Card: sqCard{ID: "ccof_x", CardBrand: "VISA", Last4: "4242", ExpMonth: 12, ExpYear: 2030},
|
|
},
|
|
}
|
|
result := paymentFromSquare(p)
|
|
if result.ExpMonth == nil || result.ExpYear == nil {
|
|
t.Fatalf("expected non-nil expiry pointers, got %v/%v", result.ExpMonth, result.ExpYear)
|
|
}
|
|
if *result.ExpMonth != 12 || *result.ExpYear != 2030 {
|
|
t.Errorf("expected exp 12/2030, got %d/%d", *result.ExpMonth, *result.ExpYear)
|
|
}
|
|
})
|
|
|
|
t.Run("no_card_details_leaves_nil", func(t *testing.T) {
|
|
p := &sqPayment{ID: "pay_noexp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"}}
|
|
result := paymentFromSquare(p)
|
|
if result.ExpMonth != nil || result.ExpYear != nil {
|
|
t.Errorf("expected nil expiry without card details, got %v/%v", result.ExpMonth, result.ExpYear)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidSquareID covers the URL path-segment safety check: Square IDs are
|
|
// alphanumeric plus '_' and '-' and at most 64 chars. Empty, over-long, and
|
|
// any character outside that set is rejected before it can reach a URL path.
|
|
func TestValidSquareID(t *testing.T) {
|
|
valid := []string{
|
|
"pay_123",
|
|
"P1-abc",
|
|
"chk_1",
|
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-", // exactly 64 chars
|
|
}
|
|
invalid := []string{
|
|
"",
|
|
"has space",
|
|
"bad/char",
|
|
"bad.char",
|
|
"traversal/../..",
|
|
strings.Repeat("a", 65),
|
|
}
|
|
for _, id := range valid {
|
|
if !validSquareID(id) {
|
|
t.Errorf("expected %q to be a valid Square ID", id)
|
|
}
|
|
}
|
|
for _, id := range invalid {
|
|
if validSquareID(id) {
|
|
t.Errorf("expected %q to be rejected", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIDValidation_RejectsBeforeHTTP verifies getPayment/getCheckout/cancelCheckout
|
|
// reject malformed IDs before building the request URL. The client points at an
|
|
// unused host: a request that slipped past validation would fail with a network
|
|
// error instead of an "invalid ... id" error, so the assertion is meaningful.
|
|
func TestIDValidation_RejectsBeforeHTTP(t *testing.T) {
|
|
hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}}
|
|
ctx := context.Background()
|
|
|
|
_, err := getPaymentHTTPWithClient(ctx, "bad/id", hc)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid payment id") {
|
|
t.Fatalf("expected invalid payment id error, got %v", err)
|
|
}
|
|
|
|
_, err = getCheckoutHTTPWithClient(ctx, "bad/id", hc)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid checkout id") {
|
|
t.Fatalf("expected invalid checkout id error, got %v", err)
|
|
}
|
|
|
|
err = cancelCheckoutHTTPWithClient(ctx, "bad/id", hc)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid checkout id") {
|
|
t.Fatalf("expected invalid checkout id error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestErrorCode_ErrorDetail verifies the exported accessors surface the
|
|
// structured Square error Code/Detail for direct and wrapped *squareAPIError
|
|
// values, and return "" for non-Square errors (so handlers can classify charge
|
|
// failures structurally instead of substring-matching).
|
|
func TestErrorCode_ErrorDetail(t *testing.T) {
|
|
t.Run("direct", func(t *testing.T) {
|
|
base := &squareAPIError{Code: "INVALID_VALUE", Detail: "bad thing", err: errors.New("square: boom")}
|
|
if got := ErrorCode(base); got != "INVALID_VALUE" {
|
|
t.Errorf("expected INVALID_VALUE, got %q", got)
|
|
}
|
|
if got := ErrorDetail(base); got != "bad thing" {
|
|
t.Errorf("expected detail 'bad thing', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("wrapped", func(t *testing.T) {
|
|
base := &squareAPIError{Code: "CARD_DECLINED", Detail: "card declined", err: errors.New("square: boom")}
|
|
wrapped := fmt.Errorf("wrap: %w", base)
|
|
if got := ErrorCode(wrapped); got != "CARD_DECLINED" {
|
|
t.Errorf("expected CARD_DECLINED through wrap, got %q", got)
|
|
}
|
|
if got := ErrorDetail(wrapped); got != "card declined" {
|
|
t.Errorf("expected detail through wrap, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("non_square_error", func(t *testing.T) {
|
|
if got := ErrorCode(errors.New("plain")); got != "" {
|
|
t.Errorf("expected \"\", got %q", got)
|
|
}
|
|
if got := ErrorDetail(errors.New("plain")); got != "" {
|
|
t.Errorf("expected \"\", got %q", got)
|
|
}
|
|
if got := ErrorCode(nil); got != "" {
|
|
t.Errorf("expected \"\" for nil, got %q", got)
|
|
}
|
|
if got := ErrorDetail(nil); got != "" {
|
|
t.Errorf("expected \"\" for nil, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTokenPrefix_Redacts verifies the PCI-safe abbreviation: the full token
|
|
// never appears, only the first 8 chars plus the length.
|
|
func TestTokenPrefix_Redacts(t *testing.T) {
|
|
token := "ccof:secret_token_abc123"
|
|
redacted := TokenPrefix(token)
|
|
if strings.Contains(redacted, "ccof:secret") {
|
|
t.Errorf("TokenPrefix leaked more than the first 8 chars: %q", redacted)
|
|
}
|
|
if !strings.HasPrefix(redacted, "ccof:sec") {
|
|
t.Errorf("expected first 8 chars prefix, got %q", redacted)
|
|
}
|
|
if !strings.Contains(redacted, "len 24") {
|
|
t.Errorf("expected total length in abbreviation, got %q", redacted)
|
|
}
|
|
}
|
|
|
|
// TestDoJSON_OversizedResponseTruncated covers the doJSON response-body limit:
|
|
// an oversized body is cut at maxResponseBody and errors surface truncation
|
|
// instead of embedding garbage or an unbounded body.
|
|
func TestDoJSON_OversizedResponseTruncated(t *testing.T) {
|
|
t.Run("oversized_non_json_error_is_capped", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(strings.Repeat("A", maxResponseBody+100)))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "HTTP 500") {
|
|
t.Errorf("expected HTTP 500 in error, got %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "truncated") {
|
|
t.Errorf("expected truncation note in error, got %q", msg)
|
|
}
|
|
// The embedded body snippet must be capped well below the full body.
|
|
if len(msg) > maxErrorBody*3 {
|
|
t.Errorf("error message embeds too much of the response body (%d bytes)", len(msg))
|
|
}
|
|
})
|
|
|
|
t.Run("oversized_success_json_unmarshal_fails_with_truncation", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"payment":{"id":"` + strings.Repeat("x", maxResponseBody) + `"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
var target sqCreatePaymentResponse
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, &target)
|
|
if err == nil {
|
|
t.Fatal("expected error for truncated oversized body")
|
|
}
|
|
if !strings.Contains(err.Error(), "truncated") {
|
|
t.Errorf("expected truncation error, got %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("valid_json_prefix_is_still_truncation_error", func(t *testing.T) {
|
|
// A 2xx body over maxResponseBody whose first 1 MiB prefix is itself
|
|
// valid JSON (e.g. {"ok":true} padded to the cap, then a huge trailing
|
|
// JSON object) must return a truncation error, NEVER a silent partial
|
|
// success. Unmarshal would succeed on the prefix alone, so the
|
|
// truncation check must fire independently of the unmarshal result.
|
|
prefix := `{"ok":true}`
|
|
padding := strings.Repeat(" ", maxResponseBody-len(prefix))
|
|
trailer := `{"pad":"` + strings.Repeat("x", maxResponseBody) + `"}`
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(prefix + padding + trailer))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
var target sqCreatePaymentResponse
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, &target)
|
|
if err == nil {
|
|
t.Fatal("expected truncation error for oversized body whose prefix is valid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "truncated") {
|
|
t.Errorf("expected truncation error, got %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("structured_error_detail_capped_in_message_only", func(t *testing.T) {
|
|
fullDetail := "echoed-input-" + strings.Repeat("A", 600)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"REFUND_DECLINED","detail":` + fmt.Sprintf("%q", fullDetail) + `,"field":"payment_id"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/refunds", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var sqErr *squareAPIError
|
|
if !errors.As(err, &sqErr) {
|
|
t.Fatalf("expected *squareAPIError, got %T", err)
|
|
}
|
|
// The message (what handlers log) must be capped...
|
|
if len(sqErr.err.Error()) > maxErrorBody*3 {
|
|
t.Errorf("error message embeds too much of the detail (%d bytes)", len(sqErr.err.Error()))
|
|
}
|
|
// ...while the structured Detail stays intact for ErrorDetail() callers.
|
|
if sqErr.Detail != fullDetail {
|
|
t.Errorf("expected full Detail preserved for ErrorDetail, got %d bytes", len(sqErr.Detail))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCapBody_RuneSafeTruncation verifies capBody never splits a multi-byte
|
|
// UTF-8 rune at the maxErrorBody cut: the result must always be valid UTF-8
|
|
// even when the byte cut lands mid-rune (handlers log these messages verbatim,
|
|
// and a half-encoded rune would mangle logs feeding UTF-8-sensitive tooling).
|
|
func TestCapBody_RuneSafeTruncation(t *testing.T) {
|
|
t.Run("cut_lands_mid_rune", func(t *testing.T) {
|
|
// 499 ASCII bytes + a 3-byte rune (€) starting at byte 499: a raw
|
|
// s[:maxErrorBody] cut would slice the rune in half.
|
|
s := strings.Repeat("a", maxErrorBody-1) + "€" + strings.Repeat("b", maxErrorBody)
|
|
got := capBody(s)
|
|
if !utf8.ValidString(got) {
|
|
t.Errorf("capBody result is invalid UTF-8: %q", got)
|
|
}
|
|
if !strings.HasSuffix(got, "... (truncated)") {
|
|
t.Errorf("expected truncation marker, got %q", got)
|
|
}
|
|
prefix := strings.TrimSuffix(got, "... (truncated)")
|
|
if len(prefix) > maxErrorBody {
|
|
t.Errorf("capped prefix is %d bytes, exceeds cap %d", len(prefix), maxErrorBody)
|
|
}
|
|
// The split rune must be dropped at the cut, not mangled.
|
|
if strings.Contains(got, "€") {
|
|
t.Errorf("expected the split rune to be dropped, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("cut_on_clean_rune_edge_is_preserved", func(t *testing.T) {
|
|
s := strings.Repeat("a", maxErrorBody) + "rest"
|
|
got := capBody(s)
|
|
if got != strings.Repeat("a", maxErrorBody)+"... (truncated)" {
|
|
t.Errorf("expected exact %d-byte prefix preserved, got %q", maxErrorBody, got)
|
|
}
|
|
})
|
|
|
|
t.Run("within_cap_is_unchanged", func(t *testing.T) {
|
|
s := "short body with €"
|
|
if got := capBody(s); got != s {
|
|
t.Errorf("expected short input unchanged, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDeleteCardOnFileHTTP_RejectsInvalidID verifies the deleteCardOnFileHTTP
|
|
// path-segment guard: an invalid card id errors before any HTTP call, and the
|
|
// error redacts the full ID (it is a DB-stored ccof: token) rather than
|
|
// echoing it verbatim.
|
|
func TestDeleteCardOnFileHTTP_RejectsInvalidID(t *testing.T) {
|
|
invalidID := "bad/card/id/path/traversal" // long enough that tokenPrefix must truncate it
|
|
err := deleteCardOnFileHTTP(context.Background(), invalidID)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid card id") {
|
|
t.Fatalf("expected invalid card id error, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), invalidID) {
|
|
t.Errorf("error must not embed the full invalid card id: %q", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestValidCardID covers the ccof:-aware card-id check used by
|
|
// deleteCardOnFileHTTP: the ccof: prefix is stripped before the standard
|
|
// charset rule, so legitimate card IDs pass while path-traversal rejects.
|
|
func TestValidCardID(t *testing.T) {
|
|
valid := []string{
|
|
"ccof:abc_123",
|
|
"ccof:ABC-def",
|
|
"plain_id_1",
|
|
}
|
|
invalid := []string{
|
|
"",
|
|
"ccof:",
|
|
"ccof:bad/id",
|
|
"ccof:bad..id",
|
|
"bad/id",
|
|
"bad?id",
|
|
strings.Repeat("a", 65),
|
|
}
|
|
for _, id := range valid {
|
|
if !validCardID(id) {
|
|
t.Errorf("expected %q to be a valid card ID", id)
|
|
}
|
|
}
|
|
for _, id := range invalid {
|
|
if validCardID(id) {
|
|
t.Errorf("expected %q to be rejected", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeleteCustomerHTTP covers the DELETE /v2/customers/{id} endpoint:
|
|
// success, idempotent NOT_FOUND no-ops, propagated failures, and the
|
|
// invalid-id guard.
|
|
func TestDeleteCustomerHTTP(t *testing.T) {
|
|
t.Run("success_deletes_customer", func(t *testing.T) {
|
|
var gotMethod, gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"customer":{"id":"cus_1","email_address":"jane@example.com","given_name":"Jane","created_at":"2026-07-31T00:00:00Z"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := deleteCustomerHTTPWithClient(context.Background(), "cus_1", hc); err != nil {
|
|
t.Fatalf("deleteCustomerHTTP failed: %v", err)
|
|
}
|
|
if gotMethod != http.MethodDelete {
|
|
t.Errorf("expected DELETE, got %s", gotMethod)
|
|
}
|
|
if gotPath != "/v2/customers/cus_1" {
|
|
t.Errorf("expected /v2/customers/cus_1, got %s", gotPath)
|
|
}
|
|
})
|
|
|
|
t.Run("structured_not_found_is_noop", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Customer not found"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := deleteCustomerHTTPWithClient(context.Background(), "cus_missing", hc); err != nil {
|
|
t.Fatalf("expected nil for NOT_FOUND (idempotent re-deletion), got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("plain_404_is_noop", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte("customer not found"))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := deleteCustomerHTTPWithClient(context.Background(), "cus_404", hc); err != nil {
|
|
t.Fatalf("expected nil for plain HTTP 404, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("other_error_propagates", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INVALID_VALUE","detail":"bad"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
|
if err := deleteCustomerHTTPWithClient(context.Background(), "cus_bad", hc); err == nil {
|
|
t.Fatal("expected error for genuine failure")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid_id_rejected_before_http", func(t *testing.T) {
|
|
hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}}
|
|
err := deleteCustomerHTTPWithClient(context.Background(), "bad/id", hc)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid customer id") {
|
|
t.Fatalf("expected invalid customer id error, got %v", err)
|
|
}
|
|
})
|
|
}
|