fix: comprehensive payment system hardening (4 review passes)

CRITICAL fixes:
- C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode)
- C2: OverrideAmount validated post-substitution (prevents negative money minting)
- C3: Terminal gift-card payments store gift_card_id; refund credits user balance
- C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead)
- C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation)
- C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund)

MAJOR fixes:
- M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed
- M3: ProcessCancellationRefund returns commit error (was swallowed)
- M5: Dispute webhook handling (created + state.updated + disputes table)

MEDIUM fixes:
- ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any)
- ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens
- ME3: Webhook handlers now mutate state (payment.updated, refund.updated)

Frontend fixes:
- Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows
- CHARGE_AND_STORE intent for save-card flows (SCA compliance)
- Nonce staleness check verified across all flows

Additional fixes from adversarial re-review:
- F1: Till-sale completed dedup echoes stored amount (C4-class)
- F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class)
- F3: Square-success UPDATE checks RowsAffected (till sales)
- F4: Dispute reason truncated to 192 chars (prevents INSERT failure)
- F5: Booking-user lookup failure marks refund failed (prevents silent money loss)
- F6: Saved-card/tip rechecks wrapped in transaction (C5 residual)

Tests:
- 15 adversarial attack tests (negative override, zero override, terminal gift card,
  refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge
  amount, raw PAN, missing auth, gift card balance, concurrent refunds)
- 14 webhook state tests (dispute created/state, payment/refund updated)
- 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test
- Full suite passes with -race (25 packages, 0 failures)

25 files changed, +1532/-275 lines
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7df983052b
commit 5e3dc9b428
28 changed files with 2905 additions and 275 deletions
+114
View File
@@ -517,6 +517,120 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
}
}
func TestAnonymizeUser_ScrubsNotes(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET notes = 'Client prefers quiet appointments and has a cat allergy'
WHERE id = $1
`, userID)
if err != nil {
t.Fatalf("failed to set user notes: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var notes, lastLoginAt interface{}
err = tx.QueryRow(ctx, `SELECT notes, last_login_at FROM users WHERE id = $1`, userID).Scan(&notes, &lastLoginAt)
if err != nil {
t.Fatalf("failed to query user notes: %v", err)
}
if notes != nil {
t.Errorf("expected users.notes to be NULL after anonymization, got %v", notes)
}
if lastLoginAt != nil {
t.Errorf("expected users.last_login_at to be NULL after anonymization, got %v", lastLoginAt)
}
}
func TestAnonymizeUser_ScrubsNameHistory(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
VALUES ($1, 'Old', 'Name')
`, userID)
if err != nil {
t.Fatalf("failed to insert name history: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var firstName, lastName string
err = tx.QueryRow(ctx, `
SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1
`, userID).Scan(&firstName, &lastName)
if err != nil {
t.Fatalf("failed to query name history: %v", err)
}
if firstName != "Deleted" {
t.Errorf("expected previous_first_name 'Deleted', got %q", firstName)
}
if lastName != "User" {
t.Errorf("expected previous_last_name 'User', got %q", lastName)
}
}
func TestAnonymizeUser_ScrubsBookingNotes(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE bookings SET notes = 'Please call me on the day, doorbell broken'
WHERE id = $1
`, bookingID)
if err != nil {
t.Fatalf("failed to set booking notes: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var notes interface{}
err = tx.QueryRow(ctx, `SELECT notes FROM bookings WHERE id = $1`, bookingID).Scan(&notes)
if err != nil {
t.Fatalf("failed to query booking notes: %v", err)
}
if notes != nil {
t.Errorf("expected booking notes to be NULL after anonymization, got %v", notes)
}
}
func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)