fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
@@ -3,11 +3,13 @@
|
||||
package square
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -16,6 +18,130 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestReplayPaymentByKeyHTTP_IdenticalBody verifies the replay-by-key sends the
|
||||
// FULL ORIGINAL request body (identical-body replay): a snapshot carrying
|
||||
// customer_id, reference_id, note and buyer_email_address — the fields the
|
||||
// original charge sends that a key+source+amount reconstruction would DROP —
|
||||
// must reach Square verbatim. Square's idempotency dedup compares the whole
|
||||
// request, so a partial replay body returns IDEMPOTENCY_KEY_REUSED for a
|
||||
// retained key and the row stays pending forever.
|
||||
func TestReplayPaymentByKeyHTTP_IdenticalBody(t *testing.T) {
|
||||
var capturedRaw []byte
|
||||
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)
|
||||
}
|
||||
var err error
|
||||
capturedRaw, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_orig","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","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()}
|
||||
req := CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:original-source",
|
||||
IdempotencyKey: "ik-replay",
|
||||
ReferenceID: "booking-123",
|
||||
Note: "deposit",
|
||||
CustomerID: "cus_123",
|
||||
VerificationToken: "verify-token-abc",
|
||||
BuyerEmail: "buyer@example.com",
|
||||
}
|
||||
snapshot, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build snapshot: %v", err)
|
||||
}
|
||||
res, err := replayPaymentByKeyHTTPWithClient(context.Background(), snapshot, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("replayPaymentByKeyHTTP failed: %v", err)
|
||||
}
|
||||
// The wire body must be BYTE-IDENTICAL to the original charge's body
|
||||
// (both go through buildCreatePaymentBody from the same CreatePaymentReq).
|
||||
expectedWire, err := json.Marshal(buildCreatePaymentBody(req, hc))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal expected wire body: %v", err)
|
||||
}
|
||||
if !bytes.Equal(capturedRaw, expectedWire) {
|
||||
t.Errorf("replay body is not byte-identical to the original charge body:\n got %s\n want %s", capturedRaw, expectedWire)
|
||||
}
|
||||
var captured map[string]any
|
||||
if err := json.Unmarshal(capturedRaw, &captured); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
if captured["source_id"] != "cnon:original-source" {
|
||||
t.Errorf("expected the ORIGINAL source_id in the replay body, got %v", captured["source_id"])
|
||||
}
|
||||
if captured["idempotency_key"] != "ik-replay" {
|
||||
t.Errorf("expected idempotency_key ik-replay, got %v", captured["idempotency_key"])
|
||||
}
|
||||
// The extra fields the original charge sent must survive the replay —
|
||||
// dropping them would make Square return IDEMPOTENCY_KEY_REUSED.
|
||||
if captured["customer_id"] != "cus_123" {
|
||||
t.Errorf("expected customer_id cus_123 in the replay body, got %v", captured["customer_id"])
|
||||
}
|
||||
if captured["reference_id"] != "booking-123" {
|
||||
t.Errorf("expected reference_id booking-123 in the replay body, got %v", captured["reference_id"])
|
||||
}
|
||||
if captured["note"] != "deposit" {
|
||||
t.Errorf("expected note deposit in the replay body, got %v", captured["note"])
|
||||
}
|
||||
if captured["buyer_email_address"] != "buyer@example.com" {
|
||||
t.Errorf("expected buyer_email_address buyer@example.com in the replay body, got %v", captured["buyer_email_address"])
|
||||
}
|
||||
if captured["verification_token"] != "verify-token-abc" {
|
||||
t.Errorf("expected verification_token verify-token-abc in the replay body, got %v", captured["verification_token"])
|
||||
}
|
||||
amt, ok := captured["amount_money"].(map[string]any)
|
||||
if !ok || amt["amount"] != float64(5000) || amt["currency"] != "GBP" {
|
||||
t.Errorf("expected amount_money {5000 GBP} (identical to the original charge), got %v", captured["amount_money"])
|
||||
}
|
||||
if res.ID != "pay_orig" {
|
||||
t.Errorf("expected the original payment returned, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplayErrorProvesNoCharge_Classification locks the identical-body replay
|
||||
// error classification: a definitive 4xx (minus 401/403/429) proves the charge
|
||||
// never happened; IDEMPOTENCY_KEY_REUSED is AMBIGUOUS (a data bug, never proof
|
||||
// of no charge); 401/403/429/5xx/transport are ambiguous.
|
||||
func TestReplayErrorProvesNoCharge_Classification(t *testing.T) {
|
||||
badRequest := func(code string) error {
|
||||
return &squareAPIError{Code: code, StatusCode: http.StatusBadRequest, err: errors.New("square: boom")}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "card_declined_4xx_proves_no_charge", err: badRequest("CARD_DECLINED"), want: true},
|
||||
{name: "invalid_request_4xx_proves_no_charge", err: badRequest("INVALID_REQUEST_ERROR"), want: true},
|
||||
{name: "plain_400_proves_no_charge", err: &squareAPIError{StatusCode: http.StatusBadRequest, err: errors.New("square: HTTP 400")}, want: true},
|
||||
{name: "idempotency_key_reused_is_ambiguous", err: badRequest("IDEMPOTENCY_KEY_REUSED"), want: false},
|
||||
{name: "unauthorized_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusUnauthorized, err: errors.New("square: 401")}, want: false},
|
||||
{name: "forbidden_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusForbidden, err: errors.New("square: 403")}, want: false},
|
||||
{name: "rate_limited_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusTooManyRequests, err: errors.New("square: 429")}, want: false},
|
||||
{name: "server_error_is_ambiguous", err: &squareAPIError{StatusCode: http.StatusInternalServerError, err: errors.New("square: 500")}, want: false},
|
||||
{name: "transport_error_is_ambiguous", err: errors.New("network error: connection reset"), want: false},
|
||||
{name: "nil_is_ambiguous", err: nil, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := replayErrorProvesNoCharge(tc.err); got != tc.want {
|
||||
t.Errorf("replayErrorProvesNoCharge(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
|
||||
p := &sqPayment{
|
||||
ID: "pay_1",
|
||||
|
||||
Reference in New Issue
Block a user