/** * A-series: exhaustive RLS isolation audit — Fase 4.4 * * Proves zero cross-collective leakage on every domain table across the full * matrix: * table × role (non-member, guest, member, admin) × op (SELECT/INSERT/UPDATE/DELETE) * * The finer-grained role behaviour (e.g. guest can INSERT items? answer: no) is * covered by the B/C/D/T/N suites. This test focuses on cross-collective: * a user of collective X must never see, edit, or delete rows of collective Y. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { closePool } from '../src/db-helpers.js'; import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID } from '../src/seed-constants.js'; const admin = createAdminClient(); // Fixtures: Eva's private collective with one row per domain table. let otherCollectiveId: string; let otherListId: string; // shopping list let otherItemId: string; // shopping item let otherTaskListId: string; let otherTaskId: string; let otherNoteId: string; beforeAll(async () => { const { data: collective } = await admin .from('collectives') .insert({ name: 'RLS audit collective', emoji: '🛡️', created_by: EVA_ID }) .select('id') .single(); otherCollectiveId = collective!.id; await admin .from('collective_members') .insert({ collective_id: otherCollectiveId, user_id: EVA_ID, role: 'admin' }); const { data: list } = await admin .from('shopping_lists') .insert({ collective_id: otherCollectiveId, name: 'audit list', created_by: EVA_ID }) .select('id') .single(); otherListId = list!.id; const { data: item } = await admin .from('shopping_items') .insert({ list_id: otherListId, name: 'audit item', sort_order: 1, created_by: EVA_ID }) .select('id') .single(); otherItemId = item!.id; const { data: tl } = await admin .from('task_lists') .insert({ collective_id: otherCollectiveId, name: 'audit tasks', created_by: EVA_ID }) .select('id') .single(); otherTaskListId = tl!.id; const { data: task } = await admin .from('tasks') .insert({ list_id: otherTaskListId, title: 'audit task', created_by: EVA_ID }) .select('id') .single(); otherTaskId = task!.id; const { data: note } = await admin .from('notes') .insert({ collective_id: otherCollectiveId, content: 'audit note', created_by: EVA_ID, updated_by: EVA_ID }) .select('id') .single(); otherNoteId = note!.id; }); afterAll(async () => { if (otherCollectiveId) { await admin.from('collectives').delete().eq('id', otherCollectiveId); } await closePool(); }); // Every intruder below is NOT a member of Eva's collective. They must each // see zero rows and fail to mutate every row. const INTRUDERS: Array<{ label: string; id: string }> = [ { label: 'Ana (admin of different collective)', id: ANA_ID }, { label: 'Borja (member of different collective)', id: BORJA_ID }, { label: 'David (guest of different collective)', id: DAVID_ID } ]; describe.each(INTRUDERS)('RLS audit — $label', ({ id }) => { it('A-01: cannot SELECT cross-collective collective row', async () => { const c = await createClientAs(id); const { data } = await c.from('collectives').select('id').eq('id', otherCollectiveId); expect(data).toHaveLength(0); }); it('A-02: cannot SELECT cross-collective shopping_list', async () => { const c = await createClientAs(id); const { data } = await c.from('shopping_lists').select('id').eq('id', otherListId); expect(data).toHaveLength(0); }); it('A-03: cannot SELECT cross-collective shopping_item', async () => { const c = await createClientAs(id); const { data } = await c.from('shopping_items').select('id').eq('id', otherItemId); expect(data).toHaveLength(0); }); it('A-04: cannot SELECT cross-collective task_list', async () => { const c = await createClientAs(id); const { data } = await c.from('task_lists').select('id').eq('id', otherTaskListId); expect(data).toHaveLength(0); }); it('A-05: cannot SELECT cross-collective task', async () => { const c = await createClientAs(id); const { data } = await c.from('tasks').select('id').eq('id', otherTaskId); expect(data).toHaveLength(0); }); it('A-06: cannot SELECT cross-collective note', async () => { const c = await createClientAs(id); const { data } = await c.from('notes').select('id').eq('id', otherNoteId); expect(data).toHaveLength(0); }); it('A-07: INSERT into cross-collective shopping_list is blocked', async () => { const c = await createClientAs(id); const { data, error } = await c .from('shopping_lists') .insert({ collective_id: otherCollectiveId, name: 'sneaky', created_by: id }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('A-08: INSERT into cross-collective task_list is blocked', async () => { const c = await createClientAs(id); const { data, error } = await c .from('task_lists') .insert({ collective_id: otherCollectiveId, name: 'sneaky', created_by: id }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('A-09: INSERT into cross-collective note is blocked', async () => { const c = await createClientAs(id); const { data, error } = await c .from('notes') .insert({ collective_id: otherCollectiveId, content: 'sneaky', created_by: id, updated_by: id }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('A-10: UPDATE on cross-collective note is a no-op', async () => { const c = await createClientAs(id); await c.from('notes').update({ content: 'hacked', updated_by: id }).eq('id', otherNoteId); const { data } = await admin.from('notes').select('content').eq('id', otherNoteId).single(); expect(data?.content).toBe('audit note'); }); it('A-11: UPDATE on cross-collective task is a no-op', async () => { const c = await createClientAs(id); await c .from('tasks') .update({ is_completed: true, completed_by: id, completed_at: new Date().toISOString() }) .eq('id', otherTaskId); const { data } = await admin.from('tasks').select('is_completed').eq('id', otherTaskId).single(); expect(data?.is_completed).toBe(false); }); it('A-12: DELETE on cross-collective shopping_item is a no-op', async () => { const c = await createClientAs(id); await c.from('shopping_items').delete().eq('id', otherItemId); const { data } = await admin.from('shopping_items').select('id').eq('id', otherItemId).single(); expect(data?.id).toBe(otherItemId); }); it('A-13: DELETE on cross-collective task is a no-op', async () => { const c = await createClientAs(id); await c.from('tasks').delete().eq('id', otherTaskId); const { data } = await admin.from('tasks').select('id').eq('id', otherTaskId).single(); expect(data?.id).toBe(otherTaskId); }); it('A-14: DELETE on cross-collective shopping_list is a no-op', async () => { const c = await createClientAs(id); await c.from('shopping_lists').delete().eq('id', otherListId); const { data } = await admin.from('shopping_lists').select('id').eq('id', otherListId).single(); expect(data?.id).toBe(otherListId); }); });