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:
145
supabase/migrations/010_search_vectors.sql
Normal file
145
supabase/migrations/010_search_vectors.sql
Normal file
@@ -0,0 +1,145 @@
|
||||
-- Migration 010: full-text search — tsvector columns + GIN indexes + search function
|
||||
--
|
||||
-- Search uses `simple` text-search config (no stemming) — the app is bilingual
|
||||
-- (en + es) and a stemmer tuned to one language degrades the other. Clients can
|
||||
-- still match partial words by appending `:*` to the query.
|
||||
--
|
||||
-- Each domain table (`shopping_items`, `tasks`, `notes`) gets a STORED GENERATED
|
||||
-- `search` column backed by a GIN index. Generated-always means Postgres keeps
|
||||
-- the vector in sync with every INSERT/UPDATE — no trigger needed.
|
||||
--
|
||||
-- `search_in_collective()` runs SECURITY INVOKER so RLS still applies. Trash is
|
||||
-- excluded per RN-08 (notes via `deleted_at IS NULL`; items via the parent list's
|
||||
-- `deleted_at`; tasks have no trash).
|
||||
|
||||
-- ── Generated tsvector columns ───────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.shopping_items
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED;
|
||||
|
||||
CREATE INDEX shopping_items_search_idx ON public.shopping_items USING GIN (search);
|
||||
|
||||
ALTER TABLE public.tasks
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, ''))) STORED;
|
||||
|
||||
CREATE INDEX tasks_search_idx ON public.tasks USING GIN (search);
|
||||
|
||||
ALTER TABLE public.notes
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, ''))
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX notes_search_idx ON public.notes USING GIN (search);
|
||||
|
||||
-- ── Search function ──────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TYPE public.search_result_type AS ENUM ('shopping_item', 'task', 'note');
|
||||
|
||||
DROP FUNCTION IF EXISTS public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.search_in_collective(
|
||||
p_collective_id uuid,
|
||||
p_query text,
|
||||
p_types public.search_result_type[] DEFAULT NULL,
|
||||
p_creator uuid DEFAULT NULL,
|
||||
p_from timestamptz DEFAULT NULL,
|
||||
p_to timestamptz DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
result_type public.search_result_type,
|
||||
title text,
|
||||
snippet text,
|
||||
collective_id uuid,
|
||||
list_id uuid,
|
||||
created_by uuid,
|
||||
created_at timestamptz,
|
||||
rank real
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
WITH q AS (
|
||||
SELECT websearch_to_tsquery('simple', coalesce(p_query, '')) AS query
|
||||
)
|
||||
-- shopping_items
|
||||
SELECT
|
||||
si.id,
|
||||
'shopping_item'::public.search_result_type,
|
||||
si.name AS title,
|
||||
si.name AS snippet,
|
||||
sl.collective_id,
|
||||
sl.id AS list_id,
|
||||
si.created_by,
|
||||
si.created_at,
|
||||
ts_rank(si.search, q.query)::real AS rank
|
||||
FROM public.shopping_items si
|
||||
JOIN public.shopping_lists sl ON sl.id = si.list_id
|
||||
CROSS JOIN q
|
||||
WHERE sl.collective_id = p_collective_id
|
||||
AND sl.deleted_at IS NULL
|
||||
AND (p_types IS NULL OR 'shopping_item' = ANY (p_types))
|
||||
AND (p_creator IS NULL OR si.created_by = p_creator)
|
||||
AND (p_from IS NULL OR si.created_at >= p_from)
|
||||
AND (p_to IS NULL OR si.created_at <= p_to)
|
||||
AND si.search @@ q.query
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- tasks
|
||||
SELECT
|
||||
t.id,
|
||||
'task'::public.search_result_type,
|
||||
t.title AS title,
|
||||
t.title AS snippet,
|
||||
tl.collective_id,
|
||||
tl.id AS list_id,
|
||||
t.created_by,
|
||||
t.created_at,
|
||||
ts_rank(t.search, q.query)::real AS rank
|
||||
FROM public.tasks t
|
||||
JOIN public.task_lists tl ON tl.id = t.list_id
|
||||
CROSS JOIN q
|
||||
WHERE tl.collective_id = p_collective_id
|
||||
AND (p_types IS NULL OR 'task' = ANY (p_types))
|
||||
AND (p_creator IS NULL OR t.created_by = p_creator)
|
||||
AND (p_from IS NULL OR t.created_at >= p_from)
|
||||
AND (p_to IS NULL OR t.created_at <= p_to)
|
||||
AND t.search @@ q.query
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- notes (trash and permanently deleted excluded by deleted_at filter; per
|
||||
-- analysis RN-08 search does not return trashed content)
|
||||
SELECT
|
||||
n.id,
|
||||
'note'::public.search_result_type,
|
||||
n.title AS title,
|
||||
left(coalesce(n.content, ''), 160) AS snippet,
|
||||
n.collective_id,
|
||||
NULL::uuid AS list_id,
|
||||
n.created_by,
|
||||
n.created_at,
|
||||
ts_rank(n.search, q.query)::real AS rank
|
||||
FROM public.notes n
|
||||
CROSS JOIN q
|
||||
WHERE n.collective_id = p_collective_id
|
||||
AND n.deleted_at IS NULL
|
||||
AND (p_types IS NULL OR 'note' = ANY (p_types))
|
||||
AND (p_creator IS NULL OR n.created_by = p_creator)
|
||||
AND (p_from IS NULL OR n.created_at >= p_from)
|
||||
AND (p_to IS NULL OR n.created_at <= p_to)
|
||||
AND n.search @@ q.query
|
||||
|
||||
ORDER BY rank DESC, created_at DESC
|
||||
LIMIT 200;
|
||||
$$;
|
||||
|
||||
-- Allow the anon + authenticated roles to call the function (RLS still filters rows).
|
||||
GRANT EXECUTE ON FUNCTION public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz)
|
||||
TO anon, authenticated;
|
||||
78
supabase/tests/007_search_tsvector.sql
Normal file
78
supabase/tests/007_search_tsvector.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- pgTAP: search_vectors — generated tsvector columns + GIN indexes + function — Fase 4
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/007_search_tsvector.sql
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(9);
|
||||
|
||||
-- Column existence — stored generated tsvector
|
||||
SELECT has_column('public', 'shopping_items', 'search',
|
||||
'SV-01: shopping_items.search column exists');
|
||||
SELECT has_column('public', 'tasks', 'search',
|
||||
'SV-02: tasks.search column exists');
|
||||
SELECT has_column('public', 'notes', 'search',
|
||||
'SV-03: notes.search column exists');
|
||||
|
||||
-- GIN indexes present
|
||||
SELECT ok(
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'shopping_items' AND indexname = 'shopping_items_search_idx'
|
||||
),
|
||||
'SV-04: shopping_items_search_idx GIN index exists'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'tasks' AND indexname = 'tasks_search_idx'
|
||||
),
|
||||
'SV-05: tasks_search_idx GIN index exists'
|
||||
);
|
||||
|
||||
SELECT ok(
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'notes' AND indexname = 'notes_search_idx'
|
||||
),
|
||||
'SV-06: notes_search_idx GIN index exists'
|
||||
);
|
||||
|
||||
-- Function exists and is callable
|
||||
SELECT has_function('public', 'search_in_collective',
|
||||
ARRAY['uuid', 'text', 'public.search_result_type[]', 'uuid', 'timestamptz', 'timestamptz'],
|
||||
'SV-07: search_in_collective(uuid, text, types[], creator, from, to) exists');
|
||||
|
||||
-- Functional invariants on the generated vector: insert an item, vector reflects the name
|
||||
DO $$
|
||||
DECLARE
|
||||
v_item_id uuid;
|
||||
v_vector tsvector;
|
||||
BEGIN
|
||||
INSERT INTO public.shopping_items (list_id, name, sort_order, created_by)
|
||||
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'pgtap unique xyzzy', 9998,
|
||||
'11111111-1111-1111-1111-111111111111')
|
||||
RETURNING id INTO v_item_id;
|
||||
|
||||
SELECT search INTO v_vector FROM public.shopping_items WHERE id = v_item_id;
|
||||
PERFORM set_config('pgtap.vector_text', v_vector::text, false);
|
||||
PERFORM set_config('pgtap.vector_match',
|
||||
CASE WHEN v_vector @@ to_tsquery('simple', 'xyzzy') THEN 'true' ELSE 'false' END,
|
||||
false);
|
||||
END $$;
|
||||
|
||||
SELECT matches(
|
||||
current_setting('pgtap.vector_text'),
|
||||
'xyzzy',
|
||||
'SV-08: generated shopping_items.search vector contains the inserted lexeme'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
current_setting('pgtap.vector_match'),
|
||||
'true',
|
||||
'SV-09: generated shopping_items.search vector matches its tsquery'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user