/** * SC-INT series: sync_conflicts wiring (Fase 9.3). * * The unit tests in apps/web/src/lib/sync/queue.test.ts cover the * conflict-detection logic in isolation. This file exercises the live * PostgREST path so we know: * * SC-INT-01: a user can insert a row attributed to themselves and read * it back. * SC-INT-02: a user cannot read another user's conflict rows (RLS). * SC-INT-03: UPDATE / DELETE are rejected silently (no policy) so the * table stays append-only at the API surface, not just at * the schema level. * * The combination of these three is what makes the offline-queue's * best-effort logging safe to enable in production. */ import { describe, it, expect, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { closePool } from '../src/db-helpers.js'; import { ANA_ID, BORJA_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; const admin = createAdminClient(); const insertedConflictIds: string[] = []; afterAll(async () => { if (insertedConflictIds.length > 0) { await admin.from('sync_conflicts').delete().in('id', insertedConflictIds); } await closePool(); }); describe('sync_conflicts — RLS + PostgREST contract', () => { it('SC-INT-01: a user can insert their own conflict row and read it back', async () => { const ana = await createClientAs(ANA_ID); const entityId = '00000000-0000-0000-0000-0000000000a1'; const { data, error } = await ana .from('sync_conflicts') .insert({ user_id: ANA_ID, collective_id: COLLECTIVE_ID, entity_type: 'shopping_item', entity_id: entityId, local_version: { name: 'ana-local' }, remote_version: { name: 'ana-remote' }, resolution: 'remote_won' }) .select('id') .single(); expect(error).toBeNull(); expect(data?.id).toBeTruthy(); insertedConflictIds.push(data!.id); const { data: rows } = await ana .from('sync_conflicts') .select('user_id, entity_type, resolution') .eq('id', data!.id); expect(rows ?? []).toHaveLength(1); expect(rows![0].user_id).toBe(ANA_ID); expect(rows![0].resolution).toBe('remote_won'); }); it('SC-INT-02: a user cannot read another user\'s conflict rows', async () => { // Insert as Borja (RLS still has to allow the self-insert). const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('sync_conflicts') .insert({ user_id: BORJA_ID, collective_id: COLLECTIVE_ID, entity_type: 'shopping_item', entity_id: '00000000-0000-0000-0000-0000000000b1', local_version: { name: 'borja-only' }, remote_version: { name: 'remote' }, resolution: 'remote_won' }) .select('id') .single(); expect(error).toBeNull(); insertedConflictIds.push(data!.id); // Ana cannot see it. const ana = await createClientAs(ANA_ID); const { data: visibleToAna } = await ana .from('sync_conflicts') .select('id') .eq('id', data!.id); expect(visibleToAna ?? []).toHaveLength(0); }); it('SC-INT-03: a user cannot impersonate another via user_id at insert time', async () => { const ana = await createClientAs(ANA_ID); const { data, error } = await ana .from('sync_conflicts') .insert({ user_id: BORJA_ID, // not me entity_type: 'shopping_item', entity_id: '00000000-0000-0000-0000-0000000000a2', local_version: {}, remote_version: {}, resolution: 'remote_won' }) .select('id') .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('SC-INT-04: UPDATE / DELETE are denied silently (append-only)', async () => { // Insert a row as Ana, then try to update + delete it as Ana. const ana = await createClientAs(ANA_ID); const { data: inserted, error } = await ana .from('sync_conflicts') .insert({ user_id: ANA_ID, entity_type: 'shopping_item', entity_id: '00000000-0000-0000-0000-0000000000a3', local_version: { v: 1 }, remote_version: { v: 2 }, resolution: 'remote_won' }) .select('id') .single(); expect(error).toBeNull(); insertedConflictIds.push(inserted!.id); const { data: updated } = await ana .from('sync_conflicts') .update({ resolution: 'local_won' }) .eq('id', inserted!.id) .select('id'); expect(updated ?? []).toHaveLength(0); // RLS returns 0 rows updated const { data: deleted } = await ana .from('sync_conflicts') .delete() .eq('id', inserted!.id) .select('id'); expect(deleted ?? []).toHaveLength(0); // Row is still there. const { data: stillThere } = await ana .from('sync_conflicts') .select('id, resolution') .eq('id', inserted!.id) .single(); expect(stillThere?.resolution).toBe('remote_won'); }); });