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>
This commit is contained in:
207
packages/test-utils/tests/rls-audit.test.ts
Normal file
207
packages/test-utils/tests/rls-audit.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
214
packages/test-utils/tests/search.test.ts
Normal file
214
packages/test-utils/tests/search.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* S-series: global search (`search_in_collective`) — filtering, RLS isolation,
|
||||
* trash exclusion (RN-08), and by-type / by-creator / by-date-range filters.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
|
||||
import {
|
||||
ANA_ID,
|
||||
BORJA_ID,
|
||||
EVA_ID,
|
||||
COLLECTIVE_ID,
|
||||
SEED_LIST_ID
|
||||
} from '../src/seed-constants.js';
|
||||
import { closePool } from '../src/db-helpers.js';
|
||||
|
||||
const admin = createAdminClient();
|
||||
|
||||
// Fixtures use a shared lexeme-safe token that appears as a standalone word in
|
||||
// every fixture (`simple` tsvector tokenises on whitespace — substrings don't match).
|
||||
const UNIQUE = `needle${Date.now()}`;
|
||||
const ITEM_TOKEN = `itm${Date.now()}`;
|
||||
const TASK_TOKEN = `tsk${Date.now()}`;
|
||||
const NOTE_TOKEN = `nt${Date.now()}`;
|
||||
const TRASH_TOKEN = `trash${Date.now()}`;
|
||||
|
||||
const cleanup: { itemIds: string[]; taskListIds: string[]; noteIds: string[] } = {
|
||||
itemIds: [],
|
||||
taskListIds: [],
|
||||
noteIds: []
|
||||
};
|
||||
|
||||
let taskListId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Shopping item in the seed list
|
||||
const { data: item } = await admin
|
||||
.from('shopping_items')
|
||||
.insert({
|
||||
list_id: SEED_LIST_ID,
|
||||
name: `${UNIQUE} ${ITEM_TOKEN}`,
|
||||
sort_order: 990,
|
||||
created_by: ANA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
cleanup.itemIds.push(item!.id);
|
||||
|
||||
// Task list + task
|
||||
const { data: tl } = await admin
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'Search test list', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
taskListId = tl!.id;
|
||||
cleanup.taskListIds.push(taskListId);
|
||||
|
||||
await admin
|
||||
.from('tasks')
|
||||
.insert({ list_id: taskListId, title: `${UNIQUE} ${TASK_TOKEN}`, created_by: BORJA_ID });
|
||||
|
||||
// Note (active)
|
||||
const { data: note } = await admin
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
title: `${UNIQUE} ${NOTE_TOKEN}`,
|
||||
content: 'recipe ingredients',
|
||||
created_by: ANA_ID,
|
||||
updated_by: ANA_ID
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
cleanup.noteIds.push(note!.id);
|
||||
|
||||
// Note in trash — must NOT appear in search (RN-08)
|
||||
const { data: trashedNote } = await admin
|
||||
.from('notes')
|
||||
.insert({
|
||||
collective_id: COLLECTIVE_ID,
|
||||
content: `secret ${TRASH_TOKEN} content`,
|
||||
created_by: ANA_ID,
|
||||
updated_by: ANA_ID,
|
||||
deleted_at: new Date().toISOString()
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
cleanup.noteIds.push(trashedNote!.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup.itemIds.length) {
|
||||
await admin.from('shopping_items').delete().in('id', cleanup.itemIds);
|
||||
}
|
||||
if (cleanup.noteIds.length) {
|
||||
await admin.from('notes').delete().in('id', cleanup.noteIds);
|
||||
}
|
||||
if (cleanup.taskListIds.length) {
|
||||
await admin.from('task_lists').delete().in('id', cleanup.taskListIds);
|
||||
}
|
||||
await closePool();
|
||||
});
|
||||
|
||||
type SearchRow = {
|
||||
id: string;
|
||||
result_type: 'shopping_item' | 'task' | 'note';
|
||||
title: string | null;
|
||||
snippet: string;
|
||||
collective_id: string;
|
||||
list_id: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
async function search(
|
||||
client: Awaited<ReturnType<typeof createClientAs>>,
|
||||
query: string,
|
||||
opts: { types?: SearchRow['result_type'][]; creator?: string; from?: string; to?: string } = {}
|
||||
): Promise<SearchRow[]> {
|
||||
const { data, error } = await client.rpc('search_in_collective' as never, {
|
||||
p_collective_id: COLLECTIVE_ID,
|
||||
p_query: query,
|
||||
p_types: opts.types ?? null,
|
||||
p_creator: opts.creator ?? null,
|
||||
p_from: opts.from ?? null,
|
||||
p_to: opts.to ?? null
|
||||
} as never);
|
||||
if (error) throw error;
|
||||
return (data ?? []) as SearchRow[];
|
||||
}
|
||||
|
||||
describe('search_in_collective — base queries', () => {
|
||||
it('S-01: member (Borja) finds shopping items by name', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const rows = await search(borja, ITEM_TOKEN);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.some((r) => r.result_type === 'shopping_item' && r.title?.includes(ITEM_TOKEN))).toBe(true);
|
||||
});
|
||||
|
||||
it('S-01b: member finds tasks by title', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const rows = await search(borja, TASK_TOKEN);
|
||||
expect(rows.some((r) => r.result_type === 'task' && r.title?.includes(TASK_TOKEN))).toBe(true);
|
||||
});
|
||||
|
||||
it('S-01c: member finds notes by title or content', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const rows = await search(borja, NOTE_TOKEN);
|
||||
expect(rows.some((r) => r.result_type === 'note' && (r.title?.includes(NOTE_TOKEN) ?? false))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_in_collective — RLS isolation', () => {
|
||||
it('S-02: Eva (non-member of main collective) sees zero rows', async () => {
|
||||
const eva = await createClientAs(EVA_ID);
|
||||
const rows = await search(eva, ITEM_TOKEN);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('S-02b: passing a stranger collective_id yields zero rows (RLS blocks the joins)', async () => {
|
||||
// Ana querying for a completely unrelated UUID
|
||||
const ana = await createClientAs(ANA_ID);
|
||||
const { data } = await ana.rpc('search_in_collective' as never, {
|
||||
p_collective_id: '00000000-0000-0000-0000-000000000000',
|
||||
p_query: ITEM_TOKEN,
|
||||
p_types: null,
|
||||
p_creator: null,
|
||||
p_from: null,
|
||||
p_to: null
|
||||
} as never);
|
||||
expect(data ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_in_collective — trash exclusion (RN-08)', () => {
|
||||
it('S-03: a soft-deleted note does not appear in results', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const rows = await search(borja, TRASH_TOKEN);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_in_collective — filters', () => {
|
||||
it('S-04a: type filter restricts to shopping_item only', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const rows = await search(borja, UNIQUE, { types: ['shopping_item'] });
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.every((r) => r.result_type === 'shopping_item')).toBe(true);
|
||||
});
|
||||
|
||||
it('S-04b: creator filter restricts to a single user', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
// Only the task is by BORJA; item + note are by ANA
|
||||
const rows = await search(borja, UNIQUE, { creator: BORJA_ID });
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.every((r) => r.created_by === BORJA_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('S-04c: date range in the far past returns no rows', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const rows = await search(borja, UNIQUE, {
|
||||
from: '1970-01-01T00:00:00Z',
|
||||
to: '1970-12-31T23:59:59Z'
|
||||
});
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('S-04d: date range covering now returns at least one match', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const from = new Date(Date.now() - 1000 * 60 * 60).toISOString();
|
||||
const rows = await search(borja, UNIQUE, { from });
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user