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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user