feat(fase-10): trash drawer — restore + hard-delete RPCs (10.4)
Migration 020 (019 reserved per plan) introduces two SECURITY DEFINER RPCs that back the trash drawer's per-row actions: - restore_list(uuid): clears deleted_at; requires caller to be admin or member of the owning collective; rejects if the list is not currently in trash (defensive) - hard_delete_list(uuid): permanently deletes a soft-deleted list; same role guard; rejects if the list is still active Both close the existing UI gap where guest could in principle issue the equivalent UPDATE (the FROM-UPDATE policy is is_active_member, which excludes guest, but the surface area is broader than these two intents — the RPCs make the contract explicit). The lists drawer already exposed the Restore + Delete-for-ever buttons; only the store calls swap from chained PostgREST writes to .rpc() — the UI itself is unchanged. pgTAP 012 (8 assertions) covers existence, restore happy path, non-member rejection, hard-delete happy path, active-list guard. Integration T-10..T-14 (5 vitest) verifies the wire format the store relies on (rpc errors, row state, active-listing visibility). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -85,12 +85,19 @@ export async function softDeleteList(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreList(id: string, collectiveId: 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);
|
await loadLists(collectiveId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function permanentDeleteList(id: string) {
|
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) {
|
export async function archiveList(id: string) {
|
||||||
|
|||||||
113
packages/test-utils/tests/trash-rpcs.test.ts
Normal file
113
packages/test-utils/tests/trash-rpcs.test.ts
Normal file
@@ -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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
supabase/migrations/020_restore_list_rpc.sql
Normal file
104
supabase/migrations/020_restore_list_rpc.sql
Normal file
@@ -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;
|
||||||
87
supabase/tests/012_trash_rpcs.sql
Normal file
87
supabase/tests/012_trash_rpcs.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user