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"
|
||||
"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]".
|
||||
|
||||
Reference in New Issue
Block a user