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

Add StatusCode/Category/Field to squareAPIError and an IsNotFound helper so 400/401/404/429/5xx are distinguishable structurally instead of by substring. Validate cnon:/ccof: token prefixes in createPayment/createCardOnFile (PCI parity with the mock). Reject ccof charges without customer_id in the mock so dev parity catches the production bug. Emit Deadline as the RFC 3339 duration (PT5M) and correct the deprecated-comment.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 54a5b1024e
commit 12af3af3b3
5 changed files with 399 additions and 39 deletions
+24 -9
View File
@@ -6,8 +6,10 @@ import (
"context"
"crussell/clock"
"crypto/sha256"
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
@@ -44,6 +46,10 @@ type MockClient struct {
// prod-only pending-refund branch (normally only reachable against the
// real Square API) can be exercised in dev/tests.
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{}
@@ -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
// mock behaves identically to production (PCI-DSS parity).
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
// 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) {
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)
now := clock.Now().UTC()
@@ -237,7 +258,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
Note: req.Note,
CreatedAt: 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()
@@ -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
// mock behaves identically to production.
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()
@@ -520,12 +541,6 @@ func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, b
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):
// 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]".