/** * C-series: Shopping list RLS — create, read, soft-delete, restore, hard-delete. */ 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 { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID, SEED_LIST_ID } from '../src/seed-constants.js'; const admin = createAdminClient(); const createdListIds: string[] = []; afterAll(async () => { // Hard-delete any lists created during tests (bypasses RLS) if (createdListIds.length > 0) { await admin.from('shopping_lists').delete().in('id', createdListIds); } await closePool(); }); describe('Shopping list read access', () => { it('C-01: member (Borja) can read the seed list', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('shopping_lists') .select('id, name') .eq('id', SEED_LIST_ID) .single(); expect(error).toBeNull(); expect(data?.id).toBe(SEED_LIST_ID); }); it('C-02: guest (David) can read the seed list', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('shopping_lists') .select('id') .eq('id', SEED_LIST_ID) .single(); expect(error).toBeNull(); expect(data?.id).toBe(SEED_LIST_ID); }); it('C-03: Eva (non-member) sees no lists', async () => { const eva = await createClientAs(EVA_ID); const { data } = await eva.from('shopping_lists').select('id'); expect(data).toHaveLength(0); }); }); describe('Shopping list create', () => { it('C-04: member (Borja) can create a list', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('shopping_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'Borja test list', created_by: BORJA_ID }) .select('id') .single(); expect(error).toBeNull(); expect(data?.id).toBeTruthy(); if (data?.id) createdListIds.push(data.id); }); it('C-05: guest (David) cannot create a list', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('shopping_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'David sneaky list', created_by: DAVID_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('C-06: cannot spoof created_by to another user', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('shopping_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'Spoofed list', created_by: ANA_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); }); describe('Shopping list soft-delete and trash window', () => { let testListId: string; beforeAll(async () => { // Create a list via admin to avoid depending on prior test const { data } = await admin .from('shopping_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'Trash test list', created_by: ANA_ID }) .select('id') .single(); testListId = data!.id; createdListIds.push(testListId); }); it('C-07: member (Borja) can soft-delete (set deleted_at) on a list', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('shopping_lists') .update({ deleted_at: new Date().toISOString() }) .eq('id', testListId); expect(error).toBeNull(); const { data } = await admin.from('shopping_lists').select('deleted_at').eq('id', testListId).single(); expect(data?.deleted_at).not.toBeNull(); }); it('C-08: soft-deleted list is still visible within the 7-day window', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('shopping_lists') .select('id, deleted_at') .eq('id', testListId) .single(); expect(error).toBeNull(); expect(data?.id).toBe(testListId); expect(data?.deleted_at).not.toBeNull(); }); it('C-09: soft-deleted list older than 7 days is not visible', async () => { // Force deleted_at to 8 days ago via direct SQL await sql( `UPDATE public.shopping_lists SET deleted_at = now() - INTERVAL '8 days' WHERE id = $1`, [testListId] ); const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('shopping_lists') .select('id') .eq('id', testListId); expect(error).toBeNull(); expect(data).toHaveLength(0); // Restore deleted_at so afterAll cleanup can hard-delete it await sql(`UPDATE public.shopping_lists SET deleted_at = now() WHERE id = $1`, [testListId]); }); it('C-10: member can restore a list (set deleted_at = null)', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('shopping_lists') .update({ deleted_at: null }) .eq('id', testListId); expect(error).toBeNull(); const { data } = await admin.from('shopping_lists').select('deleted_at').eq('id', testListId).single(); expect(data?.deleted_at).toBeNull(); }); it('C-11: guest (David) cannot permanently delete a list', async () => { // Soft-delete first via admin await admin .from('shopping_lists') .update({ deleted_at: new Date().toISOString() }) .eq('id', testListId); const david = await createClientAs(DAVID_ID); const { error } = await david.from('shopping_lists').delete().eq('id', testListId); // PostgREST returns 204 with 0 rows when RLS blocks — verify row still exists const { data } = await admin.from('shopping_lists').select('id').eq('id', testListId).single(); expect(data?.id).toBe(testListId); }); it('C-12: member (Borja) can permanently hard-delete a soft-deleted list', async () => { // Ensure it's soft-deleted await admin .from('shopping_lists') .update({ deleted_at: new Date().toISOString() }) .eq('id', testListId); const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('shopping_lists') .delete() .eq('id', testListId); expect(error).toBeNull(); const { data } = await admin.from('shopping_lists').select('id').eq('id', testListId); expect(data).toHaveLength(0); // Remove from createdListIds since it's already gone const idx = createdListIds.indexOf(testListId); if (idx !== -1) createdListIds.splice(idx, 1); }); }); describe('Cross-collective isolation', () => { let otherCollectiveId: string; let otherListId: string; beforeAll(async () => { // Eva creates her own collective and list (Eva is allowed to create collectives) const { data: collective } = await admin .from('collectives') .insert({ name: 'Eva 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: 'Eva private list', created_by: EVA_ID }) .select('id') .single(); otherListId = list!.id; }); afterAll(async () => { // Cascade delete removes members + lists if (otherCollectiveId) { await admin.from('collectives').delete().eq('id', otherCollectiveId); } }); it('C-13: Borja (member of main collective) cannot see Eva\'s list', async () => { const borja = await createClientAs(BORJA_ID); const { data } = await borja .from('shopping_lists') .select('id') .eq('id', otherListId); expect(data).toHaveLength(0); }); it('C-14: Ana (admin of main collective) cannot see Eva\'s list', async () => { const ana = await createClientAs(ANA_ID); const { data } = await ana .from('shopping_lists') .select('id') .eq('id', otherListId); expect(data).toHaveLength(0); }); });