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>
107 lines
3.6 KiB
Go
107 lines
3.6 KiB
Go
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)
|
|
}
|