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.
This commit is contained in:
@@ -2,6 +2,7 @@ package scheduling
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/handlers/payments"
|
||||||
|
"crussell/internal/square"
|
||||||
"crussell/internal/validators"
|
"crussell/internal/validators"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
|
|
||||||
@@ -413,6 +416,80 @@ func CleanupOldReservations(ctx context.Context) (int, error) {
|
|||||||
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
|
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
|
||||||
// Active/pending bookings are excluded so the salon can still contact the guest.
|
// Active/pending bookings are excluded so the salon can still contact the guest.
|
||||||
func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
|
func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
|
||||||
|
// Best-effort: disable stale-guests' saved cards at Square BEFORE the SQL
|
||||||
|
// below NULLs square_card_id, so those cards can't keep accepting ccof:
|
||||||
|
// charges after anonymization (GDPR erasure completeness). A Square failure
|
||||||
|
// is logged and ignored — the local anonymization must never be blocked by
|
||||||
|
// Square. Card IDs are selected with the same stale-guest predicate the
|
||||||
|
// users UPDATE uses, and only when a Square client is configured.
|
||||||
|
if payments.SquareClient != nil {
|
||||||
|
// Snapshot the stale-guests' saved cards AND their Square customer IDs
|
||||||
|
// BEFORE the SQL below NULLs square_card_id/square_customer_id, so the
|
||||||
|
// external Square references are still available for cleanup (GDPR
|
||||||
|
// erasure completeness: the local scrub must never strand PII at
|
||||||
|
// Square). Best-effort: a Square failure is logged and ignored — the
|
||||||
|
// local anonymization must never be blocked by Square. Rows are
|
||||||
|
// selected with the same stale-guest predicate the users UPDATE uses.
|
||||||
|
rows, err := db.Conn.Query(ctx, `
|
||||||
|
SELECT usc.square_card_id, usc.square_customer_id
|
||||||
|
FROM user_saved_cards usc
|
||||||
|
JOIN users u ON u.id = usc.user_id
|
||||||
|
WHERE u.account_role = 'guest'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bookings WHERE user_id = u.id AND status IN ('pending', 'confirmed'))
|
||||||
|
AND EXISTS (SELECT 1 FROM bookings WHERE user_id = u.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months')
|
||||||
|
AND usc.square_card_id IS NOT NULL
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to query stale-guest saved cards for Square cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
var cardIDs []string
|
||||||
|
// Distinct non-null customer IDs only: a guest's saved cards share
|
||||||
|
// one provisioned Square customer, so DeleteCustomer runs once per
|
||||||
|
// customer. NULL customer IDs (guests with no provisioned Square
|
||||||
|
// customer) are skipped.
|
||||||
|
customerSeen := map[string]bool{}
|
||||||
|
var customerIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var cardID, customerID sql.NullString
|
||||||
|
if err := rows.Scan(&cardID, &customerID); err != nil {
|
||||||
|
log.Printf("Warning: Failed to scan stale-guest saved card: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cardID.Valid && cardID.String != "" {
|
||||||
|
cardIDs = append(cardIDs, cardID.String)
|
||||||
|
}
|
||||||
|
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
|
||||||
|
customerSeen[customerID.String] = true
|
||||||
|
customerIDs = append(customerIDs, customerID.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
log.Printf("Warning: Row iteration error querying stale-guest saved cards: %v", err)
|
||||||
|
}
|
||||||
|
for _, cardID := range cardIDs {
|
||||||
|
if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil {
|
||||||
|
// TokenPrefix redacts the ccof: card token — the full ID
|
||||||
|
// must never reach logs.
|
||||||
|
log.Printf("Warning: Failed to disable stale-guest Square card %s at Square: %v", square.TokenPrefix(cardID), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GDPR erasure completeness: the guest's Square customer profile
|
||||||
|
// holds their real name + email PII. Disabling the saved cards and
|
||||||
|
// NULLing square_customer_id locally is NOT enough — the Square
|
||||||
|
// customer profile must be deleted too, or the PII persists at
|
||||||
|
// Square indefinitely after anonymization. Distinct IDs only, so a
|
||||||
|
// guest with multiple cards on one customer triggers one delete.
|
||||||
|
for _, customerID := range customerIDs {
|
||||||
|
if err := payments.SquareClient.DeleteCustomer(ctx, customerID); err != nil {
|
||||||
|
// TokenPrefix redacts the customer ID — the full ID must
|
||||||
|
// never reach logs.
|
||||||
|
log.Printf("Warning: Failed to delete stale-guest Square customer %s at Square: %v", square.TokenPrefix(customerID), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.Conn.Begin(ctx)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
|
"crussell/handlers/payments"
|
||||||
|
"crussell/internal/square"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"crussell/testutils/fixtures"
|
"crussell/testutils/fixtures"
|
||||||
|
|
||||||
@@ -1322,6 +1325,169 @@ func TestAnonymizeStaleGuestAccounts_ScrubsSavedCards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordingDisableClient records every DeleteCardOnFile and DeleteCustomer call
|
||||||
|
// so tests can assert which cards/customers the anonymization cleaned up at
|
||||||
|
// Square.
|
||||||
|
type recordingDisableClient struct {
|
||||||
|
square.SquareClient
|
||||||
|
mu sync.Mutex
|
||||||
|
disabled []string
|
||||||
|
deletedCustomers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingDisableClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.disabled = append(c.disabled, cardID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingDisableClient) DeleteCustomer(ctx context.Context, customerID string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.deletedCustomers = append(c.deletedCustomers, customerID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingDisableClient) disabledIDs() []string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return append([]string(nil), c.disabled...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingDisableClient) deletedCustomerIDs() []string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return append([]string(nil), c.deletedCustomers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymizeStaleGuestAccounts_DisablesCardsAtSquare verifies the
|
||||||
|
// best-effort Square disable: stale-guests' saved cards are disabled at Square
|
||||||
|
// BEFORE square_card_id is NULLed locally (GDPR erasure completeness).
|
||||||
|
// Deliberately NOT t.Parallel: it swaps the package-level payments.SquareClient
|
||||||
|
// and must not overlap the parallel Anonymize tests that read it.
|
||||||
|
func TestAnonymizeStaleGuestAccounts_DisablesCardsAtSquare(t *testing.T) {
|
||||||
|
ctx, tx := resetTestData(t)
|
||||||
|
|
||||||
|
guestID, err := fixtures.CreateTestUser(tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create guest user: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil {
|
||||||
|
t.Fatalf("failed to set guest role: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
||||||
|
`, guestID); err != nil {
|
||||||
|
t.Fatalf("failed to create stale booking: %v", err)
|
||||||
|
}
|
||||||
|
if _, 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, 'ccof:stale_card_123', 'cus_stale_123', 'Visa', '4242', 12, 2030, 'fp1', true)
|
||||||
|
`, guestID); err != nil {
|
||||||
|
t.Fatalf("failed to insert saved card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origSquare := payments.SquareClient
|
||||||
|
rec := &recordingDisableClient{}
|
||||||
|
payments.SquareClient = rec
|
||||||
|
defer func() { payments.SquareClient = origSquare }()
|
||||||
|
|
||||||
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
||||||
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := rec.disabledIDs()
|
||||||
|
if len(got) != 1 || got[0] != "ccof:stale_card_123" {
|
||||||
|
t.Fatalf("expected the stale guest's card to be disabled at Square, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymizeStaleGuestAccounts_DeletesCustomersAtSquare verifies the
|
||||||
|
// best-effort Square customer deletion: stale-guests' Square customer profiles
|
||||||
|
// (real name + email PII) are deleted at Square once per DISTINCT customer ID
|
||||||
|
// BEFORE square_customer_id is NULLed locally (GDPR erasure completeness).
|
||||||
|
// Deliberately NOT t.Parallel: it swaps the package-level payments.SquareClient
|
||||||
|
// and must not overlap the parallel Anonymize tests that read it.
|
||||||
|
func TestAnonymizeStaleGuestAccounts_DeletesCustomersAtSquare(t *testing.T) {
|
||||||
|
ctx, tx := resetTestData(t)
|
||||||
|
|
||||||
|
// Guest A: two saved cards sharing one provisioned Square customer.
|
||||||
|
guestAID, err := fixtures.CreateTestUser(tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create guest A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestAID); err != nil {
|
||||||
|
t.Fatalf("failed to set guest A role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest B: one saved card on a different Square customer.
|
||||||
|
guestBID, err := fixtures.CreateTestUser(tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create guest B: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestBID); err != nil {
|
||||||
|
t.Fatalf("failed to set guest B role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale bookings (> 6 months) for both guests.
|
||||||
|
for _, uid := range []string{guestAID, guestBID} {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||||
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
||||||
|
`, uid); err != nil {
|
||||||
|
t.Fatalf("failed to create stale booking: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest A's two cards share 'cus_stale_a'; guest B's card is 'cus_stale_b'.
|
||||||
|
if _, 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, 'ccof:stale_card_a1', 'cus_stale_a', 'Visa', '4242', 12, 2030, 'fp_a1', true),
|
||||||
|
($1, 'ccof:stale_card_a2', 'cus_stale_a', 'Mastercard', '1111', 12, 2030, 'fp_a2', false)
|
||||||
|
`, guestAID); err != nil {
|
||||||
|
t.Fatalf("failed to insert guest A saved cards: %v", err)
|
||||||
|
}
|
||||||
|
if _, 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, 'ccof:stale_card_b1', 'cus_stale_b', 'Visa', '9999', 12, 2030, 'fp_b1', true)
|
||||||
|
`, guestBID); err != nil {
|
||||||
|
t.Fatalf("failed to insert guest B saved card: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origSquare := payments.SquareClient
|
||||||
|
rec := &recordingDisableClient{}
|
||||||
|
payments.SquareClient = rec
|
||||||
|
defer func() { payments.SquareClient = origSquare }()
|
||||||
|
|
||||||
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
||||||
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := rec.deletedCustomerIDs()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected DeleteCustomer to run once per DISTINCT customer id (2), got %v", got)
|
||||||
|
}
|
||||||
|
gotSet := map[string]bool{}
|
||||||
|
for _, id := range got {
|
||||||
|
if gotSet[id] {
|
||||||
|
t.Errorf("DeleteCustomer called more than once for customer %q: %v", id, got)
|
||||||
|
}
|
||||||
|
gotSet[id] = true
|
||||||
|
}
|
||||||
|
if !gotSet["cus_stale_a"] || !gotSet["cus_stale_b"] {
|
||||||
|
t.Errorf("expected both distinct Square customer ids to be deleted, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The customer delete runs after the card-disable loop, so all cards were
|
||||||
|
// disabled too.
|
||||||
|
disabled := rec.disabledIDs()
|
||||||
|
if len(disabled) != 3 {
|
||||||
|
t.Errorf("expected all 3 stale cards to be disabled, got %v", disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests for CleanupExpiredFinancialRecords ---
|
// --- Tests for CleanupExpiredFinancialRecords ---
|
||||||
|
|
||||||
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
|
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"crussell/handlers/payments"
|
"crussell/handlers/payments"
|
||||||
"crussell/internal/dav"
|
"crussell/internal/dav"
|
||||||
"crussell/internal/s3"
|
"crussell/internal/s3"
|
||||||
|
"crussell/internal/square"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -69,38 +70,45 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}(profilePicURL.String)
|
}(profilePicURL.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payments.SquareClient != nil {
|
// Snapshot the Square card IDs AND customer IDs synchronously BEFORE the
|
||||||
// #nosec G118 — intentional background goroutine for async account deletion
|
// SQL anonymization below NULLs square_card_id/square_customer_id, so the
|
||||||
go func() {
|
// background cleanup still has the external Square references it needs
|
||||||
defer func() {
|
// (previously the goroutine read the rows itself, racing the anonymize
|
||||||
if r := recover(); r != nil {
|
// step which wiped them mid-flight). Distinct non-null customer IDs only:
|
||||||
log.Printf("Panic recovered in Square saved card cleanup: %v", r)
|
// a user's saved cards share one provisioned Square customer, so
|
||||||
}
|
// DeleteCustomer runs once per customer. NULL customer IDs (users with
|
||||||
}()
|
// no saved cards) are skipped.
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
var cardIDs []string
|
||||||
defer cancel()
|
customerSeen := map[string]bool{}
|
||||||
rows, err := db.Conn.Query(ctx,
|
var customerIDs []string
|
||||||
`SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL AND square_card_id IS NOT NULL`, userID)
|
// Capture the client synchronously so the async cleanup goroutine never
|
||||||
|
// reads the global payments.SquareClient (which tests swap per-account).
|
||||||
|
sqClient := payments.SquareClient
|
||||||
|
if sqClient != nil {
|
||||||
|
rows, err := db.Conn.Query(r.Context(),
|
||||||
|
`SELECT square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var cardID string
|
var cardID, customerID sql.NullString
|
||||||
if err := rows.Scan(&cardID); err != nil {
|
if err := rows.Scan(&cardID, &customerID); err != nil {
|
||||||
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
|
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil {
|
if cardID.Valid && cardID.String != "" {
|
||||||
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", cardID, userID, err)
|
cardIDs = append(cardIDs, cardID.String)
|
||||||
|
}
|
||||||
|
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
|
||||||
|
customerSeen[customerID.String] = true
|
||||||
|
customerIDs = append(customerIDs, customerID.String)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
|
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}()
|
rows.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SQL-level anonymization/deletion ---
|
// --- SQL-level anonymization/deletion ---
|
||||||
@@ -159,6 +167,52 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// TODO: Create 'user_anonymized' notification for admin audit trail
|
// TODO: Create 'user_anonymized' notification for admin audit trail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// external Square cleanup fires only after local anonymization/deletion
|
||||||
|
// commits, so a failed local tx leaves external state intact for retry.
|
||||||
|
if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) {
|
||||||
|
// #nosec G118 — intentional background goroutine for async account deletion
|
||||||
|
go func(client square.SquareClient, cards, customers []string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("Panic recovered in Square cleanup: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, cardID := range cards {
|
||||||
|
if err := client.DeleteCardOnFile(ctx, cardID); err != nil {
|
||||||
|
// TokenPrefix redacts the ccof: card token — the full ID must
|
||||||
|
// never reach logs.
|
||||||
|
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, customerID := range customers {
|
||||||
|
// 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
|
||||||
|
// end up on the SAME Square customer profile (Square dedups within
|
||||||
|
// its idempotency window). Deleting it would break the other
|
||||||
|
// account's saved-card charges, so skip the deletion when any OTHER
|
||||||
|
// non-deleted saved card still references the customer.
|
||||||
|
var stillReferenced bool
|
||||||
|
if err := db.Conn.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL)
|
||||||
|
`, customerID, userID).Scan(&stillReferenced); err != nil {
|
||||||
|
log.Printf("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stillReferenced {
|
||||||
|
// PII-redacted customer id — the full id never reaches logs.
|
||||||
|
log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := client.DeleteCustomer(ctx, customerID); err != nil {
|
||||||
|
log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(sqClient, cardIDs, customerIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete CardDAV contact (non-blocking, best-effort)
|
// Delete CardDAV contact (non-blocking, best-effort)
|
||||||
if dav.Service != nil {
|
if dav.Service != nil {
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ package user
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,6 +30,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
"crussell/handlers/payments"
|
"crussell/handlers/payments"
|
||||||
"crussell/internal/square"
|
"crussell/internal/square"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
@@ -594,3 +601,277 @@ func TestDeleteAccount_WithSquareClient(t *testing.T) {
|
|||||||
time.Sleep(50 * time.Millisecond)
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user