+ {m.settings_sync_conflicts()} +
+ {#if rows.length > 0} + + {/if} ++ {m.settings_sync_conflicts_empty()} +
+ {:else} +local
+{JSON.stringify(row.local_version, null, 0)}
+ remote ({row.resolution})
+{JSON.stringify(row.remote_version, null, 0)}
+ diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index adfc6db..f595a7f 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/pg": "^8.11.10", + "fake-indexeddb": "^6.2.5", "typescript": "^5.7.3", "vite": "^6.0.7", "vitest": "^2.1.8" diff --git a/packages/test-utils/tests/sync-conflicts.test.ts b/packages/test-utils/tests/sync-conflicts.test.ts new file mode 100644 index 0000000..94c4045 --- /dev/null +++ b/packages/test-utils/tests/sync-conflicts.test.ts @@ -0,0 +1,149 @@ +/** + * 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'); + }); +}); diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index b158f93..0685c59 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -344,6 +344,55 @@ export interface Database { }; Relationships: []; }; + sync_conflicts: { + Row: { + id: string; + user_id: string; + collective_id: string | null; + entity_type: string; + entity_id: string; + local_version: Json; + remote_version: Json; + resolution: 'remote_won' | 'local_won'; + created_at: string; + }; + Insert: { + id?: string; + user_id: string; + collective_id?: string | null; + entity_type: string; + entity_id: string; + local_version: Json; + remote_version: Json; + resolution: 'remote_won' | 'local_won'; + created_at?: string; + }; + Update: { + id?: string; + user_id?: string; + collective_id?: string | null; + entity_type?: string; + entity_id?: string; + local_version?: Json; + remote_version?: Json; + resolution?: 'remote_won' | 'local_won'; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'sync_conflicts_user_id_fkey'; + columns: ['user_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'sync_conflicts_collective_id_fkey'; + columns: ['collective_id']; + referencedRelation: 'collectives'; + referencedColumns: ['id']; + } + ]; + }; item_frequency: { Row: { collective_id: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45ed41b..76a6c7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: '@types/pg': specifier: ^8.11.10 version: 8.20.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 typescript: specifier: ^5.7.3 version: 5.9.3 diff --git a/supabase/migrations/016_sync_conflicts.sql b/supabase/migrations/016_sync_conflicts.sql new file mode 100644 index 0000000..50c3de5 --- /dev/null +++ b/supabase/migrations/016_sync_conflicts.sql @@ -0,0 +1,55 @@ +-- Migration 016: sync_conflicts (Fase 9.3) +-- +-- Append-only log of last-write-wins conflicts surfaced by the offline +-- mutation queue (apps/web/src/lib/sync/queue.ts). When the client detects +-- that a local UPDATE's pre-image disagrees with the remote row it just +-- patched (i.e. someone else mutated the same row in between), it appends +-- a row here with both payloads so users can audit and operators can +-- debug. The resolution column records which side ended up persisted — +-- always 'remote_won' under MVP's last-write-wins; the column is kept for +-- forward compatibility when a smarter merger lands. +-- +-- RLS rules: +-- * SELECT: only the user who owns the row (auth.uid() = user_id) +-- * INSERT: only the user inserting their own row +-- * UPDATE / DELETE: blocked entirely — the log is append-only. +-- +-- `collective_id` is intentionally nullable: most conflicts will be scoped +-- to a collective, but the column also covers future user-level entities +-- (e.g. theme, language) that have no collective context. We can tighten +-- to NOT NULL later if we never use it that way. + +CREATE TABLE public.sync_conflicts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + collective_id uuid NULL REFERENCES public.collectives(id) ON DELETE CASCADE, + entity_type text NOT NULL, + entity_id uuid NOT NULL, + local_version jsonb NOT NULL, + remote_version jsonb NOT NULL, + resolution text NOT NULL CHECK (resolution IN ('remote_won', 'local_won')), + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.sync_conflicts IS + 'Append-only audit log of offline-sync conflicts (last-write-wins). Inserted by the client when a queued UPDATE landed on a row that had been mutated remotely in the meantime.'; + +-- Most queries are "give me the latest N conflicts for the current user". +CREATE INDEX sync_conflicts_user_created_idx + ON public.sync_conflicts (user_id, created_at DESC); + +ALTER TABLE public.sync_conflicts ENABLE ROW LEVEL SECURITY; + +-- A user can read only their own conflicts. +CREATE POLICY sync_conflicts_select_own + ON public.sync_conflicts FOR SELECT + USING (auth.uid() = user_id); + +-- A user can insert only rows attributed to themselves. +CREATE POLICY sync_conflicts_insert_own + ON public.sync_conflicts FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- No UPDATE / DELETE policies — append-only by design. The absence of +-- ALL / UPDATE / DELETE policies + RLS being enabled means any non-owner +-- INSERT also gets rejected, and UPDATE / DELETE are uniformly denied. diff --git a/supabase/tests/010_sync_conflicts.sql b/supabase/tests/010_sync_conflicts.sql new file mode 100644 index 0000000..11a680d --- /dev/null +++ b/supabase/tests/010_sync_conflicts.sql @@ -0,0 +1,119 @@ +-- 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;