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>
148 lines
6.3 KiB
PL/PgSQL
148 lines
6.3 KiB
PL/PgSQL
-- 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;
|