Files
Crussell/backend/handlers/user/user_coverage_test.go
T
popertots f3d50f6990 Complete Square GDPR erasure: customer deletion and stale-guest scrub
Account deletion now snapshots card and customer IDs before the local anonymize transaction and dispatches the Square cleanup goroutine only after the tx commits, deleting each distinct Square customer once and skipping any customer still referenced by another user's card. AnonymizeStaleGuestAccounts also disables cards and deletes the guest's Square customer profile (PII) before NULLing references locally. Token redaction applied to all error logs.
2026-08-22 00:34:49 +01:00

878 lines
32 KiB
Go

//go:build test && dev
package user
// Package user contains coverage-improving tests for user profile,
// account management, guest creation, and admin handlers.
//
// These tests focus on error paths and edge cases not covered by the
// existing test suite, such as unauthorized access, not-found scenarios,
// invalid JSON bodies, and duplicate email conflicts.
//
// NOTE: Do NOT use t.Parallel() in this file.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// =============================================================================
// DeleteAccountHandler Coverage Tests
// =============================================================================
// TestDeleteAccount_Unauthorized verifies that deleting an account without
// setting user ID in context returns 401 Unauthorized.
func TestDeleteAccount_Unauthorized(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestDeleteAccount_NotFound verifies that deleting a non-existent user
// (valid userID format in context but no matching DB row) returns 404.
func TestDeleteAccount_NotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, "nonexistent123"))
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestDeleteAccount_WithBooking verifies that deleting a registered user
// account with existing bookings succeeds (anonymize_user handles FK).
func TestDeleteAccount_WithBooking(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)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
if rr.Code != http.StatusNoContent {
t.Errorf("expected 204, got %d. body: %s", rr.Code, rr.Body.String())
}
// Verify user was anonymized
var firstName, accountRole string
err = tx.QueryRow(ctx, `SELECT n_first_name, account_role FROM users WHERE id = $1`, userID).Scan(&firstName, &accountRole)
if err != nil {
t.Fatalf("failed to query anonymized user: %v", err)
}
if firstName != "Deleted" {
t.Errorf("expected first name 'Deleted', got %q", firstName)
}
if accountRole != "guest" {
t.Errorf("expected account_role 'guest', got %q", accountRole)
}
}
// TestDeleteAccount_GuestWithBooking verifies that deleting a guest user
// with existing bookings succeeds (delete_guest_user handles FK).
func TestDeleteAccount_GuestWithBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestGuestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
_, err = fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
if rr.Code != http.StatusNoContent {
t.Errorf("expected 204, got %d. body: %s", rr.Code, rr.Body.String())
}
// Verify guest user was fully deleted
var count int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to query user count: %v", err)
}
if count != 0 {
t.Errorf("expected user to be deleted, found %d rows", count)
}
}
// =============================================================================
// ChangePasswordHandler Coverage Tests
// =============================================================================
// TestPasswordChange_Unauthorized verifies that changing password without
// user ID in context returns 401.
func TestPasswordChange_Unauthorized(t *testing.T) {
changeReq := ChangePasswordRequest{
CurrentPassword: "testpassword123",
NewPassword: "newpassword456",
}
body, _ := json.Marshal(changeReq)
req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
ChangePasswordHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestPasswordChange_InvalidJSON verifies that sending an invalid JSON body
// returns 400 Bad Request.
func TestPasswordChange_InvalidJSON(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)
}
req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader([]byte("not valid json")))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
ChangePasswordHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestPasswordChange_UserNotFound verifies that changing password for a
// non-existent user ID returns 404.
func TestPasswordChange_UserNotFound(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
changeReq := ChangePasswordRequest{
CurrentPassword: "testpassword123",
NewPassword: "newpassword456",
}
body, _ := json.Marshal(changeReq)
req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, "nonexistent123"))
rr := httptest.NewRecorder()
ChangePasswordHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// =============================================================================
// Notification Preferences Coverage Tests
// =============================================================================
// TestNotificationPreferences_Get_Unauthorized verifies that getting
// notification preferences without auth returns 401.
func TestNotificationPreferences_Get_Unauthorized(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil)
rr := httptest.NewRecorder()
GetNotificationPreferencesHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
// TestNotificationPreferences_Update_Unauthorized verifies that updating
// notification preferences without auth returns 401.
func TestNotificationPreferences_Update_Unauthorized(t *testing.T) {
body, _ := json.Marshal(UpdateNotificationPreferencesRequest{})
req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
UpdateNotificationPreferencesHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestNotificationPreferences_Update_InvalidJSON verifies that sending
// invalid JSON to update notification preferences returns 400.
func TestNotificationPreferences_Update_InvalidJSON(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)
}
req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader([]byte("{")))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
UpdateNotificationPreferencesHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// =============================================================================
// UpdateProfileHandler Coverage Tests
// =============================================================================
// TestProfile_Update_InvalidJSON verifies that sending an invalid JSON body
// to update profile returns 400.
func TestProfile_Update_InvalidJSON(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)
}
req := httptest.NewRequest(http.MethodPut, "/api/user/profile", bytes.NewReader([]byte("not json")))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
rr := httptest.NewRecorder()
UpdateProfileHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// =============================================================================
// Guest User Coverage Tests
// =============================================================================
// TestGuestUser_Create_DuplicateEmail verifies that creating a guest user
// with an email already registered by a non-guest user returns 409 Conflict.
func TestGuestUser_Create_DuplicateEmail(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create a registered (non-guest) user with a known email
_, err := fixtures.CreateTestUserWithEmail(tx, "registered.dup@example.com", "verified_email")
if err != nil {
t.Fatalf("failed to create registered user: %v", err)
}
// Try to create a guest with the same email
reqBody := CreateGuestUserRequest{
FirstName: "Guest",
LastName: "User",
Email: "registered.dup@example.com",
Phone: "07123456789",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusConflict {
t.Errorf("expected 409, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestGuestUser_Create_InvalidJSON verifies that sending invalid JSON
// returns 400 Bad Request.
func TestGuestUser_Create_InvalidJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader([]byte("{")))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestGuestUser_Create_InvalidNameCharacters verifies that invalid characters
// in name (e.g., numbers in first name) return 400.
func TestGuestUser_Create_InvalidNameCharacters(t *testing.T) {
reqBody := CreateGuestUserRequest{
FirstName: "John123",
LastName: "Doe",
Email: "john.doe@example.com",
Phone: "07123456789",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateGuestUserHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// =============================================================================
// Admin Handler Coverage Tests
// =============================================================================
// TestGetEligiblePatchTestServices_InvalidUserID verifies that an invalid
// user ID format returns 404.
func TestGetEligiblePatchTestServices_InvalidUserID(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(GetEligiblePatchTestServicesHandler)
w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/invalid/patch-tests/eligible", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestGetUserPatchTests_NoMatchUser verifies that requesting patch tests for
// a valid-format but non-existent user returns an empty list (200 OK).
func TestGetUserPatchTests_NoMatchUser(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
// Use a valid 12-char hex ID that doesn't exist in the DB
w := makePatchTestsRequest(GetUserPatchTestsHandler, "GET", "/api/admin/users/aaaa00000000/patch-tests", nil, "admin001", "admin", ctx)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
return
}
var tests []UserPatchTest
if err := json.Unmarshal(w.Body.Bytes(), &tests); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
}
// TestGetEligiblePatchTestServices_NoMatchUser verifies that eligible patch
// tests for a valid-format but non-existent user returns an empty list.
func TestGetEligiblePatchTestServices_NoMatchUser(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(GetEligiblePatchTestServicesHandler)
w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users/bbbb00000000/patch-tests/eligible", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
return
}
var services []ServiceForPatchTest
if err := json.Unmarshal(w.Body.Bytes(), &services); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
}
// TestAddPatchTest_InvalidUserID verifies that adding a patch test with an
// invalid user ID returns 404.
func TestAddPatchTest_InvalidUserID(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(AddPatchTestHandler)
reqBody := AddPatchTestRequest{PatchTestID: "testid1234567"}
w := makeAdminHandlerRequest(handler, "POST", "/api/admin/users/invalid/patch-tests", reqBody, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestAddPatchTest_EmptyPatchTestID verifies that adding a patch test with
// an empty patch_test_id returns 400.
func TestAddPatchTest_EmptyPatchTestID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
handler := http.HandlerFunc(AddPatchTestHandler)
reqBody := AddPatchTestRequest{PatchTestID: ""}
w := makeAdminHandlerRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAddPatchTest_InvalidJSON(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
handler := http.HandlerFunc(AddPatchTestHandler)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
chiCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
chiCtx = context.WithValue(chiCtx, mw.UserIDKey, "admin-test-id")
chiCtx = context.WithValue(chiCtx, mw.UserRoleKey, "admin")
req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+userID+"/patch-tests", bytes.NewReader([]byte("not json")))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(chiCtx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d. body: %s", rr.Code, rr.Body.String())
}
}
// TestListAdminUsers_Search verifies that the list admin users handler
// works with a search query parameter.
func TestListAdminUsers_Search(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create a user with a distinct name
_, err := tx.Exec(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Searchable', 'User', 'searchable@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
`)
if err != nil {
t.Fatalf("failed to create searchable user: %v", err)
}
handler := http.HandlerFunc(ListAdminUsersHandler)
w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?q=Searchable", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var response UserListResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.Total < 1 {
t.Errorf("expected at least 1 user in search results, got %d", response.Total)
}
}
// TestListAdminUsers_SearchWithCursor verifies that the list admin users
// handler works with search + cursor pagination.
func TestListAdminUsers_SearchWithCursor(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// Create some users so cursor pagination has data
for i := 0; i < 3; i++ {
_, err := tx.Exec(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Cursor', $1, $2, '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
`, "TestUser_"+string(rune('A'+i)), "cursor.user."+string(rune('A'+i))+"@test.com")
if err != nil {
t.Fatalf("failed to create cursor test user: %v", err)
}
}
// First request without cursor
handler := http.HandlerFunc(ListAdminUsersHandler)
w := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?q=Cursor&per_page=2", nil, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var firstPage UserListResponse
if err := json.Unmarshal(w.Body.Bytes(), &firstPage); err != nil {
t.Fatalf("failed to unmarshal first page: %v", err)
}
// If we have a next cursor, use it
if firstPage.NextCursor != nil {
w2 := makeAdminHandlerRequest(handler, "GET", "/api/admin/users?q=Cursor&per_page=2&cursor="+*firstPage.NextCursor, nil, ctx)
if w2.Code != http.StatusOK {
t.Errorf("expected 200 for cursor page, got %d. body: %s", w2.Code, w2.Body.String())
}
}
}
// =============================================================================
// DeleteAccountHandler — S3 goroutine coverage
// =============================================================================
// TestDeleteAccount_WithProfilePicture verifies that the S3 profile picture
// deletion goroutine is triggered when profile_pic_url is set.
func TestDeleteAccount_WithProfilePicture(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// Set profile_pic_url on the user to trigger S3 deletion goroutine
_, err = tx.Exec(ctx, `UPDATE users SET profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, userID)
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest("DELETE", "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Handler returns 204 regardless of goroutine result
assert.Equal(t, http.StatusNoContent, w.Code)
// Best-effort wait for background goroutine to initiate
time.Sleep(50 * time.Millisecond)
}
// =============================================================================
// DeleteAccountHandler — Square goroutine coverage
// =============================================================================
// TestDeleteAccount_WithSquareClient verifies that the Square saved card
// cleanup goroutine is triggered when SquareClient is set.
func TestDeleteAccount_WithSquareClient(t *testing.T) {
// Save and restore SquareClient
savedSquareClient := payments.SquareClient
payments.SquareClient = square.NewDevClient()
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest("DELETE", "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
// Best-effort wait for background goroutine to initiate
time.Sleep(50 * time.Millisecond)
}
// =============================================================================
// DeleteAccountHandler — Square ccof redaction + customer deletion
// =============================================================================
// syncBuffer is a mutex-guarded log/slog writer so logs written by background
// goroutines can be read safely under -race.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// recordingSquareClient records Square erasure calls made by the account
// deletion goroutine so tests can assert what was (and wasn't) called.
type recordingSquareClient struct {
square.SquareClient
mu sync.Mutex
deletedCards []string
deletedCustomers []string
}
func (c *recordingSquareClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
c.mu.Lock()
c.deletedCards = append(c.deletedCards, cardID)
c.mu.Unlock()
// Token-free error so the log-redaction test isolates redaction of the
// cardID argument rather than the error string.
return fmt.Errorf("square: network error disabling card at Square")
}
func (c *recordingSquareClient) DeleteCustomer(ctx context.Context, customerID string) error {
c.mu.Lock()
c.deletedCustomers = append(c.deletedCustomers, customerID)
c.mu.Unlock()
return nil
}
func (c *recordingSquareClient) cardDeletes() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deletedCards...)
}
func (c *recordingSquareClient) customerDeletes() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deletedCustomers...)
}
// TestDeleteAccount_LogsRedactCardTokens verifies the account-deletion
// goroutine logs a redacted tokenPrefix form of ccof: card IDs, never the full
// token (SECURITY: full ccof tokens must not reach server logs).
func TestDeleteAccount_LogsRedactCardTokens(t *testing.T) {
savedSquareClient := payments.SquareClient
rec := &recordingSquareClient{SquareClient: square.NewDevClient()}
payments.SquareClient = rec
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
var sb syncBuffer
log.SetOutput(&sb)
defer log.SetOutput(os.Stderr)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
fullToken := "ccof:secret_token_abc123"
_, err = tx.Exec(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, $2, 'cus_secret_abc123', 'Visa', '4242', 12, 2030, 'fp1', true)
`, userID, fullToken)
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
require.Eventually(t, func() bool {
return len(rec.cardDeletes()) > 0
}, 5*time.Second, 10*time.Millisecond, "expected the Square cleanup goroutine to attempt card deletion")
// The card-deletion log is written by the async goroutine AFTER the call
// returns — wait for the redacted prefix to appear instead of racing the
// goroutine (the cleanup now dispatches after the local tx commits).
require.Eventually(t, func() bool {
return strings.Contains(sb.String(), "ccof:sec...")
}, 5*time.Second, 10*time.Millisecond, "expected the redacted token prefix to reach the logs")
logs := sb.String()
if strings.Contains(logs, fullToken) {
t.Errorf("full ccof token %q leaked into logs: %q", fullToken, logs)
}
}
// TestDeleteAccount_DeletesSquareCustomerOnce verifies the Square customer
// profile (email/name PII) is deleted exactly once even when multiple saved
// cards share the same square_customer_id.
func TestDeleteAccount_DeletesSquareCustomerOnce(t *testing.T) {
savedSquareClient := payments.SquareClient
rec := &recordingSquareClient{SquareClient: square.NewDevClient()}
payments.SquareClient = rec
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
for _, cardTok := range []string{"ccof:card_one_123", "ccof:card_two_456"} {
_, err := tx.Exec(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, $2, 'cus_shared_123', 'Visa', '4242', 12, 2030, 'fp1', true)
`, userID, cardTok)
require.NoError(t, err)
}
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
require.Eventually(t, func() bool {
return len(rec.customerDeletes()) > 0
}, 5*time.Second, 10*time.Millisecond, "expected DeleteCustomer to be called")
require.Equal(t, []string{"cus_shared_123"}, rec.customerDeletes(), "a shared Square customer must be deleted exactly once")
require.ElementsMatch(t, []string{"ccof:card_one_123", "ccof:card_two_456"}, rec.cardDeletes(), "both saved cards must be disabled at Square")
}
// =============================================================================
// DeleteAccountHandler — Square cleanup only after local tx commit
// =============================================================================
// TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure verifies the fix that
// dispatches the Square cleanup goroutine only AFTER the local
// anonymization/deletion transaction commits: when the local tx fails (here an
// admin_notifications row references the guest with a RESTRICT FK, so
// delete_guest_user's DELETE FROM users errors), DeleteCardOnFile/DeleteCustomer
// must NOT be called — a failed local tx leaves external state intact for retry.
func TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure(t *testing.T) {
savedSquareClient := payments.SquareClient
rec := &recordingSquareClient{SquareClient: square.NewDevClient()}
payments.SquareClient = rec
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t)
guestID, err := fixtures.CreateTestGuestUser(tx)
require.NoError(t, err)
// Saved card so the pre-tx snapshot captures a card/customer to scrub — if
// the goroutine were (wrongly) dispatched before the tx, it would record a
// call here.
_, err = tx.Exec(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, $2, 'cus_fail_tx_123', 'Visa', '4242', 12, 2030, 'fp1', true)
`, guestID, "ccof:card_fail_tx")
require.NoError(t, err)
// admin_notifications.user_id has a RESTRICT FK on users(id): seeding a row
// makes delete_guest_user's DELETE FROM users fail, so the local tx errors.
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id)
VALUES ('pending_booking', $1)
`, guestID)
require.NoError(t, err)
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, guestID))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code, "the failed local tx must surface a 500")
// Give a (wrongly dispatched) cleanup goroutine time to record its calls.
time.Sleep(100 * time.Millisecond)
require.Equal(t, 0, len(rec.cardDeletes()), "DeleteCardOnFile must NOT be called when the local tx failed")
require.Equal(t, 0, len(rec.customerDeletes()), "DeleteCustomer must NOT be called when the local tx failed")
}
// =============================================================================
// DeleteAccountHandler — shared Square customer edge (cross-user email dedup)
// =============================================================================
// TestDeleteAccount_SkipsSharedSquareCustomer verifies the fix that guards
// DeleteCustomer behind a "still referenced by another account" check: Square
// customers are provisioned per-user from a deterministic email-derived key,
// but the UNIQUE(email) index excludes guest accounts, so a guest and a
// registered user sharing an email can land on the SAME Square customer profile
// (Square dedups within its idempotency window). Deleting one account must NOT
// delete the shared profile — the other account's saved-card charges would
// break. The other user's saved-card row must be left untouched.
func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) {
savedSquareClient := payments.SquareClient
rec := &recordingSquareClient{SquareClient: square.NewDevClient()}
payments.SquareClient = rec
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t)
userA, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
userB, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
for _, tc := range []struct{ userID, cardID string }{
{userA, "ccof:card_user_a"},
{userB, "ccof:card_user_b"},
} {
_, err := tx.Exec(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, $2, 'cus_shared_cross_user', 'Visa', '4242', 12, 2030, 'fp1', true)
`, tc.userID, tc.cardID)
require.NoError(t, err)
}
// Commit the setup so the cleanup goroutine's pool-level reference check
// (background context, outside the per-test tx) can see user B's row.
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx, "no transaction in context")
require.NoError(t, pgxTx.Commit(ctx))
// The committed rows live in the SHARED test pool — clean them up.
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE square_customer_id = 'cus_shared_cross_user'`)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1, $2)`, userA, userB)
})
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userA))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
// The goroutine runs the card loop first — wait for it to record user A's
// card deletion so we know the cleanup ran, then assert the shared customer
// was NOT deleted.
require.Eventually(t, func() bool {
return len(rec.cardDeletes()) > 0
}, 5*time.Second, 10*time.Millisecond, "expected the Square cleanup goroutine to attempt card deletion")
require.Never(t, func() bool {
return len(rec.customerDeletes()) > 0
}, 500*time.Millisecond, 10*time.Millisecond, "DeleteCustomer must NOT be called for a Square customer still referenced by another account")
// User B's saved card is untouched (still references the shared customer).
var bUserID string
var bCustomerID sql.NullString
var bDeletedAt sql.NullTime
err = db.Conn.QueryRow(context.Background(), `
SELECT user_id, square_customer_id, deleted_at FROM user_saved_cards WHERE square_card_id = 'ccof:card_user_b'
`).Scan(&bUserID, &bCustomerID, &bDeletedAt)
require.NoError(t, err)
require.Equal(t, userB, bUserID)
require.True(t, bCustomerID.Valid && bCustomerID.String == "cus_shared_cross_user", "user B's row must keep the shared square_customer_id")
require.False(t, bDeletedAt.Valid, "user B's row must not be soft-deleted")
}