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:
2026-08-22 00:34:49 +01:00
parent 4b28e93710
commit e9b0f0f2a7
50 changed files with 4223 additions and 413 deletions
@@ -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",