diff --git a/internal/adapters/store/account.go b/internal/adapters/store/account.go new file mode 100644 index 0000000..d269e2e --- /dev/null +++ b/internal/adapters/store/account.go @@ -0,0 +1,50 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// DeleteUser permanently removes a user and all of their data. It runs through +// withUser so RLS confines every statement to the calling user's own rows. +// +// Deleting the users row cascades (ON DELETE CASCADE) to videos, transcripts, +// summaries (→ sink_deliveries), video_connections, and the user_identities map +// — referential-integrity cascades bypass RLS, so a user's child rows are removed +// even though the deleting connection is scoped. summary_actions is the exception: +// it carries a user_id but has NO foreign key to users (migration 002), so the +// cascade does not reach it; it is deleted explicitly in the same scoped +// transaction. Deleting an absent user is a no-op (idempotent). +// +// This is tapir-side only (decision 2026-06-03): it removes all tapir data; the +// Dex login identity is left untouched — a later login simply re-enters +// registration. The user's secrets (OAuth tokens) live in the SecretStore, not +// the DB, and are removed by the caller (the account handler). +func (s *Store) DeleteUser(ctx context.Context, userID string) error { + return s.withUser(ctx, userID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, + `DELETE FROM summary_actions WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("store: delete summary_actions: %w", err) + } + if _, err := tx.Exec(ctx, + `DELETE FROM users WHERE id = $1`, userID); err != nil { + return fmt.Errorf("store: delete user: %w", err) + } + return nil + }) +} + +// DisplayName returns the user's registered display name (empty if unset). Scoped +// by user_id via withUser, like every read in this package. +func (s *Store) DisplayName(ctx context.Context, userID string) (string, error) { + var name string + if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, + `SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&name) + }); err != nil { + return "", fmt.Errorf("store: display name: %w", err) + } + return name, nil +} diff --git a/internal/adapters/store/account_test.go b/internal/adapters/store/account_test.go new file mode 100644 index 0000000..6bb27d6 --- /dev/null +++ b/internal/adapters/store/account_test.go @@ -0,0 +1,106 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +// seedIdentity inserts the un-RLS'd dex_subject → user_id mapping for a user, so +// the cascade-on-delete to user_identities can be asserted. +func seedIdentity(t *testing.T, p *pgxpool.Pool, subject, userID string) { + t.Helper() + _, err := p.Exec(context.Background(), + `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, userID) + require.NoError(t, err) +} + +// countFor counts rows owned by userID in table. The users table is keyed on its +// own id; every other isolated table on user_id. +func countFor(t *testing.T, p *pgxpool.Pool, table, userID string) int { + t.Helper() + col := "user_id" + if table == "users" { + col = "id" + } + var n int + require.NoError(t, p.QueryRow(context.Background(), + `SELECT count(*) FROM `+table+` WHERE `+col+` = $1`, userID).Scan(&n)) + return n +} + +func countDeliveries(t *testing.T, p *pgxpool.Pool, summaryID string) int { + t.Helper() + var n int + require.NoError(t, p.QueryRow(context.Background(), + `SELECT count(*) FROM sink_deliveries WHERE summary_id = $1`, summaryID).Scan(&n)) + return n +} + +func countIdentities(t *testing.T, p *pgxpool.Pool, userID string) int { + t.Helper() + var n int + require.NoError(t, p.QueryRow(context.Background(), + `SELECT count(*) FROM user_identities WHERE user_id = $1`, userID).Scan(&n)) + return n +} + +// TestDeleteUserRemovesAllRowsForUserOnly is the account-deletion isolation proof +// (Worker N+M): DeleteUser wipes every row owned by the target user — across the +// cascade-linked tables, the user_identities map (ON DELETE CASCADE), AND +// summary_actions (which has NO FK to users, so the users-row cascade does not +// reach it and DeleteUser must delete it explicitly) — while leaving another +// user's rows completely intact. +func TestDeleteUserRemovesAllRowsForUserOnly(t *testing.T) { + ctx := context.Background() + newStore(t) // apply migrations + super := rawPool(t) + resetDB(t, super) + + a := seedUser(t, super, userA) + b := seedUser(t, super, userB) + seedIdentity(t, super, "subject-a", userA) + seedIdentity(t, super, "subject-b", userB) + + s := newStore(t) + require.NoError(t, s.DeleteUser(ctx, userA)) + + // Every user-keyed isolated table: zero rows for A, exactly one for B. + for _, table := range userIsolatedTables { + require.Equal(t, 0, countFor(t, super, table, userA), + "A's %s rows must be deleted", table) + require.Equal(t, 1, countFor(t, super, table, userB), + "B's %s rows must survive A's deletion", table) + } + + // sink_deliveries is keyed by summary, not user_id (cascade from summaries). + require.Equal(t, 0, countDeliveries(t, super, a.summaryID), "A's deliveries must cascade-delete") + require.Equal(t, 1, countDeliveries(t, super, b.summaryID), "B's deliveries must survive") + + // The cascade must reach user_identities (explicitly asserted per the mission). + require.Equal(t, 0, countIdentities(t, super, userA), "A's identity mapping must cascade-delete") + require.Equal(t, 1, countIdentities(t, super, userB), "B's identity mapping must survive") +} + +func TestDeleteUserIsIdempotent(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + // Deleting an absent user is a no-op, not an error. + require.NoError(t, s.DeleteUser(ctx, userA)) +} + +func TestDisplayNameReturnsRegisteredName(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + _, err := p.Exec(ctx, `INSERT INTO users (id, display_name) VALUES ($1, $2)`, userA, "Ada") + require.NoError(t, err) + + name, err := s.DisplayName(ctx, userA) + require.NoError(t, err) + require.Equal(t, "Ada", name) +}