fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety: - Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation - Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged - CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard) - Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse - Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID GDPR / security: - Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010 - square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2 - Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel - Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit Frontend: - Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen) - Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh - Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy S3: - Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific) Tests/docs: - 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
This commit is contained in:
@@ -439,7 +439,13 @@ func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) {
|
||||
// TestAnonymizeUser_RetainsEditRequestNotes verifies that booking edit request
|
||||
// notes survive GDPR erasure: the notes field is a single free-text medical/
|
||||
// safety record (colour/preference/lateness AND allergy/access/disability
|
||||
// content) retained de-identified for reasonable adjustments (Equality Act
|
||||
// 2010) and legal-claims defence, so the edit request row must survive and its
|
||||
// notes must be kept verbatim.
|
||||
func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -471,15 +477,29 @@ func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) {
|
||||
t.Fatalf("anonymize_user failed: %v", err)
|
||||
}
|
||||
|
||||
var notes interface{}
|
||||
// The edit request row must survive erasure (the SQL no longer deletes
|
||||
// notes-only edit requests) — it is retained as a de-identified record.
|
||||
var rowCount int
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM booking_edit_requests WHERE requested_by = $1
|
||||
`, userID).Scan(&rowCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count edit requests: %v", err)
|
||||
}
|
||||
if rowCount != 1 {
|
||||
t.Errorf("expected edit request row to be retained after anonymization, found %d rows", rowCount)
|
||||
}
|
||||
|
||||
// The notes are retained verbatim as a de-identified medical/safety record.
|
||||
var notes string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT notes FROM booking_edit_requests WHERE requested_by = $1
|
||||
`, userID).Scan(¬es)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query edit request notes: %v", err)
|
||||
}
|
||||
if notes != nil {
|
||||
t.Errorf("expected edit request notes to be NULL after anonymization, got %v", notes)
|
||||
if notes != "Please move my appointment, I have a conflict" {
|
||||
t.Errorf("expected edit request notes to be retained after anonymization, got %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,11 +537,14 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeUser_Scrubs2FAAndNotes verifies the GDPR erasure gap closure:
|
||||
// anonymize_user() itself NULLs the 2FA columns and staff notes on the row, so
|
||||
// every call site (user-initiated delete AND idle-account batch cleanup) is
|
||||
// covered without a separate Go-side scrub.
|
||||
func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
// TestAnonymizeUser_Scrubs2FA_RetainsNotes verifies the GDPR erasure gap
|
||||
// closure: anonymize_user() itself clears the 2FA columns on the row, so every
|
||||
// call site (user-initiated delete AND idle-account batch cleanup) is covered
|
||||
// without a separate Go-side scrub. The notes field is a single free-text
|
||||
// medical/safety record (colour/preference/lateness AND allergy/access/
|
||||
// disability content) and is RETAINED de-identified at erasure for reasonable
|
||||
// adjustments (Equality Act 2010) and legal-claims defence.
|
||||
func TestAnonymizeUser_Scrubs2FA_RetainsNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -548,7 +571,7 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
t.Fatalf("anonymize_user failed: %v", err)
|
||||
}
|
||||
|
||||
var notes interface{}
|
||||
var notes string
|
||||
var enabled bool
|
||||
var method, pendingHash, pendingExpires interface{}
|
||||
err = tx.QueryRow(ctx, `
|
||||
@@ -559,8 +582,8 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user after anonymization: %v", err)
|
||||
}
|
||||
if notes != nil {
|
||||
t.Errorf("expected users.notes to be NULL after erasure, got %v", notes)
|
||||
if notes != "Client prefers quiet appointments and has a cat allergy" {
|
||||
t.Errorf("expected users.notes to be retained after erasure, got %q", notes)
|
||||
}
|
||||
if enabled {
|
||||
t.Error("expected two_factor_enabled to be FALSE after erasure")
|
||||
@@ -576,12 +599,15 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteAccount_Scrubs2FAAndNotes runs the full DeleteAccountHandler for a
|
||||
// user with 2FA enabled and staff notes, asserting the end-to-end delete path
|
||||
// scrubs both (via anonymize_user(), which is now the single source of truth).
|
||||
// TestDeleteAccount_Scrubs2FA_RetainsNotes runs the full DeleteAccountHandler
|
||||
// for a user with 2FA enabled and staff notes, asserting the end-to-end delete
|
||||
// path clears the 2FA columns (via anonymize_user(), which is now the single
|
||||
// source of truth) while RETAINING the notes as a de-identified medical/safety
|
||||
// record for reasonable adjustments (Equality Act 2010) and legal-claims
|
||||
// defence.
|
||||
// Kept sequential (no t.Parallel) because the handler reads the
|
||||
// process-global payments.SquareClient.
|
||||
func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) {
|
||||
func TestDeleteAccount_Scrubs2FA_RetainsNotes(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
@@ -611,7 +637,7 @@ func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) {
|
||||
t.Fatalf("expected 204, got %d. body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var notes interface{}
|
||||
var notes string
|
||||
var enabled bool
|
||||
var method, pendingHash, pendingExpires interface{}
|
||||
err = tx.QueryRow(ctx, `
|
||||
@@ -622,8 +648,8 @@ func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user after deletion: %v", err)
|
||||
}
|
||||
if notes != nil {
|
||||
t.Errorf("expected users.notes to be NULL after deletion, got %v", notes)
|
||||
if notes != "Staff note with PII" {
|
||||
t.Errorf("expected users.notes to be retained after deletion, got %q", notes)
|
||||
}
|
||||
if enabled {
|
||||
t.Error("expected two_factor_enabled to be FALSE after deletion")
|
||||
@@ -676,7 +702,12 @@ func TestAnonymizeUser_ScrubsNameHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeUser_ScrubsBookingNotes(t *testing.T) {
|
||||
// TestAnonymizeUser_RetainsBookingNotes verifies that booking notes survive
|
||||
// GDPR erasure: the notes field is a single free-text medical/safety record
|
||||
// (colour/preference/lateness AND allergy/access/disability content) retained
|
||||
// de-identified at erasure for reasonable adjustments (Equality Act 2010) and
|
||||
// legal-claims defence (e.g. allergy mistreatment).
|
||||
func TestAnonymizeUser_RetainsBookingNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -708,13 +739,13 @@ func TestAnonymizeUser_ScrubsBookingNotes(t *testing.T) {
|
||||
t.Fatalf("anonymize_user failed: %v", err)
|
||||
}
|
||||
|
||||
var notes interface{}
|
||||
var notes string
|
||||
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)
|
||||
if notes != "Please call me on the day, doorbell broken" {
|
||||
t.Errorf("expected booking notes to be retained after anonymization, got %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,6 +780,135 @@ func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeUser_PreservesFinancialRows pins the UK financial 7-year
|
||||
// retention: GDPR erasure must scrub user-linked PII but leave financial rows
|
||||
// untouched. anonymize_user() only NULLs the Square request snapshot (which
|
||||
// embeds BuyerEmail PII); the payments, gift_cards and gift_card_transactions
|
||||
// rows themselves — amount, created_by, square_payment_id,
|
||||
// total_funds_added — must survive erasure byte-for-byte.
|
||||
func TestAnonymizeUser_PreservesFinancialRows(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)
|
||||
}
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
// created_by + Square refs: the financial fields the 7-year retention keeps.
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE payments
|
||||
SET created_by = $1,
|
||||
square_payment_id = 'sq_pay_retention_123',
|
||||
square_request_snapshot = '{"buyer_email":"real@example.com","amount":50.00}'
|
||||
WHERE id = $2
|
||||
`, userID, paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set payment created_by + square_payment_id: %v", err)
|
||||
}
|
||||
|
||||
// Gift card + its transaction ledger rows, both linked to the user.
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by)
|
||||
VALUES (100.00, 40.00, $1, $1)
|
||||
RETURNING id
|
||||
`, userID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, user_id)
|
||||
VALUES ($1, 'purchase', 100.00, $2)
|
||||
`, giftCardID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card transaction: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("anonymize_user failed: %v", err)
|
||||
}
|
||||
|
||||
// The payment row survives with its financial fields untouched; only the
|
||||
// PII-bearing Square request snapshot is scrubbed.
|
||||
var amount float64
|
||||
var createdBy, squarePaymentID, snapshot interface{}
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT amount, created_by, square_payment_id, square_request_snapshot
|
||||
FROM payments WHERE id = $1
|
||||
`, paymentID).Scan(&amount, &createdBy, &squarePaymentID, &snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query payment after anonymization: %v", err)
|
||||
}
|
||||
if amount != 50.00 {
|
||||
t.Errorf("expected payment amount 50.00 to survive erasure, got %v", amount)
|
||||
}
|
||||
if createdBy != userID {
|
||||
t.Errorf("expected payment created_by %q to survive erasure, got %v", userID, createdBy)
|
||||
}
|
||||
if squarePaymentID != "sq_pay_retention_123" {
|
||||
t.Errorf("expected payment square_payment_id to survive erasure, got %v", squarePaymentID)
|
||||
}
|
||||
if snapshot != nil {
|
||||
t.Errorf("expected square_request_snapshot to be NULLed (BuyerEmail PII), got %v", snapshot)
|
||||
}
|
||||
|
||||
// Gift card funds survive erasure.
|
||||
var totalFundsAdded float64
|
||||
err = tx.QueryRow(ctx, `SELECT total_funds_added FROM gift_cards WHERE id = $1`, giftCardID).Scan(&totalFundsAdded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift card after anonymization: %v", err)
|
||||
}
|
||||
if totalFundsAdded != 100.00 {
|
||||
t.Errorf("expected gift card total_funds_added 100.00 to survive erasure, got %v", totalFundsAdded)
|
||||
}
|
||||
|
||||
// Gift card transaction ledger rows survive erasure with the user link intact.
|
||||
var txAmount float64
|
||||
var txUserID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT amount, user_id FROM gift_card_transactions WHERE gift_card_id = $1
|
||||
`, giftCardID).Scan(&txAmount, &txUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift card transaction after anonymization: %v", err)
|
||||
}
|
||||
if txAmount != 100.00 {
|
||||
t.Errorf("expected gift card transaction amount 100.00 to survive erasure, got %v", txAmount)
|
||||
}
|
||||
if txUserID != userID {
|
||||
t.Errorf("expected gift card transaction user_id %q to survive erasure, got %v", userID, txUserID)
|
||||
}
|
||||
|
||||
// ... while the user-linked PII is scrubbed.
|
||||
var firstName, email string
|
||||
err = tx.QueryRow(ctx, `SELECT n_first_name, email FROM users WHERE id = $1`, userID).Scan(&firstName, &email)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user after anonymization: %v", err)
|
||||
}
|
||||
if firstName != "Deleted" {
|
||||
t.Errorf("expected n_first_name 'Deleted' after erasure, got %q", firstName)
|
||||
}
|
||||
if email != "deleted+"+userID+"@deleted.invalid" {
|
||||
t.Errorf("expected anonymized email, got %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SQL export_all_user_data() Comprehensive Export Tests
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user