7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
1026 lines
39 KiB
Go
1026 lines
39 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"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"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 := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
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 := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestDeleteAccount_WrongPassword_Rejected verifies the finding-3 password
|
|
// re-verification: deleting an account with the WRONG current password returns
|
|
// 401 and the account survives.
|
|
func TestDeleteAccount_WrongPassword_Rejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
req := deleteAccountRequest(t, ctx, userID, "not-the-password", "")
|
|
rr := httptest.NewRecorder()
|
|
DeleteAccountHandler(rr, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, rr.Code, rr.Body.String())
|
|
|
|
var firstName string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName))
|
|
require.Equal(t, "Test", firstName, "the account must not be touched by a failed re-verification")
|
|
}
|
|
|
|
// TestDeleteAccount_MissingPasswordBody_Rejected verifies that a DELETE with no
|
|
// password body (the pre-finding-3 client contract) is rejected as malformed.
|
|
func TestDeleteAccount_MissingPasswordBody_Rejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, 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)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// TestDeleteAccount_Enforced2FA_RequiresCode verifies the finding-3 2FA
|
|
// re-verification: in an enforced environment, a 2FA-enabled user must present
|
|
// a correct one-time code (and their password) — a wrong code is rejected with
|
|
// 400 and the account survives; the correct code deletes it.
|
|
func TestDeleteAccount_Enforced2FA_RequiresCode(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
|
|
require.NoError(t, err)
|
|
|
|
// Enforced by default in tests (SQUARE_ENVIRONMENT unset) + 2FA enabled →
|
|
// the handler demands a code. A wrong code must 400.
|
|
seedPendingTwoFA(t, ctx, tx, userID, "424242")
|
|
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "000000")
|
|
rr := httptest.NewRecorder()
|
|
DeleteAccountHandler(rr, req)
|
|
require.Equal(t, http.StatusBadRequest, rr.Code, "wrong 2FA code must be rejected before deletion")
|
|
require.Contains(t, rr.Body.String(), "incorrect verification code")
|
|
|
|
var firstName string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName))
|
|
require.Equal(t, "Test", firstName, "the account must survive a wrong 2FA code")
|
|
|
|
// A missing code must also be rejected.
|
|
req = deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
rr = httptest.NewRecorder()
|
|
DeleteAccountHandler(rr, req)
|
|
require.Equal(t, http.StatusBadRequest, rr.Code, "a missing 2FA code must be rejected in an enforced environment")
|
|
|
|
// The correct code (password + code) deletes the account.
|
|
req = deleteAccountRequest(t, ctx, userID, "testpassword123", "424242")
|
|
rr = httptest.NewRecorder()
|
|
DeleteAccountHandler(rr, req)
|
|
require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
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 := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
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 := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
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 := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
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
|
|
// =============================================================================
|
|
|
|
// failingTx wraps a real pgx.Tx and injects failures on configured operations,
|
|
// mirroring the db package's FailingTx (backend/db/error_injector_test.go);
|
|
// the db injector is test-only and not importable here. Begin returns self so
|
|
// db.Conn.Begin()'s nested tx stays on the wrapper and its Exec can fail at the
|
|
// delete_guest_user step, while the pre-tx QueryRow/Query delegate to the real
|
|
// tx.
|
|
type failingTx struct {
|
|
pgx.Tx
|
|
failExec bool
|
|
}
|
|
|
|
func (f *failingTx) Begin(ctx context.Context) (pgx.Tx, error) {
|
|
return f, nil
|
|
}
|
|
|
|
func (f *failingTx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
|
if f.failExec {
|
|
return pgconn.CommandTag{}, errors.New("simulated exec failure")
|
|
}
|
|
return f.Tx.Exec(ctx, sql, args...)
|
|
}
|
|
|
|
// TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure verifies the fix that
|
|
// dispatches the Square cleanup goroutine only AFTER the local
|
|
// anonymization/deletion transaction commits: when the local tx fails,
|
|
// DeleteCardOnFile/DeleteCustomer must NOT be called — a failed local tx leaves
|
|
// external state intact for retry.
|
|
//
|
|
// The tx failure is forced with the failingTx proxy instead of a seeded FK
|
|
// violation because delete_guest_user now NULLs every RESTRICT-FK user
|
|
// reference (admin_notifications.user_id, gift_card_transactions.user_id,
|
|
// gift_cards.redeemed_by/created_by, admin_audit_log.target_user_id) before
|
|
// DELETE FROM users, so the historical admin_notifications mechanism no longer
|
|
// errors. The proxy fails Exec precisely at `SELECT delete_guest_user($1)` —
|
|
// AFTER the pre-tx snapshot captured the card/customer — so the handler 500s
|
|
// with cleanup never dispatched.
|
|
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)
|
|
|
|
pgxTx, ok := tx.(pgx.Tx)
|
|
require.True(t, ok, "SetupTestTx must return a pgx.Tx")
|
|
reqCtx := db.ContextWithTx(context.Background(), &failingTx{Tx: pgxTx, failExec: true})
|
|
|
|
handler := http.HandlerFunc(DeleteAccountHandler)
|
|
req := deleteAccountRequest(t, reqCtx, guestID, "testpassword123", "")
|
|
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 := deleteAccountRequest(t, context.Background(), userA, "testpassword123", "")
|
|
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")
|
|
}
|
|
|
|
// =============================================================================
|
|
// DeleteAccountHandler — process-local Square customer cache invalidation
|
|
// =============================================================================
|
|
|
|
// TestDeleteAccount_InvalidatesSquareCustomerCache verifies the GDPR erasure
|
|
// flow drops the user's process-local Square customer cache entry: after
|
|
// DeleteAccountHandler anonymizes the account (NULLing square_customer_id on
|
|
// saved cards and deleting the customer at Square), a later save-card flow for
|
|
// the same (anonymized) user must re-mint a fresh Square customer instead of
|
|
// reusing the deleted one's stale cached id.
|
|
func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) {
|
|
// Use a prod-safe in-memory Square stand-in whose delete methods always
|
|
// succeed. The handler fires an ASYNC goroutine for Square erasure; if a
|
|
// delete fails it raises a critical notification through notifyCtx, which
|
|
// in tests carries the request transaction — writing to the SAME pgx.Tx the
|
|
// test below reads, racing it (pgx LRUCache/PgConn). Deterministic success
|
|
// keeps the goroutine on its own background pool context, so the test's tx
|
|
// is never touched concurrently.
|
|
savedSquareClient := payments.SquareClient
|
|
payments.SquareClient = testutils.NewTestSquareClient()
|
|
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
|
|
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
// A saved card gives the provisioning path a persistence point.
|
|
_, 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, 'ccof:cache_erasure_card', 'Visa', '4242', 12, 2030, 'fp1', true)
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(userID) })
|
|
|
|
svc := payments.NewPaymentService()
|
|
originalID, err := svc.EnsureSquareCustomer(ctx, userID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, originalID, "a Square customer must be provisioned and cached for the save-card user")
|
|
|
|
handler := http.HandlerFunc(DeleteAccountHandler)
|
|
req := deleteAccountRequest(t, ctx, userID, "testpassword123", "")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusNoContent, w.Code)
|
|
|
|
// The account is anonymized (email → anon-{id}@anon.invalid) and
|
|
// square_customer_id is NULLed; the handler must also have invalidated the
|
|
// process-local cache. A subsequent ensureSquareCustomer therefore
|
|
// re-queries the NULLed DB and mints a fresh customer from the anonymized
|
|
// email — a different id. Without the invalidation it would return the
|
|
// stale originalID, resurrecting the erased identity in memory.
|
|
reprovisioned, err := svc.EnsureSquareCustomer(ctx, userID)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, originalID, reprovisioned, "an erased user must not reuse the deleted Square customer id from the cache")
|
|
}
|