/** * B-series: Collective-level RLS — roles, invitations, member management. */ import { describe, it, expect, 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'; const admin = createAdminClient(); // Invitations created during tests — cleaned up in afterAll const createdInvitationIds: string[] = []; afterAll(async () => { if (createdInvitationIds.length > 0) { await admin .from('collective_invitations') .delete() .in('id', createdInvitationIds); } }); describe('Collective read access', () => { it('B-02: member (Borja) can read the collective', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('collectives') .select('id, name') .eq('id', COLLECTIVE_ID) .single(); expect(error).toBeNull(); expect(data?.id).toBe(COLLECTIVE_ID); }); it('B-03: guest (David) can read the collective', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('collectives') .select('id') .eq('id', COLLECTIVE_ID) .single(); expect(error).toBeNull(); expect(data?.id).toBe(COLLECTIVE_ID); }); it('B-04: member can read the member list', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('collective_members') .select('user_id') .eq('collective_id', COLLECTIVE_ID); expect(error).toBeNull(); expect(data!.length).toBeGreaterThanOrEqual(4); }); }); describe('Collective update — admin only', () => { it('B-05: admin (Ana) can rename the collective', async () => { const ana = await createClientAs(ANA_ID); const { error } = await ana .from('collectives') .update({ name: 'Casa García-López' }) .eq('id', COLLECTIVE_ID); expect(error).toBeNull(); }); it('B-06: member (Borja) cannot rename the collective', async () => { const borja = await createClientAs(BORJA_ID); const { error, count } = await borja .from('collectives') .update({ name: 'Borja takeover' }) .eq('id', COLLECTIVE_ID); // PostgREST returns 200 with 0 rows updated when blocked by RLS expect(error).toBeNull(); // Verify the name didn't change const { data } = await admin.from('collectives').select('name').eq('id', COLLECTIVE_ID).single(); expect(data?.name).toBe('Casa García-López'); }); it('B-07: guest (David) cannot rename the collective', async () => { const david = await createClientAs(DAVID_ID); await david .from('collectives') .update({ name: 'David takeover' }) .eq('id', COLLECTIVE_ID); const { data } = await admin.from('collectives').select('name').eq('id', COLLECTIVE_ID).single(); expect(data?.name).toBe('Casa García-López'); }); }); describe('Invitations — admin only', () => { it('B-08: admin (Ana) can create an invitation', async () => { const ana = await createClientAs(ANA_ID); const { data, error } = await ana .from('collective_invitations') .insert({ collective_id: COLLECTIVE_ID, created_by: ANA_ID, role: 'member' }) .select('id') .single(); expect(error).toBeNull(); expect(data?.id).toBeTruthy(); if (data?.id) createdInvitationIds.push(data.id); }); it('B-09: member (Borja) cannot create an invitation', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('collective_invitations') .insert({ collective_id: COLLECTIVE_ID, created_by: BORJA_ID, role: 'member' }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('B-10: guest (David) cannot create an invitation', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('collective_invitations') .insert({ collective_id: COLLECTIVE_ID, created_by: DAVID_ID, role: 'guest' }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('B-11: admin can read the invitations they created', async () => { const ana = await createClientAs(ANA_ID); const { data, error } = await ana .from('collective_invitations') .select('id') .eq('collective_id', COLLECTIVE_ID); expect(error).toBeNull(); expect(data!.length).toBeGreaterThanOrEqual(1); }); it('B-12: member (Borja) cannot update an invitation directly', async () => { // Accepting invitations must go through the accept_invitation() RPC — // direct UPDATE is denied by the update_deny policy. const borja = await createClientAs(BORJA_ID); const { data: inv } = await admin .from('collective_invitations') .select('id') .eq('collective_id', COLLECTIVE_ID) .limit(1) .single(); if (!inv?.id) return; // skip if no invitations exist const { error } = await borja .from('collective_invitations') .update({ accepted_at: new Date().toISOString() }) .eq('id', inv.id); // RLS denies all direct UPDATEs — PostgREST may return error or 0 rows const { data: check } = await admin .from('collective_invitations') .select('accepted_at') .eq('id', inv.id) .single(); expect(check?.accepted_at).toBeNull(); }); it('B-13: Eva (non-member) cannot create an invitation', async () => { const eva = await createClientAs(EVA_ID); const { data, error } = await eva .from('collective_invitations') .insert({ collective_id: COLLECTIVE_ID, created_by: EVA_ID, role: 'member' }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); });