Add tests for Square client hardening and GDPR deletion
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.
This commit is contained in:
@@ -797,6 +797,58 @@ func TestDevClient_CreateCustomer_EmptyEmail(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "email")
|
assert.Contains(t, err.Error(), "email")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDevClient_DeleteCustomer(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cust, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = client.DeleteCustomer(ctx, cust.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.mu.RLock()
|
||||||
|
defer client.mu.RUnlock()
|
||||||
|
assert.Len(t, client.customers, 0, "deleted customer must be removed from the mock store")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_DeleteCustomer_NotFoundIsNoop(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Deleting a customer the mock never created mirrors Square's NOT_FOUND —
|
||||||
|
// idempotent re-deletion must return nil (GDPR re-runs are safe).
|
||||||
|
err := client.DeleteCustomer(ctx, "cus_missing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDevClient_CustomerID_RedactedInLogs verifies the mock never logs a full
|
||||||
|
// customer ID (S-2 convention): DeleteCustomer's entry/success lines and
|
||||||
|
// CreateCustomer's dedup-hit/created lines all use the tokenPrefix redaction,
|
||||||
|
// mirroring how prod redacts ccof: tokens.
|
||||||
|
func TestDevClient_CustomerID_RedactedInLogs(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cust, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
defer log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
err = client.DeleteCustomer(ctx, cust.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logs := buf.String()
|
||||||
|
if strings.Contains(logs, cust.ID) {
|
||||||
|
t.Errorf("full customer id %q leaked into mock logs: %q", cust.ID, logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, tokenPrefix(cust.ID)) {
|
||||||
|
t.Errorf("expected redacted customer id %q in logs, got %q", tokenPrefix(cust.ID), logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
|
func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
|
||||||
client := NewDevClient().(*MockClient)
|
client := NewDevClient().(*MockClient)
|
||||||
client.HoldCheckouts = true
|
client.HoldCheckouts = true
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
||||||
@@ -25,6 +26,8 @@ func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
|||||||
ID: "",
|
ID: "",
|
||||||
CardBrand: "VISA",
|
CardBrand: "VISA",
|
||||||
Last4: "4242",
|
Last4: "4242",
|
||||||
|
ExpMonth: 12,
|
||||||
|
ExpYear: 2030,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -36,6 +39,18 @@ func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
|||||||
if result.CardLast4 != "4242" {
|
if result.CardLast4 != "4242" {
|
||||||
t.Errorf("expected CardLast4 4242, got %s", result.CardLast4)
|
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) {
|
func TestPaymentFromSquare_NilCardDetails(t *testing.T) {
|
||||||
@@ -1236,3 +1251,281 @@ func TestIsTokenLike(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user