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:
@@ -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(¬es, &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(¬es)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user