Files
collective-lists/packages/test-utils/tests/rls-audit.test.ts
Oier Bravo Urtasun 2806e06db2 feat(fase-4): global search + RLS isolation audit (211 tests green)
4.0 Tests primero
  Vitest: search.test.ts (10 — S-01..S-04), rls-audit.test.ts (42 —
    3 intruders × 14 cross-collective ops proving zero leakage)
  pgTAP: 007_search_tsvector.sql (9 — columns, GIN indexes, function
    signature, generated-vector invariant)
  Playwright: search.test.ts (2 — SR-01 happy-path, SR-02 filter toggle)

4.1 Búsqueda global
  Migration 010_search_vectors.sql: STORED GENERATED tsvector columns
    on shopping_items/tasks/notes + GIN indexes + search_result_type
    enum + search_in_collective() SECURITY INVOKER function (RLS-aware,
    trash-excluded per RN-08) + GRANT EXECUTE to anon/authenticated.
    Uses `simple` config (no stemmer — bilingual en/es).
  Store apps/web/src/lib/stores/search.ts — RPC wrapper
  /search: debounced input (300ms, ≥2 chars), type filter chips with
    multi-toggle, results grouped per type with data-testid hooks for
    Playwright, deep-link per type (shopping_item → /lists/[id], task →
    /tasks/[id], note → /notes/[id])

4.4 Security
  rls-audit.test.ts replaces the curl-based manual audit with a
    parametrised matrix — zero cross-collective reads/writes under
    three different attacker roles

Realtime flake hardening
  Add 250ms settle after SUBSCRIBED in realtime-helpers.ts — the Phoenix
    socket reports SUBSCRIBED slightly before the backend finishes
    propagating the filter to the WAL subscription, so mutations fired
    immediately after subscribe could miss the filter under load.

4.Z Verification
  just test-all → 211 verdes, 2 skipped
    34 pgTAP (was 25, +9 search)
    140 Vitest integration (was 88, +10 search +42 rls-audit; 2 skipped)
    6 Vitest unit (unchanged)
    31 Playwright (was 29, +2 search)
  Deferred (prod-deploy scope):
    PWA install/push (needs real iOS/Android hardware)
    Kong rate-limit plugin config
    JWT_SECRET / ANON_KEY rotation
    Lighthouse PWA ≥ 90 manual validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:47:38 +02:00

