feat(store): DeleteUser + DisplayName for account management
DeleteUser permanently removes a user and all owned data, scoped via withUser. The users-row ON DELETE CASCADE reaches videos, transcripts, summaries → sink_deliveries, video_connections, and the user_identities map (cascades bypass RLS, so a scoped connection still wipes child rows). summary_actions carries user_id but has NO FK to users (migration 002), so it is deleted explicitly in the same scoped transaction. Idempotent. Tapir-side only (decision 2026-06-03): Dex identity is left untouched; secrets live in the SecretStore and are removed by the account handler. DisplayName returns the registered name for the account page. Test proves deletion removes every row for the target user across all isolated tables (incl. video_connections AND user_identities) and leaves another user's rows fully intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user