-- pgTAP: public.sync_conflicts table + RLS (migration 016) -- Run with: psql -U postgres -d postgres -f supabase/tests/010_sync_conflicts.sql -- -- Fase 9.3 — append-only audit log of offline-sync last-write-wins -- conflicts. The tests cover: -- * structure (table exists, RLS enabled, expected columns) -- * RLS reads scoped to auth.uid() -- * RLS inserts only allowed when row.user_id = auth.uid() -- * UPDATE and DELETE blocked across the board (no policy = denied) CREATE EXTENSION IF NOT EXISTS pgtap; BEGIN; SELECT plan(12); -- ── Structure ─────────────────────────────────────────────────────────── SELECT has_table( 'public', 'sync_conflicts', 'public.sync_conflicts exists' ); SELECT ok( (SELECT relrowsecurity FROM pg_class WHERE oid = 'public.sync_conflicts'::regclass), 'sync_conflicts has RLS enabled' ); SELECT col_not_null( 'public', 'sync_conflicts', 'user_id', 'sync_conflicts.user_id is NOT NULL' ); SELECT col_is_null( 'public', 'sync_conflicts', 'collective_id', 'sync_conflicts.collective_id is nullable (some conflicts have no collective scope)' ); -- ── Seed two distinct conflicts (one for Ana, one for Borja) as admin ── -- IDs map to seed.sql. DO $$ BEGIN INSERT INTO public.sync_conflicts (user_id, collective_id, entity_type, entity_id, local_version, remote_version, resolution) VALUES ('11111111-1111-1111-1111-111111111111', NULL, 'shopping_item', gen_random_uuid(), '{"name":"ana-local"}'::jsonb, '{"name":"ana-remote"}'::jsonb, 'remote_won'), ('22222222-2222-2222-2222-222222222222', NULL, 'shopping_item', gen_random_uuid(), '{"name":"borja-local"}'::jsonb, '{"name":"borja-remote"}'::jsonb, 'remote_won'); END $$; -- ── RLS: Ana sees only her own row ────────────────────────────────────── SET LOCAL ROLE authenticated; SET LOCAL request.jwt.claims TO '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}'; SELECT is( (SELECT count(*)::int FROM public.sync_conflicts), 1, 'Ana can read only the one row she owns' ); SELECT is( (SELECT user_id FROM public.sync_conflicts LIMIT 1), '11111111-1111-1111-1111-111111111111'::uuid, 'Visible row belongs to Ana' ); -- ── RLS: Ana can INSERT her own row ───────────────────────────────────── INSERT INTO public.sync_conflicts (user_id, entity_type, entity_id, local_version, remote_version, resolution) VALUES ('11111111-1111-1111-1111-111111111111', 'shopping_item', gen_random_uuid(), '{"a":1}'::jsonb, '{"a":2}'::jsonb, 'remote_won'); SELECT is( (SELECT count(*)::int FROM public.sync_conflicts), 2, 'Ana can insert a row attributed to herself' ); -- ── RLS: Ana cannot INSERT a row owned by Borja ───────────────────────── SELECT throws_ok( $$INSERT INTO public.sync_conflicts (user_id, entity_type, entity_id, local_version, remote_version, resolution) VALUES ('22222222-2222-2222-2222-222222222222', 'shopping_item', gen_random_uuid(), '{}'::jsonb, '{}'::jsonb, 'remote_won')$$, '42501', NULL, 'Ana cannot insert a row attributed to Borja' ); -- ── RLS: UPDATE is denied (no policy) ─────────────────────────────────── -- An UPDATE with no matching policy returns 0 rows updated (silently), -- not a privilege error. Assert nothing was modified. WITH bumped AS ( UPDATE public.sync_conflicts SET resolution = 'local_won' WHERE user_id = '11111111-1111-1111-1111-111111111111' RETURNING 1 ) SELECT is( (SELECT count(*)::int FROM bumped), 0, 'UPDATE on sync_conflicts is denied (no policy)' ); -- ── RLS: DELETE is denied (no policy) ─────────────────────────────────── WITH gone AS ( DELETE FROM public.sync_conflicts WHERE user_id = '11111111-1111-1111-1111-111111111111' RETURNING 1 ) SELECT is( (SELECT count(*)::int FROM gone), 0, 'DELETE on sync_conflicts is denied (no policy)' ); -- ── RLS: Borja cannot see Ana's rows ──────────────────────────────────── SET LOCAL request.jwt.claims TO '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}'; SELECT is( (SELECT count(*)::int FROM public.sync_conflicts WHERE user_id = '11111111-1111-1111-1111-111111111111'), 0, 'Borja cannot see Ana''s sync_conflicts rows' ); -- ── RLS: select_own — Borja sees their own seeded row only ────────────── SELECT is( (SELECT count(*)::int FROM public.sync_conflicts), 1, 'Borja sees only the row attributed to them' ); SELECT * FROM finish(); ROLLBACK;