fix: fresh-review round — 2FA deliverability, disable re-verification, GDPR batch scrub, dispute alerting, docs accuracy
Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
This commit is contained in:
@@ -558,6 +558,62 @@ func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) {
|
||||
assert.Equal(t, first.ID, byKey.ID)
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_DedupSourceAware locks the body-aware dedup
|
||||
// parity fix: real Square compares the WHOLE request body on an
|
||||
// idempotency-key hit, so a same-key retry with a DIFFERENT source must return
|
||||
// IDEMPOTENCY_KEY_REUSED (never the original payment) — exactly like
|
||||
// ReplayPaymentByKey and the real API. Only an IDENTICAL body (matching
|
||||
// source) returns the original payment. Before this fix the mock was
|
||||
// body-blind and the gift-card same-key retry (which refreshes square_source_id
|
||||
// with a fresh cnon on pending-reuse) succeeded in dev where prod returns
|
||||
// IDEMPOTENCY_KEY_REUSED and leaves the row pending for the sweep.
|
||||
func TestDevClient_CreatePayment_DedupSourceAware(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
const key = "dedup-source-aware-key"
|
||||
first, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:original-source",
|
||||
IdempotencyKey: key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Same key + SAME source → the original payment (Square's idempotency
|
||||
// guarantee), never a second charge.
|
||||
same, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:original-source",
|
||||
IdempotencyKey: key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.ID, same.ID, "same-key + same-source retry must return the original payment")
|
||||
|
||||
// Same key + DIFFERENT source (the gift-card pending-reuse refresh) →
|
||||
// Square's structured IDEMPOTENCY_KEY_REUSED rejection.
|
||||
_, err = client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:fresh-refreshed-nonce",
|
||||
IdempotencyKey: key,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "same-key different-source retry 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")
|
||||
// The original payment must still be returned for the IDENTICAL body.
|
||||
got, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:original-source",
|
||||
IdempotencyKey: key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.ID, got.ID, "the identical-body retry must keep returning the original payment after a rejected mismatch")
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) {
|
||||
// Real Square dedups on idempotency key: a same-key retry returns the
|
||||
// original refund. The mock must mirror this or the pending-refund resume
|
||||
|
||||
Reference in New Issue
Block a user