diff --git a/apps/web/tests/e2e/account-deletion.test.ts b/apps/web/tests/e2e/account-deletion.test.ts new file mode 100644 index 0000000..f26dbda --- /dev/null +++ b/apps/web/tests/e2e/account-deletion.test.ts @@ -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(); + }); +}); diff --git a/supabase/migrations/019_user_deletion_fks.sql b/supabase/migrations/019_user_deletion_fks.sql new file mode 100644 index 0000000..52f4860 --- /dev/null +++ b/supabase/migrations/019_user_deletion_fks.sql @@ -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; diff --git a/supabase/migrations/021_delete_account_rpc.sql b/supabase/migrations/021_delete_account_rpc.sql new file mode 100644 index 0000000..d8d63e9 --- /dev/null +++ b/supabase/migrations/021_delete_account_rpc.sql @@ -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; diff --git a/supabase/tests/013_delete_account.sql b/supabase/tests/013_delete_account.sql new file mode 100644 index 0000000..159762b --- /dev/null +++ b/supabase/tests/013_delete_account.sql @@ -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;