/** * T-series: task_lists and tasks RLS — CRUD by role, completion invariants, * cross-collective isolation. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; import { closePool } from '../src/db-helpers.js'; const admin = createAdminClient(); const createdTaskListIds: string[] = []; let seedTaskListId: string; beforeAll(async () => { // Seed a task list owned by Ana for the rest of the suite to read against. const { data, error } = await admin .from('task_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'T-series seed list', created_by: ANA_ID }) .select('id') .single(); if (error) throw error; seedTaskListId = data!.id; createdTaskListIds.push(seedTaskListId); }); afterAll(async () => { if (createdTaskListIds.length > 0) { await admin.from('task_lists').delete().in('id', createdTaskListIds); } await closePool(); }); describe('Task list access by role', () => { it('T-01a: member (Borja) can create a task list', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('task_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'Borja list', created_by: BORJA_ID }) .select('id') .single(); expect(error).toBeNull(); expect(data?.id).toBeTruthy(); if (data?.id) createdTaskListIds.push(data.id); }); it('T-01b: guest (David) cannot create a task list', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('task_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'David list', created_by: DAVID_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('T-01c: guest (David) can read task lists', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('task_lists') .select('id') .eq('id', seedTaskListId); expect(error).toBeNull(); expect(data).toHaveLength(1); }); }); describe('Task CRUD by role', () => { let taskId: string; it('T-02a: member (Borja) can add a task', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('tasks') .insert({ list_id: seedTaskListId, title: 'Borja task', created_by: BORJA_ID }) .select('id') .single(); expect(error).toBeNull(); expect(data?.id).toBeTruthy(); taskId = data!.id; }); it('T-02b: guest (David) cannot add a task', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('tasks') .insert({ list_id: seedTaskListId, title: 'David task', created_by: DAVID_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('T-02c: guest (David) can read tasks', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('tasks') .select('id') .eq('list_id', seedTaskListId); expect(error).toBeNull(); expect(data!.length).toBeGreaterThanOrEqual(1); }); it('T-02d: cannot spoof created_by', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('tasks') .insert({ list_id: seedTaskListId, title: 'Spoof', created_by: ANA_ID }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); }); describe('Task completion invariants', () => { let taskId: string; beforeAll(async () => { const { data } = await admin .from('tasks') .insert({ list_id: seedTaskListId, title: 'Complete me', created_by: ANA_ID }) .select('id') .single(); taskId = data!.id; }); it('T-03a: marking complete sets completed_by + completed_at', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('tasks') .update({ is_completed: true, completed_by: BORJA_ID, completed_at: new Date().toISOString() }) .eq('id', taskId); expect(error).toBeNull(); const { data } = await admin.from('tasks').select('*').eq('id', taskId).single(); expect(data?.is_completed).toBe(true); expect(data?.completed_by).toBe(BORJA_ID); expect(data?.completed_at).not.toBeNull(); }); it('T-03b: unmarking clears completed_by + completed_at', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('tasks') .update({ is_completed: false, completed_by: null, completed_at: null }) .eq('id', taskId); expect(error).toBeNull(); const { data } = await admin.from('tasks').select('*').eq('id', taskId).single(); expect(data?.is_completed).toBe(false); expect(data?.completed_by).toBeNull(); expect(data?.completed_at).toBeNull(); }); it('T-03c: CHECK constraint forbids is_completed=true without completed_by', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('tasks') .update({ is_completed: true, completed_by: null, completed_at: null }) .eq('id', taskId); expect(error).not.toBeNull(); // CHECK violation surfaces as a Postgres error }); it('T-03d: guest (David) cannot mark complete', async () => { const david = await createClientAs(DAVID_ID); await david .from('tasks') .update({ is_completed: true, completed_by: DAVID_ID, completed_at: new Date().toISOString() }) .eq('id', taskId); const { data } = await admin.from('tasks').select('is_completed').eq('id', taskId).single(); expect(data?.is_completed).toBe(false); }); }); describe('Cross-collective isolation', () => { let otherCollectiveId: string; let otherListId: string; let otherTaskId: string; beforeAll(async () => { const { data: collective } = await admin .from('collectives') .insert({ name: 'Tasks 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: list } = await admin .from('task_lists') .insert({ collective_id: otherCollectiveId, name: 'Eva tasks', created_by: EVA_ID }) .select('id') .single(); otherListId = list!.id; const { data: task } = await admin .from('tasks') .insert({ list_id: otherListId, title: 'Eva private task', created_by: EVA_ID }) .select('id') .single(); otherTaskId = task!.id; }); afterAll(async () => { if (otherCollectiveId) { await admin.from('collectives').delete().eq('id', otherCollectiveId); } }); it('T-04a: Eva (non-member of main collective) sees no tasks from main', async () => { const eva = await createClientAs(EVA_ID); const { data } = await eva.from('tasks').select('id').eq('list_id', seedTaskListId); expect(data).toHaveLength(0); }); it('T-04b: Borja cannot read Eva\'s task', async () => { const borja = await createClientAs(BORJA_ID); const { data } = await borja.from('tasks').select('id').eq('id', otherTaskId); expect(data).toHaveLength(0); }); it('T-04c: Borja cannot delete Eva\'s task list', async () => { const borja = await createClientAs(BORJA_ID); await borja.from('task_lists').delete().eq('id', otherListId); const { data } = await admin.from('task_lists').select('id').eq('id', otherListId).single(); expect(data?.id).toBe(otherListId); }); });