fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs

Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -313,6 +314,95 @@ func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.
require.Equal(t, 2, cardCount)
}
// TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey locks the A10 fix:
// a no-key purchase whose payment row was swept/declined to 'failed' must NOT
// permanently block the identical repurchase. The slot scan advances past BOTH
// COMPLETED and FAILED rows (mirroring the till's scanTillIdempotencyKeySlot),
// so the repurchase derives a FRESH key and charges again instead of
// 409-rejecting forever on the failed row.
func TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
// The deterministic fallback key a no-key £20 self purchase would derive.
failedKey := fmt.Sprintf("gc-%s-2000-self-new", userID)
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'failed', 20.00, $1, $2, NOW(), NOW())
`, failedKey, userID)
require.NoError(t, err)
// The identical repurchase must SUCCEED on a fresh key — not 409 forever.
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
t.Fatalf("expected the repurchase after a failed slot to succeed, got %d: %s", code, body)
}
// Two distinct keys: the failed slot key + the fresh advance key.
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 2, keyCount, "the repurchase must diverge onto a fresh key, not reuse the failed slot")
}
// TestBuyGiftCard_ForeignIdempotencyKey_NotReused locks the A6 fix: a
// client-supplied idempotency key that matches ANOTHER user's payment row must
// never be returned (completed), reused (pending), or rejected on (failed) —
// cross-user hijack. The purchase proceeds as a fresh request on a fresh key.
func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
victimID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
attackerID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(attackerID, "verified_email")
// The victim's COMPLETED payment under a deterministic/guessable key.
victimKey := fmt.Sprintf("gc-%s-2000-self-new", victimID)
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'completed', 20.00, $1, $2, NOW(), NOW())
`, victimKey, victimID)
require.NoError(t, err)
// The attacker supplies the victim's key: must NOT get the victim's
// completed payment back (which would be a false success leaking the
// victim's row) — it must proceed as a fresh charge.
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": victimKey,
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "a foreign-key purchase must proceed as a fresh charge, body: %s", w.Body.String())
// A gift card was issued to the ATTACKER, not the victim.
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", attackerID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "the attacker must receive their own gift card")
// The victim's payment row is untouched and the attacker got their own row.
var victimPayCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", victimKey).Scan(&victimPayCount))
require.Equal(t, 1, victimPayCount, "the victim's payment row must not be reused or duplicated")
var attackerPayCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", attackerID).Scan(&attackerPayCount))
require.Equal(t, 1, attackerPayCount, "the attacker must have exactly one completed payment of their own")
}
// =============================================================================
// H4 — a COMPLETED provisional terminal checkout must be recorded, not just
// released