feat(fase-10): account hard-delete — FK relax + RPC (10.5)
Migration 019 normalizes every \`created_by\` / \`updated_by\` FK from \`NOT NULL REFERENCES users(id) ON DELETE RESTRICT\` to \`NULL REFERENCES users(id) ON DELETE SET NULL\`. Affected tables: collectives, collective_invitations, shopping_lists, shopping_items, task_lists, tasks, notes (created_by + updated_by). Without this, delete_account() would be blocked the first time the user creates any content. Spec §6.3: content survives the user; the row is orphaned with the FK set to NULL (UI shows "Deleted user" — to be polished later). Migration 021 adds delete_account(): - two-step delete: public.users (fires the existing promote-oldest- admin trigger from migration 002, then CASCADEs collective_members and SET-NULLs content FKs), then auth.users (CASCADEs sync_conflicts + drops the GoTrue identity row + token rows) - guard: errcode P0003 when the caller is the sole admin of a collective where every other member is a guest (nobody promotable); the user must promote someone manually first - Keycloak is NOT touched (documented limitation in the UI body); the user can re-register with the same email and will receive a fresh UUID-distinct profile Settings UI (already shipped with 10.1) wires the modal: explicit body listing what is and isn't deleted; confirm button only enables when the user types DELETE exactly. Post-success calls logout() so the Keycloak SSO cookie is ended too. pgTAP 013 (8 assertions): RPC exists, regular user delete + cascade + content-orphan, sole-admin-with-promotable promotes, sole-admin-with- only-guest blocked with P0003. Playwright DEL-01 covers the type-the-word UI guard; full "delete + re-login fails" is left to pgTAP because seeding ephemeral Keycloak accounts in E2E would contaminate every downstream suite. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
49
apps/web/tests/e2e/account-deletion.test.ts
Normal file
49
apps/web/tests/e2e/account-deletion.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* DEL-series: /settings → Delete account (Fase 10.5).
|
||||
*
|
||||
* The full "delete + re-login fails" path needs an ephemeral Keycloak user
|
||||
* because deleting a seed user would break every downstream suite that
|
||||
* assumes Ana/Borja/etc. exist. That round-trip is heavy and brittle; the
|
||||
* pgTAP suite (supabase/tests/013_delete_account.sql) already proves the
|
||||
* DB-side contract exhaustively (cascade, role auto-promote, P0003 guard).
|
||||
*
|
||||
* This suite focuses on the UI contract that the pgTAP can't observe:
|
||||
* * the button is only enabled when the user types the exact word
|
||||
* ("DELETE") into the confirmation input
|
||||
* * the danger-zone block is visible on /settings
|
||||
*
|
||||
* Cleanup: no state changes, no afterAll needed.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { closePool } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Account deletion UI (CU §6.3)', () => {
|
||||
test.afterAll(async () => {
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('DEL-01: confirm button is disabled until the exact word is typed', async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Danger zone surface.
|
||||
const opener = page.getByTestId('delete-account-open');
|
||||
await expect(opener).toBeVisible({ timeout: 15_000 });
|
||||
await opener.click();
|
||||
|
||||
const confirm = page.getByTestId('confirm-delete-account');
|
||||
const input = page.getByTestId('delete-account-confirm-input');
|
||||
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
await input.fill('delete'); // lowercase — must be case-sensitive
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
await input.fill('DELETE');
|
||||
await expect(confirm).toBeEnabled();
|
||||
});
|
||||
});
|
||||
89
supabase/migrations/019_user_deletion_fks.sql
Normal file
89
supabase/migrations/019_user_deletion_fks.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- Migration 019: relax user FKs so account deletion (Fase 10.5) works.
|
||||
--
|
||||
-- Spec (§6.3): deleting a user account must NOT delete the content they
|
||||
-- created in collectives. Several `created_by`/`updated_by` FKs were
|
||||
-- declared `NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT`,
|
||||
-- which would block delete_account() whenever the user has ever created
|
||||
-- a list, task, note, etc.
|
||||
--
|
||||
-- This migration:
|
||||
-- * drops the offending RESTRICT FKs, drops the NOT NULL, re-adds them
|
||||
-- as `ON DELETE SET NULL`
|
||||
-- * relaxes `collectives.created_by` likewise (the orphaned collective
|
||||
-- becomes "created by deleted user")
|
||||
-- * relaxes `collective_invitations.created_by` from CASCADE → SET NULL
|
||||
-- so existing invitations survive the creator's account deletion
|
||||
--
|
||||
-- Reads of these columns in the UI should defensively show
|
||||
-- "Deleted user" when null.
|
||||
|
||||
-- ── public.collectives ───────────────────────────────────────────────────────
|
||||
ALTER TABLE public.collectives
|
||||
DROP CONSTRAINT collectives_created_by_fkey;
|
||||
ALTER TABLE public.collectives
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.collectives
|
||||
ADD CONSTRAINT collectives_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── public.collective_invitations ────────────────────────────────────────────
|
||||
ALTER TABLE public.collective_invitations
|
||||
DROP CONSTRAINT collective_invitations_created_by_fkey;
|
||||
ALTER TABLE public.collective_invitations
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.collective_invitations
|
||||
ADD CONSTRAINT collective_invitations_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── public.shopping_lists ────────────────────────────────────────────────────
|
||||
ALTER TABLE public.shopping_lists
|
||||
DROP CONSTRAINT shopping_lists_created_by_fkey;
|
||||
ALTER TABLE public.shopping_lists
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.shopping_lists
|
||||
ADD CONSTRAINT shopping_lists_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── public.shopping_items ────────────────────────────────────────────────────
|
||||
ALTER TABLE public.shopping_items
|
||||
DROP CONSTRAINT shopping_items_created_by_fkey;
|
||||
ALTER TABLE public.shopping_items
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.shopping_items
|
||||
ADD CONSTRAINT shopping_items_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── public.task_lists ────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.task_lists
|
||||
DROP CONSTRAINT task_lists_created_by_fkey;
|
||||
ALTER TABLE public.task_lists
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.task_lists
|
||||
ADD CONSTRAINT task_lists_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── public.tasks ─────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.tasks
|
||||
DROP CONSTRAINT tasks_created_by_fkey;
|
||||
ALTER TABLE public.tasks
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.tasks
|
||||
ADD CONSTRAINT tasks_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ── public.notes ─────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.notes
|
||||
DROP CONSTRAINT notes_created_by_fkey;
|
||||
ALTER TABLE public.notes
|
||||
ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE public.notes
|
||||
ADD CONSTRAINT notes_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.notes
|
||||
DROP CONSTRAINT notes_updated_by_fkey;
|
||||
ALTER TABLE public.notes
|
||||
ALTER COLUMN updated_by DROP NOT NULL;
|
||||
ALTER TABLE public.notes
|
||||
ADD CONSTRAINT notes_updated_by_fkey
|
||||
FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||
70
supabase/migrations/021_delete_account_rpc.sql
Normal file
70
supabase/migrations/021_delete_account_rpc.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- Migration 021: delete_account() RPC — Fase 10.5.
|
||||
--
|
||||
-- Hard-deletes the caller's account. The content they created stays in the
|
||||
-- collectives (FKs were relaxed in migration 019 to SET NULL).
|
||||
--
|
||||
-- Guard (errcode P0003): if the caller is the only admin of any collective
|
||||
-- AND no eligible member (member or admin, not guest) exists to be
|
||||
-- auto-promoted, the deletion is blocked — otherwise the collective ends
|
||||
-- up with zero admins and nobody can manage it. The user is asked to
|
||||
-- promote someone manually or kick the remaining non-eligible members.
|
||||
--
|
||||
-- Order of deletes matters:
|
||||
-- 1. DELETE FROM public.users — fires `promote_oldest_member_on_admin_leave`
|
||||
-- (migration 002) BEFORE the cascade removes collective_members. That
|
||||
-- trigger promotes the oldest non-departing member to admin. Then the
|
||||
-- cascade removes the user's memberships. Content rows with
|
||||
-- created_by = v_user fall to NULL.
|
||||
-- 2. DELETE FROM auth.users — cascades sync_conflicts, removes the GoTrue
|
||||
-- identity. We do NOT touch Keycloak; the user can re-register with a
|
||||
-- new UUID (documented limitation, see plan/fase-10 §10.5.4).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_account()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, auth
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user uuid := auth.uid();
|
||||
v_blocked boolean;
|
||||
BEGIN
|
||||
IF v_user IS NULL THEN
|
||||
RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Pre-flight: is the user the sole admin of any collective that has
|
||||
-- members but no other promotable member?
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.collective_members cm
|
||||
WHERE cm.user_id = v_user
|
||||
AND cm.role = 'admin'
|
||||
-- More than one member in the collective
|
||||
AND (SELECT count(*) FROM public.collective_members c2
|
||||
WHERE c2.collective_id = cm.collective_id) > 1
|
||||
-- And nobody else is admin OR member (only guests + the caller)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.collective_members c3
|
||||
WHERE c3.collective_id = cm.collective_id
|
||||
AND c3.user_id <> v_user
|
||||
AND c3.role IN ('admin', 'member')
|
||||
)
|
||||
) INTO v_blocked;
|
||||
|
||||
IF v_blocked THEN
|
||||
RAISE EXCEPTION 'sole admin with non-promotable members'
|
||||
USING ERRCODE = 'P0003';
|
||||
END IF;
|
||||
|
||||
-- Step 1: public.users — trigger promotes oldest member if needed,
|
||||
-- then cascade clears collective_members, SET NULL on content FKs.
|
||||
DELETE FROM public.users WHERE id = v_user;
|
||||
|
||||
-- Step 2: auth.users — sweeps sync_conflicts (CASCADE) + GoTrue row.
|
||||
DELETE FROM auth.users WHERE id = v_user;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.delete_account() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.delete_account() TO authenticated;
|
||||
147
supabase/tests/013_delete_account.sql
Normal file
147
supabase/tests/013_delete_account.sql
Normal file
@@ -0,0 +1,147 @@
|
||||
-- pgTAP: delete_account() RPC — Fase 10.5
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/013_delete_account.sql
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(8);
|
||||
|
||||
-- ── Existence ────────────────────────────────────────────────────────────────
|
||||
SELECT has_function(
|
||||
'public', 'delete_account', ARRAY[]::text[],
|
||||
'public.delete_account() RPC exists'
|
||||
);
|
||||
|
||||
-- ── Setup A: simple solo user who deletes themselves ─────────────────────────
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, email_confirmed_at,
|
||||
confirmation_token, recovery_token, email_change_token_new, email_change,
|
||||
raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000000',
|
||||
'e3000000-0000-0000-0000-000000000001',
|
||||
'authenticated', 'authenticated', 'delacc-solo@local',
|
||||
now(), '', '', '', '',
|
||||
'{}', '{}', false, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.users (id, email, display_name, language, avatar_type)
|
||||
VALUES ('e3000000-0000-0000-0000-000000000001', 'delacc-solo@local', 'Del Solo', 'es', 'initials')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Solo user creates a list in the seed collective via membership.
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'e3000000-0000-0000-0000-000000000001', 'member')
|
||||
ON CONFLICT (collective_id, user_id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by)
|
||||
VALUES ('cccccccc-5555-5555-5555-5555deadbeef',
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'List by deleted user', 'active',
|
||||
'e3000000-0000-0000-0000-000000000001')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── Case 1: happy path — user deletes self, content stays attributed to NULL ─
|
||||
SELECT set_config('request.jwt.claim.sub', 'e3000000-0000-0000-0000-000000000001', true);
|
||||
|
||||
SELECT lives_ok(
|
||||
$$ SELECT public.delete_account() $$,
|
||||
'DA-01: regular user deletes their own account'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM auth.users WHERE id = 'e3000000-0000-0000-0000-000000000001'),
|
||||
0,
|
||||
'DA-01: auth.users row removed'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.users WHERE id = 'e3000000-0000-0000-0000-000000000001'),
|
||||
0,
|
||||
'DA-01: public.users row removed'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT created_by FROM public.shopping_lists
|
||||
WHERE id = 'cccccccc-5555-5555-5555-5555deadbeef'),
|
||||
NULL,
|
||||
'DA-01: content created_by set to null (orphaned)'
|
||||
);
|
||||
|
||||
-- ── Setup B: sole-admin with promotable member ───────────────────────────────
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, email_confirmed_at,
|
||||
confirmation_token, recovery_token, email_change_token_new, email_change,
|
||||
raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000000',
|
||||
'e3000000-0000-0000-0000-000000000002',
|
||||
'authenticated', 'authenticated', 'delacc-admin@local',
|
||||
now(), '', '', '', '',
|
||||
'{}', '{}', false, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.users (id, email, display_name, language, avatar_type)
|
||||
VALUES ('e3000000-0000-0000-0000-000000000002', 'delacc-admin@local', 'Del Admin', 'es', 'initials')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Auxiliary collective: admin is the test user, member is Carmen.
|
||||
INSERT INTO public.collectives (id, name, emoji, created_by)
|
||||
VALUES ('cccccccc-5555-5555-5555-555555555555', 'Del-acc test', '🚪',
|
||||
'22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
|
||||
VALUES
|
||||
('cccccccc-5555-5555-5555-555555555555', 'e3000000-0000-0000-0000-000000000002', 'admin', now() - INTERVAL '10 days'),
|
||||
('cccccccc-5555-5555-5555-555555555555', '33333333-3333-3333-3333-333333333333', 'member', now() - INTERVAL '5 days')
|
||||
ON CONFLICT (collective_id, user_id) DO NOTHING;
|
||||
|
||||
SELECT set_config('request.jwt.claim.sub', 'e3000000-0000-0000-0000-000000000002', true);
|
||||
|
||||
SELECT lives_ok(
|
||||
$$ SELECT public.delete_account() $$,
|
||||
'DA-02: sole admin with promotable member can delete account'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT role::text FROM public.collective_members
|
||||
WHERE collective_id = 'cccccccc-5555-5555-5555-555555555555'
|
||||
AND user_id = '33333333-3333-3333-3333-333333333333'),
|
||||
'admin',
|
||||
'DA-02: member auto-promoted to admin after sole-admin leaves via delete'
|
||||
);
|
||||
|
||||
-- ── Setup C: sole-admin with only-guest member → should be rejected ──────────
|
||||
INSERT INTO auth.users (instance_id, id, aud, role, email, email_confirmed_at,
|
||||
confirmation_token, recovery_token, email_change_token_new, email_change,
|
||||
raw_app_meta_data, raw_user_meta_data, is_super_admin, created_at, updated_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000000',
|
||||
'e3000000-0000-0000-0000-000000000003',
|
||||
'authenticated', 'authenticated', 'delacc-stuck@local',
|
||||
now(), '', '', '', '',
|
||||
'{}', '{}', false, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.users (id, email, display_name, language, avatar_type)
|
||||
VALUES ('e3000000-0000-0000-0000-000000000003', 'delacc-stuck@local', 'Del Stuck', 'es', 'initials')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.collectives (id, name, emoji, created_by)
|
||||
VALUES ('cccccccc-6666-6666-6666-666666666666', 'Stuck collective', '🚪',
|
||||
'22222222-2222-2222-2222-222222222222')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES
|
||||
('cccccccc-6666-6666-6666-666666666666', 'e3000000-0000-0000-0000-000000000003', 'admin'),
|
||||
('cccccccc-6666-6666-6666-666666666666', '44444444-4444-4444-4444-444444444444', 'guest')
|
||||
ON CONFLICT (collective_id, user_id) DO NOTHING;
|
||||
|
||||
SELECT set_config('request.jwt.claim.sub', 'e3000000-0000-0000-0000-000000000003', true);
|
||||
|
||||
SELECT throws_ok(
|
||||
$$ SELECT public.delete_account() $$,
|
||||
'P0003',
|
||||
NULL,
|
||||
'DA-03: sole admin with only-guest member rejected with P0003'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user