/** * R-series (Isolation): RLS must be enforced on Realtime postgres_changes too. * * Eva is not a member of the seed collective. Even if she subscribes to the * shopping_items channel for SEED_LIST_ID, the Realtime server evaluates RLS * using her JWT when deciding which events to forward, and should drop every * event whose row is not visible to her. * * We also verify the positive case: a guest of the same collective (David) DOES * receive events, because the existing shopping_items SELECT policy allows * guests to read items in their collectives. */ import { describe, it, expect, afterAll, afterEach } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { subscribePostgresChanges } from '../src/realtime-helpers.js'; import { ANA_ID, DAVID_ID, EVA_ID, SEED_LIST_ID } from '../src/seed-constants.js'; import type { SupabaseClient } from '@supabase/supabase-js'; const admin = createAdminClient(); const createdItemIds: string[] = []; const activeClients: SupabaseClient[] = []; function track(c: SupabaseClient): SupabaseClient { activeClients.push(c); return c; } afterEach(async () => { for (const c of activeClients.splice(0)) { await c.removeAllChannels(); await c.realtime.disconnect(); } }); afterAll(async () => { if (createdItemIds.length > 0) { await admin.from('shopping_items').delete().in('id', createdItemIds); } }); describe('Realtime RLS isolation — shopping_items', () => { it('R-I-01: David (guest) receives events for items in his collective', async () => { const david = track(await createClientAs(DAVID_ID)); const ana = track(await createClientAs(ANA_ID)); const sub = await subscribePostgresChanges<{ id: string; name: string }>(david, { table: 'shopping_items', event: 'INSERT', filter: `list_id=eq.${SEED_LIST_ID}` }); try { const itemName = `R-I-01-${Date.now()}`; const { data } = await ana .from('shopping_items') .insert({ list_id: SEED_LIST_ID, name: itemName, sort_order: 800, created_by: ANA_ID }) .select('id') .single(); createdItemIds.push(data!.id); const evt = await sub.waitFor((e) => e.new.name === itemName); expect(evt.new.name).toBe(itemName); } finally { await sub.unsubscribe(); await ana.removeAllChannels(); await david.removeAllChannels(); } }); it('R-I-02: Eva (non-member) receives NO events for items in the seed list', async () => { const eva = track(await createClientAs(EVA_ID)); const ana = track(await createClientAs(ANA_ID)); const sub = await subscribePostgresChanges<{ id: string; name: string }>(eva, { table: 'shopping_items', event: 'INSERT', filter: `list_id=eq.${SEED_LIST_ID}` }); try { // Ana inserts something that should NOT reach Eva const forbiddenName = `R-I-02-forbidden-${Date.now()}`; const { data } = await ana .from('shopping_items') .insert({ list_id: SEED_LIST_ID, name: forbiddenName, sort_order: 801, created_by: ANA_ID }) .select('id') .single(); createdItemIds.push(data!.id); // Give Realtime up to 2s to (not) deliver. If RLS works, Eva sees nothing. await new Promise((r) => setTimeout(r, 2_000)); const evt = sub.events.find((e) => e.new.name === forbiddenName); expect(evt).toBeUndefined(); expect(sub.events.length).toBe(0); } finally { await sub.unsubscribe(); await ana.removeAllChannels(); await eva.removeAllChannels(); } }); });