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:
@@ -6,8 +6,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -44,6 +46,10 @@ type MockClient struct {
|
|||||||
// prod-only pending-refund branch (normally only reachable against the
|
// prod-only pending-refund branch (normally only reachable against the
|
||||||
// real Square API) can be exercised in dev/tests.
|
// real Square API) can be exercised in dev/tests.
|
||||||
ForceRefundPending bool
|
ForceRefundPending bool
|
||||||
|
// FailCreateCheckout makes CreateCheckout return an error so the handler's
|
||||||
|
// post-insert CreateCheckout-failure path (marking the provisional
|
||||||
|
// terminal_checkouts row failed) can be exercised in dev/tests.
|
||||||
|
FailCreateCheckout bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type devProdClient struct{}
|
type devProdClient struct{}
|
||||||
@@ -128,7 +134,19 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||||
// mock behaves identically to production (PCI-DSS parity).
|
// mock behaves identically to production (PCI-DSS parity).
|
||||||
if !isTokenLike(req.SourceID) {
|
if !isTokenLike(req.SourceID) {
|
||||||
return nil, fmt.Errorf("invalid source_id: %q — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", req.SourceID)
|
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID))
|
||||||
|
}
|
||||||
|
// Square requires customer_id when charging a card-on-file (ccof:) token.
|
||||||
|
// The mock enforces the same rule so dev parity catches the production bug
|
||||||
|
// where a saved-card charge is sent without the customer's Square customer
|
||||||
|
// id (real Square rejects it with a 400 INVALID_REQUEST_ERROR).
|
||||||
|
if strings.HasPrefix(req.SourceID, "ccof:") && req.CustomerID == "" {
|
||||||
|
return nil, &squareAPIError{
|
||||||
|
Code: "INVALID_REQUEST_ERROR",
|
||||||
|
Detail: "customer_id required for card-on-file source",
|
||||||
|
StatusCode: http.StatusBadRequest,
|
||||||
|
err: errors.New("square: customer_id required for card-on-file source"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
|
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
|
||||||
// card reference (ccof:) that could be replayed. Log only its prefix and
|
// card reference (ccof:) that could be replayed. Log only its prefix and
|
||||||
@@ -223,6 +241,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||||
|
if m.FailCreateCheckout {
|
||||||
|
return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)")
|
||||||
|
}
|
||||||
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
|
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
|
||||||
|
|
||||||
now := clock.Now().UTC()
|
now := clock.Now().UTC()
|
||||||
@@ -237,7 +258,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
|||||||
Note: req.Note,
|
Note: req.Note,
|
||||||
CreatedAt: now.Format(time.RFC3339),
|
CreatedAt: now.Format(time.RFC3339),
|
||||||
UpdatedAt: now.Format(time.RFC3339),
|
UpdatedAt: now.Format(time.RFC3339),
|
||||||
Deadline: now.Add(5 * time.Minute).Format(time.RFC3339),
|
Deadline: "PT5M", // deadline_duration wire format: RFC 3339 duration, not a timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -429,7 +450,7 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
|
|||||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||||
// mock behaves identically to production.
|
// mock behaves identically to production.
|
||||||
if !isTokenLike(cardToken) {
|
if !isTokenLike(cardToken) {
|
||||||
return nil, fmt.Errorf("invalid source_id: %q — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", cardToken)
|
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -520,12 +541,6 @@ func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, b
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
|
||||||
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
|
||||||
func isTokenLike(s string) bool {
|
|
||||||
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
|
||||||
}
|
|
||||||
|
|
||||||
// redactedEmail masks a customer email for dev logs (PII, S-2 convention):
|
// redactedEmail masks a customer email for dev logs (PII, S-2 convention):
|
||||||
// only the first two characters of the local part plus the domain are shown,
|
// only the first two characters of the local part plus the domain are shown,
|
||||||
// e.g. "ja***@example.com". Malformed addresses fall back to "[redacted]".
|
// e.g. "ja***@example.com". Malformed addresses fall back to "[redacted]".
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -88,6 +89,28 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
|||||||
assert.NotEmpty(t, completed.EntryMethod)
|
assert.NotEmpty(t, completed.EntryMethod)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDevClient_CreateCheckout_DeadlineDurationFormat verifies the mock emits
|
||||||
|
// Square's deadline_duration wire format — an RFC 3339 duration ("PT5M"), NOT
|
||||||
|
// an absolute RFC3339 timestamp — so dev parity matches the real API.
|
||||||
|
func TestDevClient_CreateCheckout_DeadlineDurationFormat(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||||
|
Amount: 5000,
|
||||||
|
Currency: "GBP",
|
||||||
|
IdempotencyKey: "checkout-deadline",
|
||||||
|
ReferenceID: "deadline-ref",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PT5M", result.Deadline, "deadline_duration must be an RFC 3339 duration, not a timestamp")
|
||||||
|
|
||||||
|
// Round-trip through checkoutFromSquare: the wire value is copied through
|
||||||
|
// unchanged (it is not parsed/reformatted anywhere in the package).
|
||||||
|
res := checkoutFromSquare(&sqTerminalCheckout{Deadline: result.Deadline})
|
||||||
|
assert.Equal(t, "PT5M", res.Deadline)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||||
client := NewDevClient().(*MockClient)
|
client := NewDevClient().(*MockClient)
|
||||||
|
|
||||||
@@ -872,20 +895,55 @@ func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) {
|
|||||||
{"amex", "378282246310005"},
|
{"amex", "378282246310005"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
Amount: 5000,
|
Amount: 5000,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: tt.pan,
|
SourceID: tt.pan,
|
||||||
IdempotencyKey: "raw-pan-" + tt.name,
|
IdempotencyKey: "raw-pan-" + tt.name,
|
||||||
ReferenceID: "booking-raw",
|
ReferenceID: "booking-raw",
|
||||||
|
})
|
||||||
|
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "invalid source_id")
|
||||||
})
|
})
|
||||||
require.Error(t, err, "raw PAN must be rejected for production parity")
|
}
|
||||||
assert.Nil(t, result)
|
}
|
||||||
assert.Contains(t, err.Error(), "invalid source_id")
|
|
||||||
})
|
// TestDevClient_CreatePayment_CardOnFileRequiresCustomerID verifies the mock
|
||||||
}
|
// mirrors Square's real enforcement: charging a ccof: (card-on-file) token
|
||||||
|
// without a customer_id is rejected with a structured 400 INVALID_REQUEST_ERROR
|
||||||
|
// (this is the exact production bug the mock must catch in dev), while the same
|
||||||
|
// charge with a customer_id succeeds as ON_FILE.
|
||||||
|
func TestDevClient_CreatePayment_CardOnFileRequiresCustomerID(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 5000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "ccof:mock_saved",
|
||||||
|
IdempotencyKey: "ccof-no-customer",
|
||||||
|
ReferenceID: "booking-ccof-no-customer",
|
||||||
|
})
|
||||||
|
require.Error(t, err, "ccof charge without customer_id must be rejected")
|
||||||
|
assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err))
|
||||||
|
assert.Contains(t, ErrorDetail(err), "customer_id required")
|
||||||
|
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||||
|
|
||||||
|
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 5000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "ccof:mock_saved",
|
||||||
|
IdempotencyKey: "ccof-with-customer",
|
||||||
|
ReferenceID: "booking-ccof-with-customer",
|
||||||
|
CustomerID: "cus_mock_1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "COMPLETED", result.Status)
|
||||||
|
assert.Equal(t, "ON_FILE", result.EntryMethod)
|
||||||
|
assert.Equal(t, "cus_mock_1", result.CustomerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevClient_RefundPayment_ForcePending(t *testing.T) {
|
func TestDevClient_RefundPayment_ForcePending(t *testing.T) {
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
|
|||||||
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
||||||
se := errResp.Errors[0]
|
se := errResp.Errors[0]
|
||||||
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
|
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
|
||||||
return &squareAPIError{Code: se.Code, Detail: se.Detail, err: errors.New(msg)}
|
return &squareAPIError{
|
||||||
|
Code: se.Code,
|
||||||
|
Detail: se.Detail,
|
||||||
|
Category: se.Category,
|
||||||
|
Field: se.Field,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
err: errors.New(msg),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
|
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
@@ -228,8 +235,11 @@ type sqTerminalCheckout struct {
|
|||||||
ReferenceID string `json:"reference_id,omitempty"`
|
ReferenceID string `json:"reference_id,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
PaymentIDs []string `json:"payment_ids,omitempty"`
|
PaymentIDs []string `json:"payment_ids,omitempty"`
|
||||||
// Deadline (deadline_duration) is deprecated in the TerminalCheckout API —
|
// Deadline (deadline_duration) is a LIVE TerminalCheckout field: an RFC 3339
|
||||||
// retained read-only for informational purposes; harmless when set.
|
// duration (e.g. "PT5M") telling the terminal how long the checkout stays
|
||||||
|
// active. Square defaults it to 5 minutes. It is NOT an absolute timestamp
|
||||||
|
// and NOT deprecated. Kept as a string because the app only copies it
|
||||||
|
// through to CheckoutResult.Deadline.
|
||||||
Deadline string `json:"deadline_duration,omitempty"`
|
Deadline string `json:"deadline_duration,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -339,11 +349,38 @@ func validSquareID(id string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
||||||
|
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
||||||
|
// This is the single source of truth for token validation, shared by the real
|
||||||
|
// HTTP client and the dev mock so PCI-DSS parity holds in both builds.
|
||||||
|
func isTokenLike(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenPrefix returns a PCI-safe abbreviation of a card token for error and
|
||||||
|
// log messages: the first 8 characters plus an ellipsis and the total length.
|
||||||
|
// The full token (a raw PAN, single-use nonce, or card reference) must NEVER
|
||||||
|
// be echoed — handlers log these errors, so embedding the raw value would land
|
||||||
|
// client-submitted card data verbatim in server logs.
|
||||||
|
func tokenPrefix(s string) string {
|
||||||
|
if len(s) > 8 {
|
||||||
|
return fmt.Sprintf("%s... (len %d)", s[:8], len(s))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (len %d)", s, len(s))
|
||||||
|
}
|
||||||
|
|
||||||
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||||
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *httpClient) (*PaymentResult, error) {
|
func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *httpClient) (*PaymentResult, error) {
|
||||||
|
// PCI-DSS parity with the dev mock: reject raw PANs before they reach
|
||||||
|
// Square. source_id must be a cnon: nonce or ccof: card ID — anything else
|
||||||
|
// (e.g. a plain card number) is refused client-side so no card data is ever
|
||||||
|
// sent to the API in a non-token form.
|
||||||
|
if !isTokenLike(req.SourceID) {
|
||||||
|
return nil, fmt.Errorf("square: invalid card token %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID))
|
||||||
|
}
|
||||||
body := sqCreatePaymentRequest{
|
body := sqCreatePaymentRequest{
|
||||||
SourceID: req.SourceID,
|
SourceID: req.SourceID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
@@ -452,12 +489,19 @@ func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
// squareAPIError wraps a formatted Square API error while exposing the
|
// squareAPIError wraps a formatted Square API error while exposing the
|
||||||
// structured Square error code so callers can classify definitive business
|
// structured Square error code and the HTTP status code so callers can
|
||||||
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
|
// classify definitive business rejections (e.g. ErrRefundDeclined) vs
|
||||||
|
// ambiguous transport/server errors, and distinguish 400/401/429/500
|
||||||
|
// structurally without parsing the message. Category/Field are captured from
|
||||||
|
// Square's error payload (the wire error carries them; they were previously
|
||||||
|
// dropped).
|
||||||
type squareAPIError struct {
|
type squareAPIError struct {
|
||||||
Code string
|
Code string
|
||||||
Detail string
|
Detail string
|
||||||
err error
|
Category string
|
||||||
|
Field string
|
||||||
|
StatusCode int
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *squareAPIError) Error() string { return e.err.Error() }
|
func (e *squareAPIError) Error() string { return e.err.Error() }
|
||||||
@@ -486,6 +530,54 @@ func ErrorDetail(err error) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrorStatusCode returns the HTTP status code of the Square response carried
|
||||||
|
// by err when err (or any error it wraps) is a *squareAPIError, and 0
|
||||||
|
// otherwise. Callers can distinguish 400/401/429/500 structurally instead of
|
||||||
|
// substring-matching "HTTP 400" etc.
|
||||||
|
func ErrorStatusCode(err error) int {
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) {
|
||||||
|
return sqErr.StatusCode
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCategory returns the Square error Category carried by err when err (or
|
||||||
|
// any error it wraps) is a *squareAPIError, and "" otherwise.
|
||||||
|
func ErrorCategory(err error) string {
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) {
|
||||||
|
return sqErr.Category
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorField returns the Square error Field carried by err when err (or any
|
||||||
|
// error it wraps) is a *squareAPIError, and "" otherwise.
|
||||||
|
func ErrorField(err error) string {
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) {
|
||||||
|
return sqErr.Field
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFound reports whether err is a Square "not found" condition: the
|
||||||
|
// structured NOT_FOUND error code, an HTTP 404 response status, or a plain
|
||||||
|
// non-JSON 404 body (doJSON's fallback error message embeds "HTTP 404").
|
||||||
|
// Callers use this to treat already-completed/unknown Square resources as
|
||||||
|
// idempotent no-ops instead of substring-matching the error message.
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) {
|
||||||
|
return sqErr.Code == "NOT_FOUND" || sqErr.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "HTTP 404")
|
||||||
|
}
|
||||||
|
|
||||||
// Definitive Square refund rejection codes — the refund was declined and can
|
// Definitive Square refund rejection codes — the refund was declined and can
|
||||||
// never succeed, so retrying is pointless and the refund record should be
|
// never succeed, so retrying is pointless and the refund record should be
|
||||||
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
||||||
@@ -561,6 +653,11 @@ func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, customerID string, hc *httpClient) (*CardOnFile, error) {
|
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, customerID string, hc *httpClient) (*CardOnFile, error) {
|
||||||
|
// PCI-DSS parity with the dev mock: source_id must be a cnon: nonce or
|
||||||
|
// ccof: card ID. A raw PAN is refused client-side before it reaches Square.
|
||||||
|
if !isTokenLike(cardToken) {
|
||||||
|
return nil, fmt.Errorf("square: invalid card token %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
|
||||||
|
}
|
||||||
|
|
||||||
// Deterministic idempotency key derived from user + card (not time-based)
|
// Deterministic idempotency key derived from user + card (not time-based)
|
||||||
// so that retries with the same details don't create duplicate cards.
|
// so that retries with the same details don't create duplicate cards.
|
||||||
@@ -596,11 +693,13 @@ func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpCl
|
|||||||
// Filter by reference_id natively: Square's List Cards API supports the
|
// Filter by reference_id natively: Square's List Cards API supports the
|
||||||
// reference_id query param, and cards are created with reference_id = the
|
// reference_id query param, and cards are created with reference_id = the
|
||||||
// local user ID. customer_id is not used for the filter because a user may
|
// local user ID. customer_id is not used for the filter because a user may
|
||||||
// have no provisioned Square customer. List Cards pages at 25 cards, so
|
// have no provisioned Square customer. List Cards has NO limit param and
|
||||||
// loop on the cursor to avoid silently truncating a large saved-card list
|
// pages at 25 cards per page, so loop on the cursor to avoid silently
|
||||||
// (N-10).
|
// truncating a large saved-card list (N-10). The 20-page guard therefore
|
||||||
|
// caps out at 500 cards before warning.
|
||||||
var cards []CardOnFile
|
var cards []CardOnFile
|
||||||
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
||||||
|
truncated := false
|
||||||
for page := 0; page < 20; page++ {
|
for page := 0; page < 20; page++ {
|
||||||
var resp sqListCardsResponse
|
var resp sqListCardsResponse
|
||||||
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||||
@@ -612,8 +711,17 @@ func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpCl
|
|||||||
if resp.Cursor == "" {
|
if resp.Cursor == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if page == 19 {
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
path = "/v2/cards?reference_id=" + url.QueryEscape(userID) + "&cursor=" + url.QueryEscape(resp.Cursor)
|
path = "/v2/cards?reference_id=" + url.QueryEscape(userID) + "&cursor=" + url.QueryEscape(resp.Cursor)
|
||||||
}
|
}
|
||||||
|
if truncated {
|
||||||
|
// 20 pages fetched and a cursor is still present — return what we
|
||||||
|
// collected rather than discarding partial results (mirrors the
|
||||||
|
// listRefunds 20-page guard's behavior).
|
||||||
|
log.Printf("[SQUARE] list cards for %s exceeded 20 pages (infinite-loop guard) — returning partial results: %d cards", userID, len(cards))
|
||||||
|
}
|
||||||
if cards == nil {
|
if cards == nil {
|
||||||
cards = []CardOnFile{}
|
cards = []CardOnFile{}
|
||||||
}
|
}
|
||||||
@@ -667,11 +775,7 @@ func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *ht
|
|||||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts/"+checkoutID+"/cancel", nil, &resp); err != nil {
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts/"+checkoutID+"/cancel", nil, &resp); err != nil {
|
||||||
// Square returns 404 / NOT_FOUND when the checkout is already
|
// Square returns 404 / NOT_FOUND when the checkout is already
|
||||||
// completed or canceled — that is a no-op, not a failure.
|
// completed or canceled — that is a no-op, not a failure.
|
||||||
var sqErr *squareAPIError
|
if IsNotFound(err) {
|
||||||
if errors.As(err, &sqErr) && sqErr.Code == "NOT_FOUND" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if strings.Contains(err.Error(), "HTTP 404") {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
// TestDoJSON_ErrorParsing covers the doJSON error branches: structured Square
|
||||||
// errors become *squareAPIError (with Code/Detail preserved), while non-JSON
|
// errors become *squareAPIError (with Code/Detail preserved), while non-JSON
|
||||||
// error bodies fall back to a plain error (so refund classification treats the
|
// 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" {
|
if sqErr.Detail != "The refund was declined" {
|
||||||
t.Errorf("expected detail, got %q", sqErr.Detail)
|
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) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ type CheckoutResult struct {
|
|||||||
PaymentIDs []string // payment ID(s) once completed
|
PaymentIDs []string // payment ID(s) once completed
|
||||||
CreatedAt string // ISO 8601 timestamp
|
CreatedAt string // ISO 8601 timestamp
|
||||||
UpdatedAt string // ISO 8601 timestamp
|
UpdatedAt string // ISO 8601 timestamp
|
||||||
Deadline string // ISO 8601 deadline duration
|
Deadline string // deadline_duration from Square: an RFC 3339 duration (e.g. "PT5M"), NOT an absolute timestamp; Square defaults it to 5 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardOnFile maps to Square's Card object from the Cards API.
|
// CardOnFile maps to Square's Card object from the Cards API.
|
||||||
|
|||||||
Reference in New Issue
Block a user