/** * N-series: notes RLS — CRUD by role, archive, trash window, cross-collective isolation. */ 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 } from '../src/seed-constants.js'; const admin = createAdminClient(); const createdNoteIds: string[] = []; afterAll(async () => { if (createdNoteIds.length > 0) { await admin.from('notes').delete().in('id', createdNoteIds); } await closePool(); }); describe('Note CRUD by role', () => { let noteId: string; it('N-01a: member (Borja) can create a note', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('notes') .insert({ collective_id: COLLECTIVE_ID, title: 'Borja note', content: 'hello', created_by: BORJA_ID, updated_by: BORJA_ID }) .select('id') .single(); expect(error).toBeNull(); expect(data?.id).toBeTruthy(); noteId = data!.id; createdNoteIds.push(noteId); }); it('N-01b: guest (David) cannot create a note', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('notes') .insert({ collective_id: COLLECTIVE_ID, content: 'David note', created_by: DAVID_ID, updated_by: DAVID_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('N-01c: guest (David) can read notes', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david.from('notes').select('id').eq('id', noteId); expect(error).toBeNull(); expect(data).toHaveLength(1); }); it('N-01d: any active member can edit any note (Carmen edits Borja\'s)', async () => { const carmen = await createClientAs('33333333-3333-3333-3333-333333333333'); const { error } = await carmen .from('notes') .update({ content: 'edited by Carmen', updated_by: '33333333-3333-3333-3333-333333333333' }) .eq('id', noteId); expect(error).toBeNull(); const { data } = await admin.from('notes').select('content, updated_by').eq('id', noteId).single(); expect(data?.content).toBe('edited by Carmen'); expect(data?.updated_by).toBe('33333333-3333-3333-3333-333333333333'); }); it('N-01e: cannot spoof created_by/updated_by on insert', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('notes') .insert({ collective_id: COLLECTIVE_ID, content: 'spoof', created_by: ANA_ID, updated_by: ANA_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); }); describe('Note pin / archive / trash flow', () => { let noteId: string; beforeAll(async () => { const { data } = await admin .from('notes') .insert({ collective_id: COLLECTIVE_ID, content: 'flow note', created_by: ANA_ID, updated_by: ANA_ID }) .select('id') .single(); noteId = data!.id; createdNoteIds.push(noteId); }); it('N-02a: member can pin', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('notes') .update({ is_pinned: true, updated_by: BORJA_ID }) .eq('id', noteId); expect(error).toBeNull(); }); it('N-02b: pinned + archived combination is rejected (CHECK)', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('notes') .update({ is_archived: true, updated_by: BORJA_ID }) .eq('id', noteId); expect(error).not.toBeNull(); }); it('N-02c: archive (after unpin) succeeds', async () => { const borja = await createClientAs(BORJA_ID); await borja .from('notes') .update({ is_pinned: false, is_archived: true, updated_by: BORJA_ID }) .eq('id', noteId); const { data } = await admin.from('notes').select('is_archived').eq('id', noteId).single(); expect(data?.is_archived).toBe(true); }); it('N-02d: archived note is still readable', async () => { const david = await createClientAs(DAVID_ID); const { data } = await david.from('notes').select('id, is_archived').eq('id', noteId).single(); expect(data?.id).toBe(noteId); expect(data?.is_archived).toBe(true); }); }); describe('Note trash window', () => { let noteId: string; beforeAll(async () => { const { data } = await admin .from('notes') .insert({ collective_id: COLLECTIVE_ID, content: 'trash note', created_by: ANA_ID, updated_by: ANA_ID }) .select('id') .single(); noteId = data!.id; createdNoteIds.push(noteId); }); it('N-03a: soft-deleted note is visible within the 7-day window', async () => { const borja = await createClientAs(BORJA_ID); await borja .from('notes') .update({ deleted_at: new Date().toISOString(), updated_by: BORJA_ID }) .eq('id', noteId); const { data } = await borja.from('notes').select('id, deleted_at').eq('id', noteId).single(); expect(data?.id).toBe(noteId); expect(data?.deleted_at).not.toBeNull(); }); it('N-03b: soft-deleted note older than 7 days is invisible', async () => { await sql(`UPDATE public.notes SET deleted_at = now() - INTERVAL '8 days' WHERE id = $1`, [noteId]); const borja = await createClientAs(BORJA_ID); const { data } = await borja.from('notes').select('id').eq('id', noteId); expect(data).toHaveLength(0); await sql(`UPDATE public.notes SET deleted_at = now() WHERE id = $1`, [noteId]); }); it('N-03c: guest cannot hard-delete a soft-deleted note', async () => { const david = await createClientAs(DAVID_ID); await david.from('notes').delete().eq('id', noteId); const { data } = await admin.from('notes').select('id').eq('id', noteId).single(); expect(data?.id).toBe(noteId); }); it('N-03d: member can hard-delete a soft-deleted note', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja.from('notes').delete().eq('id', noteId); expect(error).toBeNull(); const { data } = await admin.from('notes').select('id').eq('id', noteId); expect(data).toHaveLength(0); const idx = createdNoteIds.indexOf(noteId); if (idx !== -1) createdNoteIds.splice(idx, 1); }); }); describe('Cross-collective note isolation', () => { let otherCollectiveId: string; let otherNoteId: string; beforeAll(async () => { const { data: collective } = await admin .from('collectives') .insert({ name: 'Notes isolation 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: note } = await admin .from('notes') .insert({ collective_id: otherCollectiveId, content: 'Eva private', 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); } }); it('N-04a: Borja cannot read Eva\'s note', async () => { const borja = await createClientAs(BORJA_ID); const { data } = await borja.from('notes').select('id').eq('id', otherNoteId); expect(data).toHaveLength(0); }); it('N-04b: Borja cannot edit Eva\'s note', async () => { const borja = await createClientAs(BORJA_ID); await borja .from('notes') .update({ content: 'hacked', updated_by: BORJA_ID }) .eq('id', otherNoteId); const { data } = await admin.from('notes').select('content').eq('id', otherNoteId).single(); expect(data?.content).toBe('Eva private'); }); });