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:
@@ -5,6 +5,7 @@ package square
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -19,6 +20,15 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// replaySnapshotReq marshals a CreatePaymentReq into the stored
|
||||
// square_request_snapshot shape (domain JSON) that ReplayPaymentByKey parses.
|
||||
func replaySnapshotReq(t *testing.T, req CreatePaymentReq) []byte {
|
||||
t.Helper()
|
||||
snap, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
return snap
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
@@ -672,6 +682,157 @@ func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
|
||||
assert.Empty(t, cards)
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_MatchingSource_ReturnsOriginal(t *testing.T) {
|
||||
// Identical-body replay contract: a retained key with the MATCHING stored
|
||||
// source returns the ORIGINAL payment (Square's idempotency guarantee) —
|
||||
// never a second charge and never IDEMPOTENCY_KEY_REUSED.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
orig, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "replay-match-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "replay-match-key",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, orig.ID, got.ID, "identical-body replay must return the original payment")
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_SourceMismatch_ReturnsKeyReused(t *testing.T) {
|
||||
// Identical-body replay contract: reusing a retained key with a DIFFERENT
|
||||
// source is Square's documented IDEMPOTENCY_KEY_REUSED rejection — a data
|
||||
// bug, NOT proof the charge never happened. The mock must carry the
|
||||
// structured code so ErrorCode(err) can read it (the sweep treats it as
|
||||
// ambiguous).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "replay-mismatch-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:different",
|
||||
IdempotencyKey: "replay-mismatch-key",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "source-mismatch replay must carry IDEMPOTENCY_KEY_REUSED")
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "IDEMPOTENCY_KEY_REUSED is NOT proof the charge never happened")
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_UnknownKey_NotRetained(t *testing.T) {
|
||||
// Identical-body replay contract: an unknown key makes Square attempt a
|
||||
// real charge with the (expired/used) cnon: nonce, which is rejected with a
|
||||
// 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never
|
||||
// happened).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "key-never-seen",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "unknown-key cnon replay must surface ErrReplayKeyNotRetained, got %v", err)
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_UnknownKey_CcofSavedCard_ChargesAndRescues(t *testing.T) {
|
||||
// B3 dev/prod parity: an unknown key with a STILL-VALID ccof: saved-card
|
||||
// token makes real Square attempt a REAL charge that succeeds — the sweep
|
||||
// must RESCUE such rows, never fail them. The mock mirrors this by looking
|
||||
// up the saved card and creating a new COMPLETED payment under the key.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, "user-replay-rescue", "cnon:test-token", "cus_replay123")
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshot := replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: card.CardID,
|
||||
CustomerID: "cus_replay123",
|
||||
IdempotencyKey: "key-never-seen-ccof",
|
||||
})
|
||||
got, err := client.ReplayPaymentByKey(ctx, snapshot)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", got.Status, "a still-valid ccof: source must charge successfully on an unknown key")
|
||||
assert.Equal(t, int64(5000), got.Amount)
|
||||
assert.Equal(t, "ON_FILE", got.EntryMethod)
|
||||
|
||||
// The charge must be recorded under the key so a later identical replay
|
||||
// returns the SAME payment (Square's dedup) instead of charging twice.
|
||||
got2, err := client.ReplayPaymentByKey(ctx, snapshot)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, got.ID, got2.ID, "a replayed ccof: charge under the same key must dedup, never charge twice")
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_UnknownKey_UnregisteredCcof_NotRetained(t *testing.T) {
|
||||
// A ccof: token that is NOT in the saved-card ledger mirrors real Square
|
||||
// rejecting a deleted/disabled card with a definitive 4xx — the charge
|
||||
// never happened.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "ccof:never-registered",
|
||||
CustomerID: "cus_replay123",
|
||||
IdempotencyKey: "key-never-seen-ccof-deleted",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "an unregistered ccof: token must surface ErrReplayKeyNotRetained, got %v", err)
|
||||
}
|
||||
|
||||
func TestDevClient_ReplayPaymentByKey_DedupSourceTracked(t *testing.T) {
|
||||
// The mock must record the source used by each CreatePayment so a later
|
||||
// identical-body replay can verify the source matches (the "works in dev ==
|
||||
// works in prod" guarantee for the reconcile sweep).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:dedup-src",
|
||||
IdempotencyKey: "replay-dedup-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
client.mu.RLock()
|
||||
stored := client.paymentSource["replay-dedup-key"]
|
||||
client.mu.RUnlock()
|
||||
assert.Equal(t, "cnon:dedup-src", stored, "the source of each keyed payment must be stored for replay parity")
|
||||
|
||||
got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:dedup-src",
|
||||
IdempotencyKey: "replay-dedup-key",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, got.ID)
|
||||
}
|
||||
|
||||
func TestDevClient_GetCheckout_StillPending(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.HoldCheckouts = true
|
||||
|
||||
Reference in New Issue
Block a user