Files
Crussell/backend/handlers/user/gdpr_test.go
T

1822 lines
56 KiB
Go

//go:build test
package user
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
) // ============================================================
// GetGDPRExportHandler Tests
// ============================================================
func TestGDPRExport_NoAuth(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil)
rr := httptest.NewRecorder()
GetGDPRExportHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", rr.Code)
}
}
func TestGDPRExport_CacheMiss(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)
}
token := jwt.GenerateUserToken(userID)
req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
GetGDPRExportHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
return
}
cacheHeader := rr.Header().Get("X-Cache")
if cacheHeader != "MISS" {
t.Errorf("expected X-Cache header 'MISS', got %q", cacheHeader)
}
var resp map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp["status"] != "generating" {
t.Errorf("expected status 'generating', got %q", resp["status"])
}
}
func TestGDPRExport_CacheHit(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)
}
token := jwt.GenerateUserToken(userID)
testData := json.RawMessage(`{"user_profile":{"id":"` + userID + `","first_name":"Test"}}`)
gdprExportCacheMu.Lock()
gdprExportCache[userID] = &gdprCacheEntry{
data: testData,
expiresAt: clock.Now().Add(12 * time.Hour),
}
gdprExportCacheMu.Unlock()
req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
GetGDPRExportHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
return
}
cacheHeader := rr.Header().Get("X-Cache")
if cacheHeader != "HIT" {
t.Errorf("expected X-Cache header 'HIT', got %q", cacheHeader)
}
if rr.Body.String() != string(testData) {
t.Errorf("expected cached data, got %s", rr.Body.String())
}
}
func TestGDPRExport_CacheGenerating(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)
}
token := jwt.GenerateUserToken(userID)
gdprExportCacheMu.Lock()
gdprExportCache[userID] = &gdprCacheEntry{
generating: true,
expiresAt: clock.Now().Add(12 * time.Hour),
}
gdprExportCacheMu.Unlock()
req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
GetGDPRExportHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
return
}
cacheHeader := rr.Header().Get("X-Cache")
if cacheHeader != "GENERATING" {
t.Errorf("expected X-Cache header 'GENERATING', got %q", cacheHeader)
}
var resp map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp["status"] != "generating" {
t.Errorf("expected status 'generating', got %q", resp["status"])
}
}
func TestGDPRExport_ExpiredCacheTriggersRegeneration(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)
}
token := jwt.GenerateUserToken(userID)
gdprExportCacheMu.Lock()
gdprExportCache[userID] = &gdprCacheEntry{
data: json.RawMessage(`{"old":"data"}`),
expiresAt: clock.Now().Add(-1 * time.Hour),
}
gdprExportCacheMu.Unlock()
req := httptest.NewRequest(http.MethodGet, "/api/user/gdpr-export", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
GetGDPRExportHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rr.Code)
return
}
cacheHeader := rr.Header().Get("X-Cache")
if cacheHeader != "MISS" {
t.Errorf("expected X-Cache header 'MISS' for expired cache, got %q", cacheHeader)
}
}
// ============================================================
// SQL anonymize_user() Child Table Scrubbing Tests
// ============================================================
func TestAnonymizeUser_ScrubsSocialLogins(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 user_social_logins (user_id, provider, immutable_id)
VALUES ($1, 'google', 'google-123'), ($1, 'microsoft', 'ms-456')
`, userID)
if err != nil {
t.Fatalf("failed to insert social logins: %v", err)
}
var count int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to count social logins: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 social logins before anonymization, got %d", count)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_social_logins WHERE user_id = $1`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to count social logins after anonymization: %v", err)
}
if count != 0 {
t.Errorf("expected 0 social logins after anonymization, got %d", count)
}
}
func TestAnonymizeUser_SoftDeletesSavedCards(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 user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
VALUES ($1, 'sq_card_123', 'Visa', '4242', 12, 2030, 'fp_abc123', true)
`, userID)
if err != nil {
t.Fatalf("failed to insert saved card: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var deletedAt, fingerprint, squareCardID interface{}
var last4 string
var expMonth, expYear int
err = tx.QueryRow(ctx, `
SELECT deleted_at, last_4, fingerprint, exp_month, exp_year, square_card_id
FROM user_saved_cards WHERE user_id = $1
`, userID).Scan(&deletedAt, &last4, &fingerprint, &expMonth, &expYear, &squareCardID)
if err != nil {
t.Fatalf("failed to query saved card after anonymization: %v", err)
}
if deletedAt == nil {
t.Error("expected deleted_at to be set after anonymization")
}
if last4 != "XXXX" {
t.Errorf("expected last_4 to be 'XXXX', got %q", last4)
}
if fingerprint != nil {
t.Errorf("expected fingerprint to be NULL, got %v", fingerprint)
}
if squareCardID != nil {
t.Errorf("expected square_card_id to be NULL after anonymization (external-system reference), got %v", squareCardID)
}
if expMonth != 1 {
t.Errorf("expected exp_month to be 1, got %d", expMonth)
}
if expYear != 2000 {
t.Errorf("expected exp_year to be 2000, got %d", expYear)
}
}
func TestDeleteGuestUser_ScrubsSavedCards(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create test guest user: %v", err)
}
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
VALUES ($1, 'sq_card_guest', 'sq_customer_guest', 'Visa', '4242', 12, 2030, 'fp_guest123', true)
RETURNING id
`, userID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert saved card: %v", err)
}
_, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
if err != nil {
t.Fatalf("delete_guest_user failed: %v", err)
}
// The guest user row is fully deleted.
var userCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&userCount); err != nil {
t.Fatalf("failed to count users: %v", err)
}
if userCount != 0 {
t.Errorf("expected guest user to be deleted, found %d rows", userCount)
}
// The saved card is soft-deleted and scrubbed. delete_guest_user UNLINKS
// the card (user_id = NULL), so query by the card id captured at insert.
var deletedAt, fingerprint, squareCardID, squareCustomerID interface{}
var last4 string
var userIDCol interface{}
err = tx.QueryRow(ctx, `
SELECT user_id, deleted_at, last_4, fingerprint, square_card_id, square_customer_id
FROM user_saved_cards WHERE id = $1
`, cardID).Scan(&userIDCol, &deletedAt, &last4, &fingerprint, &squareCardID, &squareCustomerID)
if err != nil {
t.Fatalf("failed to query saved card after deletion: %v", err)
}
if userIDCol != nil {
t.Errorf("expected user_id to be NULL (card unlinked), got %v", userIDCol)
}
if deletedAt == nil {
t.Error("expected deleted_at to be set after delete_guest_user")
}
if last4 != "XXXX" {
t.Errorf("expected last_4 to be 'XXXX', got %q", last4)
}
if fingerprint != nil {
t.Errorf("expected fingerprint to be NULL, got %v", fingerprint)
}
if squareCardID != nil {
t.Errorf("expected square_card_id to be NULL after delete_guest_user (external-system reference), got %v", squareCardID)
}
if squareCustomerID != nil {
t.Errorf("expected square_customer_id to be NULL after delete_guest_user, got %v", squareCustomerID)
}
}
func TestAnonymizeUser_ExpiresVerificationCodes(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 verification_codes (user_id, purpose, code, used_at, expires_at)
VALUES ($1, 'email_verify', 'CODE1', NOW(), NOW() + INTERVAL '1 hour'),
($1, 'password_reset', 'CODE2', NULL, NOW() + INTERVAL '1 hour')
`, userID)
if err != nil {
t.Fatalf("failed to insert verification codes: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var pendingCount int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM verification_codes WHERE user_id = $1 AND used_at IS NULL
`, userID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to count pending verification codes: %v", err)
}
if pendingCount != 0 {
t.Errorf("expected 0 pending verification codes after anonymization, got %d", pendingCount)
}
}
func TestAnonymizeUser_ScrubsTimeBlockerReservations(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 time_blockers (start_time, duration_minutes, description, created_by)
VALUES (NOW(), 60, 'RESERVATION:user:slot123', $1),
(NOW() + INTERVAL '1 hour', 30, 'RESERVATION:user:slot456', $1),
(NOW() + INTERVAL '2 hours', 45, 'Admin lunch break', $1)
`, userID)
if err != nil {
t.Fatalf("failed to insert time blockers: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var reservationCount int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM time_blockers WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%%'
`, userID).Scan(&reservationCount)
if err != nil {
t.Fatalf("failed to count reservation time blockers: %v", err)
}
if reservationCount != 0 {
t.Errorf("expected 0 RESERVATION time blockers after anonymization, got %d", reservationCount)
}
var adminBreakDesc string
err = tx.QueryRow(ctx, `
SELECT description FROM time_blockers WHERE created_by = $1 AND description = 'Admin lunch break'
`, userID).Scan(&adminBreakDesc)
if err != nil {
t.Errorf("expected non-RESERVATION time blocker to be preserved, got error: %v", err)
}
if adminBreakDesc != "Admin lunch break" {
t.Errorf("expected 'Admin lunch break' to be preserved, got %q", adminBreakDesc)
}
}
// 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)
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, `
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes)
VALUES ($1, $2, NOW() + INTERVAL '1 day', 'Please move my appointment, I have a conflict')
`, bookingID, userID)
if err != nil {
t.Fatalf("failed to insert edit request: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
// The edit request row must survive erasure (the SQL no longer deletes
// notes-only edit requests) — it is retained as a de-identified record.
// After anonymization, requested_by is SET NULL (user link severed per GDPR),
// so we query by booking_id instead of requested_by.
var rowCount int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM booking_edit_requests WHERE booking_id = $1
`, bookingID).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.
// requested_by is NULL after anonymization, so we query by booking_id.
var notes string
err = tx.QueryRow(ctx, `
SELECT notes FROM booking_edit_requests WHERE booking_id = $1
`, bookingID).Scan(&notes)
if err != nil {
t.Fatalf("failed to query edit request notes: %v", err)
}
if notes != "Please move my appointment, I have a conflict" {
t.Errorf("expected edit request notes to be retained after anonymization, got %q", notes)
}
}
// TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback closes the GDPR erasure gap
// for admin_audit_log: a '2fa_fallback_charge' row (written by handlers/payments) carries target_user_id = the erased user, admin_id = the
// customer's own userID (the CIT actor), AND details.card_last4 — the audit row
// MUST survive erasure (GDPR Art 30 records of processing / financial audit
// trail) but be de-identified: the user links (both target_user_id and
// admin_id) are NULLed and the card PII in details is scrubbed.
func TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback(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)
}
// A 2FA-fallback audit row for a CIT saved-card charge: target_user_id is
// the customer and admin_id is the customer's own userID (the CIT actor).
// details carries the card_last4 PII.
var auditID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, '2fa_fallback_charge', $1, $2::jsonb)
RETURNING id
`, userID, `{"sca_performed": false, "fallback_reason": "verification_unavailable", "card_last4": "4242", "reference_id": "booking123", "notes": "saved-card charge authorized via 2FA fallback (SCA unavailable)"}`).Scan(&auditID)
if err != nil {
t.Fatalf("failed to insert 2fa_fallback_charge audit row: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
// The audit row survives erasure (audit retention) but is de-identified:
// both the target_user_id and the admin_id (the CIT actor was the erased
// customer) are NULLed, and the card_last4 PII is scrubbed from details.
var targetUserID, adminID interface{}
var details json.RawMessage
err = tx.QueryRow(ctx, `
SELECT target_user_id, admin_id, details FROM admin_audit_log WHERE id = $1
`, auditID).Scan(&targetUserID, &adminID, &details)
if err != nil {
t.Fatalf("failed to query audit row after anonymization: %v", err)
}
if targetUserID != nil {
t.Errorf("expected target_user_id to be NULL after anonymization, got %v", targetUserID)
}
if adminID != nil {
t.Errorf("expected admin_id to be NULL after anonymization (the CIT actor is the erased user), got %v", adminID)
}
if len(details) == 0 {
t.Error("expected the audit row to survive erasure (retained, de-identified)")
}
var detailsMap map[string]any
if err := json.Unmarshal(details, &detailsMap); err != nil {
t.Fatalf("failed to parse retained audit details: %v", err)
}
if last4, ok := detailsMap["card_last4"]; ok && last4 != nil {
t.Errorf("expected details.card_last4 to be scrubbed after anonymization, got %v", last4)
}
// No residual audit rows may still reference the erased user.
var remaining int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_log WHERE target_user_id = $1 OR admin_id = $1
`, userID).Scan(&remaining)
if err != nil {
t.Fatalf("failed to count residual audit rows: %v", err)
}
if remaining != 0 {
t.Errorf("expected 0 audit rows still referencing the erased user, got %d", remaining)
}
}
func TestAnonymizeUser_ClearsNotificationPrefs(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 user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled)
VALUES ($1, true, false, true)
`, userID)
if err != nil {
t.Fatalf("failed to insert notification preferences: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var count int
err = tx.QueryRow(ctx, `
SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1
`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to count notification preferences: %v", err)
}
if count != 0 {
t.Errorf("expected 0 notification preferences after anonymization, got %d", count)
}
}
// 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)
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',
two_factor_enabled = TRUE,
two_factor_method = 'email',
two_factor_pending_code_hash = 'abc123',
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
WHERE id = $1
`, userID)
if err != nil {
t.Fatalf("failed to set user notes + 2FA: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var notes string
var enabled bool
var method, pendingHash, pendingExpires interface{}
err = tx.QueryRow(ctx, `
SELECT notes, two_factor_enabled, two_factor_method,
two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users WHERE id = $1
`, userID).Scan(&notes, &enabled, &method, &pendingHash, &pendingExpires)
if err != nil {
t.Fatalf("failed to query user after anonymization: %v", err)
}
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")
}
if method != nil {
t.Errorf("expected two_factor_method to be NULL after erasure, got %v", method)
}
if pendingHash != nil {
t.Errorf("expected two_factor_pending_code_hash to be NULL after erasure, got %v", pendingHash)
}
if pendingExpires != nil {
t.Errorf("expected two_factor_pending_code_expires to be NULL after erasure, got %v", pendingExpires)
}
}
// 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_Scrubs2FA_RetainsNotes(t *testing.T) {
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 = 'Staff note with PII',
two_factor_enabled = TRUE,
two_factor_method = 'sms',
two_factor_pending_code_hash = 'deadbeef',
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
WHERE id = $1
`, userID)
if err != nil {
t.Fatalf("failed to set user notes + 2FA: %v", err)
}
// Finding 3: an enforced environment + a 2FA-enabled user requires a fresh
// one-time code at deletion time. Seed a known pending code (the test runs
// with SQUARE_ENVIRONMENT unset → enforced) and present it.
seedPendingTwoFA(t, ctx, tx, userID, "424242")
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "424242")
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d. body: %s", rr.Code, rr.Body.String())
}
var notes string
var enabled bool
var method, pendingHash, pendingExpires interface{}
err = tx.QueryRow(ctx, `
SELECT notes, two_factor_enabled, two_factor_method,
two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users WHERE id = $1
`, userID).Scan(&notes, &enabled, &method, &pendingHash, &pendingExpires)
if err != nil {
t.Fatalf("failed to query user after deletion: %v", err)
}
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")
}
if method != nil {
t.Errorf("expected two_factor_method to be NULL after deletion, got %v", method)
}
if pendingHash != nil {
t.Errorf("expected two_factor_pending_code_hash to be NULL after deletion, got %v", pendingHash)
}
if pendingExpires != nil {
t.Errorf("expected two_factor_pending_code_expires to be NULL after deletion, got %v", pendingExpires)
}
}
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)
}
}
// 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)
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 string
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 != "Please call me on the day, doorbell broken" {
t.Errorf("expected booking notes to be retained after anonymization, got %q", notes)
}
}
func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
var firstName string
err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName)
if err != nil {
t.Fatalf("failed to query guest user before anonymization: %v", err)
}
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
t.Fatalf("anonymize_user failed: %v", err)
}
var firstNameAfter string
err = tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstNameAfter)
if err != nil {
t.Fatalf("failed to query guest user after anonymization: %v", err)
}
if firstNameAfter != firstName {
t.Errorf("expected guest user first name to remain %q, got %q", firstName, firstNameAfter)
}
}
// 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
// ============================================================
func TestExportAllUserData_BasicProfile(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)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
profile, ok := data["user_profile"].(map[string]interface{})
if !ok {
t.Fatal("expected user_profile section in export")
}
if profile["id"] != userID {
t.Errorf("expected user_profile.id to be %q, got %v", userID, profile["id"])
}
if profile["first_name"] != "Test" {
t.Errorf("expected user_profile.first_name to be 'Test', got %v", profile["first_name"])
}
if profile["last_name"] != "User" {
t.Errorf("expected user_profile.last_name to be 'User', got %v", profile["last_name"])
}
}
func TestExportAllUserData_BookingsWithOverrides(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 booking_services SET override_price = 75.00 WHERE booking_id = $1 AND service_id = $2
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to set override price: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
bookings, ok := data["bookings"].([]interface{})
if !ok {
t.Fatal("expected bookings section in export")
}
if len(bookings) == 0 {
t.Fatal("expected at least one booking in export")
}
booking := bookings[0].(map[string]interface{})
if booking["booking_id"] != bookingID {
t.Errorf("expected booking_id %q, got %v", bookingID, booking["booking_id"])
}
totalPrice := booking["total_price"]
switch v := totalPrice.(type) {
case string:
if v != "75.00" {
t.Errorf("expected total_price '75.00' (override), got %q", v)
}
case float64:
if v != 75.00 {
t.Errorf("expected total_price 75.00 (override), got %v", v)
}
default:
t.Errorf("unexpected total_price type %T: %v", totalPrice, totalPrice)
}
services := booking["services"].([]interface{})
if len(services) == 0 {
t.Fatal("expected at least one service in booking")
}
service := services[0].(map[string]interface{})
svcPrice := service["price"]
switch v := svcPrice.(type) {
case string:
if v != "75.00" {
t.Errorf("expected service price '75.00' (override), got %v", v)
}
case float64:
if v != 75.00 {
t.Errorf("expected service price 75.00 (override), got %v", v)
}
default:
t.Errorf("unexpected service price type %T: %v", svcPrice, svcPrice)
}
}
func TestExportAllUserData_PaymentsAndRefunds(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)
}
refundID, err := fixtures.CreateTestRefund(tx, paymentID, bookingID, 25.00)
if err != nil {
t.Fatalf("failed to create refund: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
payments, ok := data["payments"].([]interface{})
if !ok {
t.Fatal("expected payments section in export")
}
if len(payments) != 1 {
t.Fatalf("expected 1 payment, got %d", len(payments))
}
payment := payments[0].(map[string]interface{})
if payment["payment_id"] != paymentID {
t.Errorf("expected payment_id %q, got %v", paymentID, payment["payment_id"])
}
refunds, ok := data["refunds"].([]interface{})
if !ok {
t.Fatal("expected refunds section in export")
}
if len(refunds) != 1 {
t.Fatalf("expected 1 refund, got %d", len(refunds))
}
refund := refunds[0].(map[string]interface{})
if refund["refund_id"] != refundID {
t.Errorf("expected refund_id %q, got %v", refundID, refund["refund_id"])
}
}
func TestExportAllUserData_SavedCards(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 = fixtures.CreateTestPaymentMethod(tx, userID, "sq_card_test", "Visa", "4242")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
cards, ok := data["saved_cards"].([]interface{})
if !ok {
t.Fatal("expected saved_cards section in export")
}
if len(cards) != 1 {
t.Fatalf("expected 1 saved card, got %d", len(cards))
}
card := cards[0].(map[string]interface{})
if card["brand"] != "Visa" {
t.Errorf("expected card brand 'Visa', got %v", card["brand"])
}
if card["last_4"] != "4242" {
t.Errorf("expected card last_4 '4242', got %v", card["last_4"])
}
}
func TestExportAllUserData_EmptySectionsReturnEmptyArrays(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)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
emptySections := []string{
"bookings", "payments", "patch_tests", "saved_cards", "refunds",
"social_logins", "loyalty_redemptions", "booking_discounts",
"edit_requests", "affiliate_payouts", "forgiven_no_shows", "gift_cards", "admin_audit_log",
"gift_card_transactions", "name_history", "referral_discounts",
"login_audit", "refresh_tokens",
}
for _, section := range emptySections {
val, ok := data[section]
if !ok {
t.Errorf("expected %q section in export", section)
continue
}
arr, ok := val.([]interface{})
if !ok {
t.Errorf("expected %q to be an array, got %T", section, val)
continue
}
if len(arr) != 0 {
t.Errorf("expected %q to be empty array, got %d items", section, len(arr))
}
}
// gift_card_balance is a JSON object (not array), check separately.
if gb, ok := data["gift_card_balance"]; !ok {
t.Error("expected 'gift_card_balance' section in export")
} else if gbMap, ok := gb.(map[string]interface{}); !ok {
t.Errorf("expected 'gift_card_balance' to be an object, got %T", gb)
} else if gbMap["balance"] != nil && gbMap["balance"].(float64) != 0 {
t.Errorf("expected gift_card_balance to be 0, got %v", gbMap["balance"])
}
}
func TestExportAllUserData_ExportMetadata(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)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
metadata, ok := data["export_metadata"].(map[string]interface{})
if !ok {
t.Fatal("expected export_metadata section in export")
}
if metadata["user_id"] != userID {
t.Errorf("expected export_metadata.user_id %q, got %v", userID, metadata["user_id"])
}
if metadata["format_version"] != "1.1" {
t.Errorf("expected format_version '1.1', got %v", metadata["format_version"])
}
if metadata["exported_by"] != "system" {
t.Errorf("expected exported_by 'system', got %v", metadata["exported_by"])
}
if metadata["exported_at"] == nil {
t.Error("expected exported_at to be set")
}
}
func TestExportAllUserData_NotificationPreferences(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 user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled)
VALUES ($1, true, false, true)
`, userID)
if err != nil {
t.Fatalf("failed to insert notification preferences: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
prefs, ok := data["notification_preferences"].([]interface{})
if !ok {
t.Fatal("expected notification_preferences section in export")
}
if len(prefs) != 1 {
t.Fatalf("expected 1 notification preference, got %d", len(prefs))
}
pref := prefs[0].(map[string]interface{})
if pref["email_enabled"] != true {
t.Errorf("expected email_enabled true, got %v", pref["email_enabled"])
}
if pref["sms_enabled"] != false {
t.Errorf("expected sms_enabled false, got %v", pref["sms_enabled"])
}
if pref["browser_push_enabled"] != true {
t.Errorf("expected browser_push_enabled true, got %v", pref["browser_push_enabled"])
}
}
func TestExportAllUserData_VerificationCodes(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 verification_codes (user_id, purpose, code, used_at, expires_at)
VALUES ($1, 'email_verify', 'CODE1', NOW(), NOW() + INTERVAL '1 hour'),
($1, 'password_reset', 'CODE2', NULL, NOW() + INTERVAL '1 hour')
`, userID)
if err != nil {
t.Fatalf("failed to insert verification codes: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
// Verification codes are authentication tokens, not personal data,
// so they SHOULD be excluded from the SAR export (GDPR Art 15).
_, exists := data["verification_codes"]
if exists {
t.Fatal("verification_codes should not be included in GDPR export (authentication tokens)")
}
}
func TestExportAllUserData_EditRequests(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, `
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, notes, has_overrides)
VALUES ($1, $2, NOW() + INTERVAL '1 day', 'Please reschedule', false)
`, bookingID, userID)
if err != nil {
t.Fatalf("failed to insert edit request: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
editRequests, ok := data["edit_requests"].([]interface{})
if !ok {
t.Fatal("expected edit_requests section in export")
}
if len(editRequests) != 1 {
t.Fatalf("expected 1 edit request, got %d", len(editRequests))
}
er := editRequests[0].(map[string]interface{})
if er["notes"] != "Please reschedule" {
t.Errorf("expected edit request notes 'Please reschedule', got %v", er["notes"])
}
}
func TestExportAllUserData_ForgivenNoShows(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, `
INSERT INTO forgiven_no_shows (booking_id, created_at)
VALUES ($1, NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to insert forgiven no-show: %v", err)
}
var result json.RawMessage
err = tx.QueryRow(ctx, `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
t.Fatalf("export_all_user_data failed: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(result, &data); err != nil {
t.Fatalf("failed to unmarshal export result: %v", err)
}
noShows, ok := data["forgiven_no_shows"].([]interface{})
if !ok {
t.Fatal("expected forgiven_no_shows section in export")
}
if len(noShows) != 1 {
t.Fatalf("expected 1 forgiven no-show, got %d", len(noShows))
}
ns := noShows[0].(map[string]interface{})
if ns["booking_id"] != bookingID {
t.Errorf("expected forgiven_no_show booking_id %q, got %v", bookingID, ns["booking_id"])
}
}
// ============================================================
// AnonymizeStaleGuestAccounts Tests (SQL behavior)
// ============================================================
func TestAnonymizeStaleGuestAccounts_ScrubsAdditionalFields(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
guestID, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET
profile_pic_url = 'https://example.com/pic.jpg',
referral_code = 'ABCDEF123456',
notes = 'Some personal notes about this guest',
data_retention_consent = TRUE
WHERE id = $1
`, guestID)
if err != nil {
t.Fatalf("failed to update guest fields: %v", err)
}
staleTime := clock.Now().Add(-7 * 30 * 24 * time.Hour)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'completed')
`, guestID, staleTime)
if err != nil {
t.Fatalf("failed to create stale booking: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
email = 'anon-' || id || '@anon.invalid',
phone = '000000000000',
date_of_birth = '1900-01-01',
profile_pic_url = NULL,
referral_code = NULL,
notes = NULL,
data_retention_consent = FALSE,
updated_at = NOW()
WHERE account_role = 'guest'
AND id NOT IN (
SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed')
)
AND id IN (
SELECT user_id FROM bookings WHERE user_id IS NOT NULL
GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months'
)
`)
if err != nil {
t.Fatalf("stale guest anonymization SQL failed: %v", err)
}
var profilePicURL, referralCode, notes interface{}
var dataRetentionConsent bool
err = tx.QueryRow(ctx, `
SELECT profile_pic_url, referral_code, notes, data_retention_consent
FROM users WHERE id = $1
`, guestID).Scan(&profilePicURL, &referralCode, &notes, &dataRetentionConsent)
if err != nil {
t.Fatalf("failed to query guest user after anonymization: %v", err)
}
if profilePicURL != nil {
t.Errorf("expected profile_pic_url to be NULL, got %v", profilePicURL)
}
if referralCode != nil {
t.Errorf("expected referral_code to be NULL, got %v", referralCode)
}
if notes != nil {
t.Errorf("expected notes to be NULL, got %v", notes)
}
if dataRetentionConsent != false {
t.Errorf("expected data_retention_consent to be FALSE, got %v", dataRetentionConsent)
}
}
func TestAnonymizeStaleGuestAccounts_DoesNotAffectActiveGuests(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
guestID, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET
profile_pic_url = 'https://example.com/pic.jpg',
referral_code = 'ABCDEF123456',
data_retention_consent = TRUE
WHERE id = $1
`, guestID)
if err != nil {
t.Fatalf("failed to update guest fields: %v", err)
}
recentTime := clock.Now().Add(24 * time.Hour)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, $2, 'pending')
`, guestID, recentTime)
if err != nil {
t.Fatalf("failed to create recent booking: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
email = 'anon-' || id || '@anon.invalid',
phone = '000000000000',
date_of_birth = '1900-01-01',
profile_pic_url = NULL,
referral_code = NULL,
notes = NULL,
data_retention_consent = FALSE,
updated_at = NOW()
WHERE account_role = 'guest'
AND id NOT IN (
SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed')
)
AND id IN (
SELECT user_id FROM bookings WHERE user_id IS NOT NULL
GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months'
)
`)
if err != nil {
t.Fatalf("stale guest anonymization SQL failed: %v", err)
}
var profilePicURL interface{}
var referralCode string
var dataRetentionConsent bool
err = tx.QueryRow(ctx, `
SELECT profile_pic_url, referral_code, data_retention_consent
FROM users WHERE id = $1
`, guestID).Scan(&profilePicURL, &referralCode, &dataRetentionConsent)
if err != nil {
t.Fatalf("failed to query guest user: %v", err)
}
if profilePicURL == nil {
t.Error("expected profile_pic_url to be preserved for active guest")
}
if referralCode != "ABCDEF123456" {
t.Errorf("expected referral_code to be preserved, got %v", referralCode)
}
if dataRetentionConsent != true {
t.Errorf("expected data_retention_consent to be TRUE, got %v", dataRetentionConsent)
}
}
func TestAnonymizeStaleGuestAccounts_DoesNotAffectRegisteredUsers(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
profile_pic_url = 'https://example.com/pic.jpg',
referral_code = 'ABCDEF123456',
data_retention_consent = TRUE
WHERE id = $1
`, userID)
if err != nil {
t.Fatalf("failed to update user fields: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
email = 'anon-' || id || '@anon.invalid',
phone = '000000000000',
date_of_birth = '1900-01-01',
profile_pic_url = NULL,
referral_code = NULL,
notes = NULL,
data_retention_consent = FALSE,
updated_at = NOW()
WHERE account_role = 'guest'
AND id NOT IN (
SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed')
)
AND id IN (
SELECT user_id FROM bookings WHERE user_id IS NOT NULL
GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months'
)
`)
if err != nil {
t.Fatalf("stale guest anonymization SQL failed: %v", err)
}
var profilePicURL interface{}
var referralCode string
err = tx.QueryRow(ctx, `
SELECT profile_pic_url, referral_code
FROM users WHERE id = $1
`, userID).Scan(&profilePicURL, &referralCode)
if err != nil {
t.Fatalf("failed to query user: %v", err)
}
if profilePicURL == nil {
t.Error("expected profile_pic_url to be preserved for registered user")
}
if referralCode != "ABCDEF123456" {
t.Errorf("expected referral_code to be preserved, got %v", referralCode)
}
}
// ============================================================
// CleanupGDPRExportCache Tests
// ============================================================
func TestCleanupGDPRExportCache_Empty(t *testing.T) {
t.Parallel()
gdprExportCacheMu.Lock()
gdprExportCache = make(map[string]*gdprCacheEntry)
gdprExportCacheMu.Unlock()
_, err := CleanupGDPRExportCache(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
func TestCleanupGDPRExportCache_RemovesExpired(t *testing.T) {
t.Parallel()
gdprExportCacheMu.Lock()
gdprExportCache = map[string]*gdprCacheEntry{
"user-expired": {expiresAt: clock.Now().Add(-1 * time.Hour)},
}
gdprExportCacheMu.Unlock()
_, err := CleanupGDPRExportCache(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
gdprExportCacheMu.RLock()
_, exists := gdprExportCache["user-expired"]
gdprExportCacheMu.RUnlock()
if exists {
t.Error("expected expired entry to be removed")
}
}
func TestCleanupGDPRExportCache_PreservesValid(t *testing.T) {
t.Parallel()
gdprExportCacheMu.Lock()
gdprExportCache = map[string]*gdprCacheEntry{
"user-valid": {expiresAt: clock.Now().Add(1 * time.Hour)},
}
gdprExportCacheMu.Unlock()
_, err := CleanupGDPRExportCache(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
gdprExportCacheMu.RLock()
entry, exists := gdprExportCache["user-valid"]
gdprExportCacheMu.RUnlock()
if !exists {
t.Fatal("expected valid entry to be preserved")
}
if entry == nil {
t.Error("expected non-nil entry")
}
}
func TestCleanupGDPRExportCache_Mixed(t *testing.T) {
t.Parallel()
gdprExportCacheMu.Lock()
gdprExportCache = map[string]*gdprCacheEntry{
"user-expired": {expiresAt: clock.Now().Add(-2 * time.Hour)},
"user-valid": {expiresAt: clock.Now().Add(2 * time.Hour)},
"user-expired2": {expiresAt: clock.Now().Add(-30 * time.Minute)},
}
gdprExportCacheMu.Unlock()
_, err := CleanupGDPRExportCache(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
gdprExportCacheMu.RLock()
_, expiredExists := gdprExportCache["user-expired"]
_, validExists := gdprExportCache["user-valid"]
_, expired2Exists := gdprExportCache["user-expired2"]
gdprExportCacheMu.RUnlock()
if expiredExists {
t.Error("expected user-expired to be removed")
}
if expired2Exists {
t.Error("expected user-expired2 to be removed")
}
if !validExists {
t.Error("expected user-valid to be preserved")
}
}