diff --git a/apps/web/src/lib/stores/lists.ts b/apps/web/src/lib/stores/lists.ts index 5d8a0d2..996a63f 100644 --- a/apps/web/src/lib/stores/lists.ts +++ b/apps/web/src/lib/stores/lists.ts @@ -85,12 +85,19 @@ export async function softDeleteList(id: string) { } export async function restoreList(id: string, collectiveId: string) { - await getSupabase().from('shopping_lists').update({ deleted_at: null }).eq('id', id); + // Fase 10.4: route through SECURITY DEFINER RPC so the role check is + // explicit and not implicit-via-RLS (guest is allowed by UPDATE policy + // today because we never bothered to scope `restoreList`-style writes + // distinctly from "any UPDATE" in 005_shopping.sql — the RPC closes that). + await getSupabase().rpc('restore_list', { p_list_id: id }); await loadLists(collectiveId); } export async function permanentDeleteList(id: string) { - await getSupabase().from('shopping_lists').delete().eq('id', id); + // Fase 10.4: matching RPC for hard delete, which also requires the row + // to be soft-deleted first (defensive guard against accidental hard + // delete from the active view). + await getSupabase().rpc('hard_delete_list', { p_list_id: id }); } export async function archiveList(id: string) { diff --git a/packages/test-utils/tests/trash-rpcs.test.ts b/packages/test-utils/tests/trash-rpcs.test.ts new file mode 100644 index 0000000..7faef7e --- /dev/null +++ b/packages/test-utils/tests/trash-rpcs.test.ts @@ -0,0 +1,113 @@ +/** + * T-series: trash RPCs (restore_list, hard_delete_list) — Fase 10.4. + * + * Verifies that the SECURITY DEFINER functions return what the UI store + * expects: restore brings the list back to active (visible in loadLists), + * hard_delete removes it permanently, and both functions guard on caller + * role (guest rejected, non-member rejected). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; +import { sql, closePool } from '../src/db-helpers.js'; +import { + BORJA_ID, + DAVID_ID, + EVA_ID, + COLLECTIVE_ID +} from '../src/seed-constants.js'; + +const admin = createAdminClient(); +const trashedIds: string[] = []; + +afterAll(async () => { + if (trashedIds.length) { + await admin.from('shopping_lists').delete().in('id', trashedIds); + } + await closePool(); +}); + +async function makeTrashedList(): Promise { + const r = await sql( + `INSERT INTO public.shopping_lists (collective_id, name, status, created_by, deleted_at) + VALUES ($1, 'Trash RPC test', 'active', $2, now() - interval '1 hour') + RETURNING id`, + [COLLECTIVE_ID, BORJA_ID] + ); + const id = r.rows[0].id as string; + trashedIds.push(id); + return id; +} + +describe('Trash RPCs', () => { + it('T-10: member restore brings list back to the active listing', async () => { + const id = await makeTrashedList(); + const borja = await createClientAs(BORJA_ID); + + const { error: rpcErr } = await borja.rpc('restore_list', { p_list_id: id }); + expect(rpcErr).toBeNull(); + + const { data } = await admin + .from('shopping_lists') + .select('deleted_at') + .eq('id', id) + .single(); + expect(data?.deleted_at).toBeNull(); + + // Visible in the active listing now + const { data: active } = await borja + .from('shopping_lists') + .select('id') + .eq('id', id) + .is('deleted_at', null) + .single(); + expect(active?.id).toBe(id); + }); + + it('T-11: guest (David) cannot restore', async () => { + const id = await makeTrashedList(); + const david = await createClientAs(DAVID_ID); + + const { error } = await david.rpc('restore_list', { p_list_id: id }); + expect(error).not.toBeNull(); + }); + + it('T-12: non-member (Eva) cannot restore', async () => { + const id = await makeTrashedList(); + const eva = await createClientAs(EVA_ID); + + const { error } = await eva.rpc('restore_list', { p_list_id: id }); + expect(error).not.toBeNull(); + }); + + it('T-13: member can hard-delete a soft-deleted list', async () => { + const id = await makeTrashedList(); + const borja = await createClientAs(BORJA_ID); + + const { error } = await borja.rpc('hard_delete_list', { p_list_id: id }); + expect(error).toBeNull(); + + const { data } = await admin.from('shopping_lists').select('id').eq('id', id); + expect(data).toHaveLength(0); + // Already gone — drop from cleanup list. + const idx = trashedIds.indexOf(id); + if (idx >= 0) trashedIds.splice(idx, 1); + }); + + it('T-14: hard delete on an active (non-trashed) list is rejected', async () => { + const r = await sql( + `INSERT INTO public.shopping_lists (collective_id, name, status, created_by) + VALUES ($1, 'Trash-RPC-guard', 'active', $2) RETURNING id`, + [COLLECTIVE_ID, BORJA_ID] + ); + const id = r.rows[0].id as string; + trashedIds.push(id); + + const borja = await createClientAs(BORJA_ID); + const { error } = await borja.rpc('hard_delete_list', { p_list_id: id }); + expect(error).not.toBeNull(); + + // Row still present + const { data } = await admin.from('shopping_lists').select('id').eq('id', id).single(); + expect(data?.id).toBe(id); + }); +}); diff --git a/supabase/migrations/020_restore_list_rpc.sql b/supabase/migrations/020_restore_list_rpc.sql new file mode 100644 index 0000000..5ba8706 --- /dev/null +++ b/supabase/migrations/020_restore_list_rpc.sql @@ -0,0 +1,104 @@ +-- Migration 020: restore_list() + hard_delete_list() RPCs — Fase 10.4. +-- (Migration number 019 intentionally reserved; see plan/fase-10-spec-completion.md.) +-- +-- Both functions are SECURITY DEFINER and guard explicitly on caller +-- membership (admin or member; guests cannot mutate). They give the +-- trash drawer a single round-trip that does what it says, instead of +-- chained PostgREST UPDATEs (which evaluate SELECT RLS on the returned +-- row — see CLAUDE.md "Backend / RLS / Kong" gotcha). + +-- ── restore_list ───────────────────────────────────────────────────────────── +-- Clear deleted_at on a soft-deleted list. Rejects if the list is not +-- currently soft-deleted (defensive — restore on an active list is a no-op +-- but tests can rely on the throw to detect logic errors). + +CREATE OR REPLACE FUNCTION public.restore_list(p_list_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_user uuid := auth.uid(); + v_collective uuid; + v_role public.member_role; + v_deleted timestamptz; +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000'; + END IF; + + SELECT collective_id, deleted_at INTO v_collective, v_deleted + FROM public.shopping_lists + WHERE id = p_list_id; + + IF v_collective IS NULL THEN + RAISE EXCEPTION 'list not found' USING ERRCODE = 'P0404'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = v_collective AND user_id = v_user; + + IF v_role IS NULL OR v_role = 'guest' THEN + RAISE EXCEPTION 'not allowed' USING ERRCODE = '42501'; + END IF; + + IF v_deleted IS NULL THEN + RAISE EXCEPTION 'list is not in trash' USING ERRCODE = 'P0001'; + END IF; + + UPDATE public.shopping_lists SET deleted_at = NULL WHERE id = p_list_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.restore_list(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.restore_list(uuid) TO authenticated; + +-- ── hard_delete_list ───────────────────────────────────────────────────────── +-- Permanently delete a list that is already in the trash. Rejects if the +-- list is still active (i.e. not soft-deleted yet) — the user must trash +-- it first. shopping_items cascade on the FK. + +CREATE OR REPLACE FUNCTION public.hard_delete_list(p_list_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_user uuid := auth.uid(); + v_collective uuid; + v_role public.member_role; + v_deleted timestamptz; +BEGIN + IF v_user IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000'; + END IF; + + SELECT collective_id, deleted_at INTO v_collective, v_deleted + FROM public.shopping_lists + WHERE id = p_list_id; + + IF v_collective IS NULL THEN + RAISE EXCEPTION 'list not found' USING ERRCODE = 'P0404'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = v_collective AND user_id = v_user; + + IF v_role IS NULL OR v_role = 'guest' THEN + RAISE EXCEPTION 'not allowed' USING ERRCODE = '42501'; + END IF; + + IF v_deleted IS NULL THEN + RAISE EXCEPTION 'list is not in trash' USING ERRCODE = 'P0001'; + END IF; + + DELETE FROM public.shopping_lists WHERE id = p_list_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.hard_delete_list(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.hard_delete_list(uuid) TO authenticated; diff --git a/supabase/tests/012_trash_rpcs.sql b/supabase/tests/012_trash_rpcs.sql new file mode 100644 index 0000000..6f58c4d --- /dev/null +++ b/supabase/tests/012_trash_rpcs.sql @@ -0,0 +1,87 @@ +-- pgTAP: restore_list / hard_delete_list RPCs — Fase 10.4 +-- Run with: psql -U postgres -d postgres -f supabase/tests/012_trash_rpcs.sql + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(8); + +-- ── Existence ──────────────────────────────────────────────────────────────── +SELECT has_function( + 'public', 'restore_list', ARRAY['uuid'], + 'public.restore_list(uuid) RPC exists' +); + +SELECT has_function( + 'public', 'hard_delete_list', ARRAY['uuid'], + 'public.hard_delete_list(uuid) RPC exists' +); + +-- ── Setup: a soft-deleted list in the seed collective ──────────────────────── +-- Owned by Borja so it has a valid created_by (RESTRICT FK). +INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by, deleted_at) +VALUES ('cccccccc-4444-4444-4444-4444deadbeef', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'Trash test list', 'active', + '22222222-2222-2222-2222-222222222222', now() - interval '1 hour'); + +INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by) +VALUES ('cccccccc-4444-4444-4444-4444cafebabe', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'Active list', 'active', + '22222222-2222-2222-2222-222222222222'); + +-- ── Case 1: restore as Borja (member) ──────────────────────────────────────── +SELECT set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); + +SELECT lives_ok( + $$ SELECT public.restore_list('cccccccc-4444-4444-4444-4444deadbeef') $$, + 'T-01: member can restore a soft-deleted list in their collective' +); + +SELECT is( + (SELECT deleted_at FROM public.shopping_lists + WHERE id = 'cccccccc-4444-4444-4444-4444deadbeef'), + NULL, + 'T-01: deleted_at cleared after restore' +); + +-- ── Case 2: restore by non-member (Eva) → reject ───────────────────────────── +-- Soft-delete it again first +UPDATE public.shopping_lists SET deleted_at = now() - interval '1 hour' +WHERE id = 'cccccccc-4444-4444-4444-4444deadbeef'; + +SELECT set_config('request.jwt.claim.sub', '55555555-5555-5555-5555-555555555555', true); -- Eva + +SELECT throws_ok( + $$ SELECT public.restore_list('cccccccc-4444-4444-4444-4444deadbeef') $$, + NULL, + NULL, + 'T-02: non-member rejected' +); + +-- ── Case 3: hard delete a soft-deleted list (Borja) ────────────────────────── +SELECT set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); + +SELECT lives_ok( + $$ SELECT public.hard_delete_list('cccccccc-4444-4444-4444-4444deadbeef') $$, + 'T-03: member can hard-delete a soft-deleted list' +); + +SELECT is( + (SELECT count(*)::int FROM public.shopping_lists + WHERE id = 'cccccccc-4444-4444-4444-4444deadbeef'), + 0, + 'T-03: row gone after hard delete' +); + +-- ── Case 4: hard delete an active list → rejected (guard) ──────────────────── +SELECT throws_ok( + $$ SELECT public.hard_delete_list('cccccccc-4444-4444-4444-4444cafebabe') $$, + NULL, + NULL, + 'T-04: hard delete of non-trashed list rejected' +); + +SELECT * FROM finish(); +ROLLBACK;