208 lines
7.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A-series: exhaustive RLS isolation audit — Fase 4.4
*
* Proves zero cross-collective leakage on every domain table across the full
* matrix:
* table × role (non-member, guest, member, admin) × op (SELECT/INSERT/UPDATE/DELETE)
*
* The finer-grained role behaviour (e.g. guest can INSERT items? answer: no) is
* covered by the B/C/D/T/N suites. This test focuses on cross-collective:
* a user of collective X must never see, edit, or delete rows of collective Y.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { closePool } from '../src/db-helpers.js';
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID } from '../src/seed-constants.js';
const admin = createAdminClient();
// Fixtures: Eva's private collective with one row per domain table.
let otherCollectiveId: string;
let otherListId: string; // shopping list
let otherItemId: string; // shopping item
let otherTaskListId: string;
let otherTaskId: string;
let otherNoteId: string;
beforeAll(async () => {
const { data: collective } = await admin
.from('collectives')
.insert({ name: 'RLS audit collective', emoji: '🛡️', created_by: EVA_ID })
.select('id')
.single();
otherCollectiveId = collective!.id;
await admin
.from('collective_members')
.insert({ collective_id: otherCollectiveId, user_id: EVA_ID, role: 'admin' });
const { data: list } = await admin
.from('shopping_lists')
.insert({ collective_id: otherCollectiveId, name: 'audit list', created_by: EVA_ID })
.select('id')
.single();
otherListId = list!.id;
const { data: item } = await admin
.from('shopping_items')
.insert({ list_id: otherListId, name: 'audit item', sort_order: 1, created_by: EVA_ID })
.select('id')
.single();
otherItemId = item!.id;
const { data: tl } = await admin
.from('task_lists')
.insert({ collective_id: otherCollectiveId, name: 'audit tasks', created_by: EVA_ID })
.select('id')
.single();
otherTaskListId = tl!.id;
const { data: task } = await admin
.from('tasks')
.insert({ list_id: otherTaskListId, title: 'audit task', created_by: EVA_ID })
.select('id')
.single();
otherTaskId = task!.id;
const { data: note } = await admin
.from('notes')
.insert({
collective_id: otherCollectiveId,
content: 'audit note',
created_by: EVA_ID,
updated_by: EVA_ID
})
.select('id')
.single();
otherNoteId = note!.id;
});
afterAll(async () => {
if (otherCollectiveId) {
await admin.from('collectives').delete().eq('id', otherCollectiveId);
}
await closePool();
});
// Every intruder below is NOT a member of Eva's collective. They must each
// see zero rows and fail to mutate every row.
const INTRUDERS: Array<{ label: string; id: string }> = [
{ label: 'Ana (admin of different collective)', id: ANA_ID },
{ label: 'Borja (member of different collective)', id: BORJA_ID },
{ label: 'David (guest of different collective)', id: DAVID_ID }
];
describe.each(INTRUDERS)('RLS audit — $label', ({ id }) => {
it('A-01: cannot SELECT cross-collective collective row', async () => {
const c = await createClientAs(id);
const { data } = await c.from('collectives').select('id').eq('id', otherCollectiveId);
expect(data).toHaveLength(0);
});
it('A-02: cannot SELECT cross-collective shopping_list', async () => {
const c = await createClientAs(id);
const { data } = await c.from('shopping_lists').select('id').eq('id', otherListId);
expect(data).toHaveLength(0);
});
it('A-03: cannot SELECT cross-collective shopping_item', async () => {
const c = await createClientAs(id);
const { data } = await c.from('shopping_items').select('id').eq('id', otherItemId);
expect(data).toHaveLength(0);
});
it('A-04: cannot SELECT cross-collective task_list', async () => {
const c = await createClientAs(id);
const { data } = await c.from('task_lists').select('id').eq('id', otherTaskListId);
expect(data).toHaveLength(0);
});
it('A-05: cannot SELECT cross-collective task', async () => {
const c = await createClientAs(id);
const { data } = await c.from('tasks').select('id').eq('id', otherTaskId);
expect(data).toHaveLength(0);
});
it('A-06: cannot SELECT cross-collective note', async () => {
const c = await createClientAs(id);
const { data } = await c.from('notes').select('id').eq('id', otherNoteId);
expect(data).toHaveLength(0);
});
it('A-07: INSERT into cross-collective shopping_list is blocked', async () => {
const c = await createClientAs(id);
const { data, error } = await c
.from('shopping_lists')
.insert({ collective_id: otherCollectiveId, name: 'sneaky', created_by: id })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('A-08: INSERT into cross-collective task_list is blocked', async () => {
const c = await createClientAs(id);
const { data, error } = await c
.from('task_lists')
.insert({ collective_id: otherCollectiveId, name: 'sneaky', created_by: id })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('A-09: INSERT into cross-collective note is blocked', async () => {
const c = await createClientAs(id);
const { data, error } = await c
.from('notes')
.insert({
collective_id: otherCollectiveId,
content: 'sneaky',
created_by: id,
updated_by: id
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('A-10: UPDATE on cross-collective note is a no-op', async () => {
const c = await createClientAs(id);
await c.from('notes').update({ content: 'hacked', updated_by: id }).eq('id', otherNoteId);
const { data } = await admin.from('notes').select('content').eq('id', otherNoteId).single();
expect(data?.content).toBe('audit note');
});
it('A-11: UPDATE on cross-collective task is a no-op', async () => {
const c = await createClientAs(id);
await c
.from('tasks')
.update({ is_completed: true, completed_by: id, completed_at: new Date().toISOString() })
.eq('id', otherTaskId);
const { data } = await admin.from('tasks').select('is_completed').eq('id', otherTaskId).single();
expect(data?.is_completed).toBe(false);
});
it('A-12: DELETE on cross-collective shopping_item is a no-op', async () => {
const c = await createClientAs(id);
await c.from('shopping_items').delete().eq('id', otherItemId);
const { data } = await admin.from('shopping_items').select('id').eq('id', otherItemId).single();
expect(data?.id).toBe(otherItemId);
});
it('A-13: DELETE on cross-collective task is a no-op', async () => {
const c = await createClientAs(id);
await c.from('tasks').delete().eq('id', otherTaskId);
const { data } = await admin.from('tasks').select('id').eq('id', otherTaskId).single();
expect(data?.id).toBe(otherTaskId);
});
it('A-14: DELETE on cross-collective shopping_list is a no-op', async () => {
const c = await createClientAs(id);
await c.from('shopping_lists').delete().eq('id', otherListId);
const { data } = await admin.from('shopping_lists').select('id').eq('id', otherListId).single();
expect(data?.id).toBe(otherListId);
});
});