Files
Crussell/backend/handlers/user/gdpr_test.go
T
popertotsandSisyphus e86248b27c refactor: move auth, GDPR, and booking cleanup to centralized scheduler
Convert CleanupRevokedJTIs to return (int, error) and remove StartJTICleanup goroutine. Add CleanupStaleLoginEntries and CleanupGDPRExportCache for centralized scheduler. Add clock.London timezone location.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-07 00:10:00 +01:00

1314 lines
37 KiB
Go

//go:build test
// +build test
package user
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/testutils"
"crussell/mw"
"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 interface{}
var last4 string
var expMonth, expYear int
err = tx.QueryRow(ctx, `
SELECT deleted_at, last_4, fingerprint, exp_month, exp_year
FROM user_saved_cards WHERE user_id = $1
`, userID).Scan(&deletedAt, &last4, &fingerprint, &expMonth, &expYear)
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 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 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)
}
}
func TestAnonymizeUser_ScrubsEditRequestNotes(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)
}
var notes interface{}
err = tx.QueryRow(ctx, `
SELECT notes FROM booking_edit_requests WHERE requested_by = $1
`, userID).Scan(&notes)
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)
}
}
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)
}
}
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)
}
}
// ============================================================
// 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.0" {
t.Errorf("expected format_version '1.0', 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")
}
}