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:
@@ -136,6 +136,15 @@
|
||||
"notes_empty_board_hint": "Click \"New note\" to start writing",
|
||||
"search_title": "Search",
|
||||
"search_placeholder": "Search lists, tasks, notes…",
|
||||
"search_filter_all": "All",
|
||||
"search_filter_lists": "Lists",
|
||||
"search_filter_tasks": "Tasks",
|
||||
"search_filter_notes": "Notes",
|
||||
"search_group_shopping_items": "Shopping items",
|
||||
"search_group_tasks": "Tasks",
|
||||
"search_group_notes": "Notes",
|
||||
"search_empty": "No matches",
|
||||
"search_hint": "Type at least two characters",
|
||||
"trash_restore": "Restore",
|
||||
"trash_delete_permanent": "Delete permanently",
|
||||
"trash_empty": "Trash is empty",
|
||||
|
||||
@@ -136,6 +136,15 @@
|
||||
"notes_empty_board_hint": "Haz clic en \"Nueva nota\" para empezar",
|
||||
"search_title": "Buscar",
|
||||
"search_placeholder": "Busca listas, tareas, notas…",
|
||||
"search_filter_all": "Todo",
|
||||
"search_filter_lists": "Listas",
|
||||
"search_filter_tasks": "Tareas",
|
||||
"search_filter_notes": "Notas",
|
||||
"search_group_shopping_items": "Productos",
|
||||
"search_group_tasks": "Tareas",
|
||||
"search_group_notes": "Notas",
|
||||
"search_empty": "Sin resultados",
|
||||
"search_hint": "Escribe al menos dos caracteres",
|
||||
"trash_restore": "Restaurar",
|
||||
"trash_delete_permanent": "Eliminar definitivamente",
|
||||
"trash_empty": "La papelera está vacía",
|
||||
|
||||
39
apps/web/src/lib/stores/search.ts
Normal file
39
apps/web/src/lib/stores/search.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
|
||||
export type SearchResultType = 'shopping_item' | 'task' | 'note';
|
||||
|
||||
export interface SearchRow {
|
||||
id: string;
|
||||
result_type: SearchResultType;
|
||||
title: string | null;
|
||||
snippet: string;
|
||||
collective_id: string;
|
||||
list_id: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
types?: SearchResultType[];
|
||||
creator?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export async function runSearch(
|
||||
collectiveId: string,
|
||||
query: string,
|
||||
filters: SearchFilters = {}
|
||||
): Promise<SearchRow[]> {
|
||||
const { data, error } = await getSupabase().rpc('search_in_collective' as never, {
|
||||
p_collective_id: collectiveId,
|
||||
p_query: query,
|
||||
p_types: filters.types ?? null,
|
||||
p_creator: filters.creator ?? null,
|
||||
p_from: filters.from ?? null,
|
||||
p_to: filters.to ?? null
|
||||
} as never);
|
||||
if (error) return [];
|
||||
return (data ?? []) as unknown as SearchRow[];
|
||||
}
|
||||
@@ -1,17 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { runSearch, type SearchRow, type SearchResultType } from '$lib/stores/search';
|
||||
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchRow[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
// Filter chips — at least one type must be active; if all three are active, no filter is sent.
|
||||
let showItems = $state(true);
|
||||
let showTasks = $state(true);
|
||||
let showNotes = $state(true);
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
$effect(() => {
|
||||
// Recompute whenever query or filters change; the debounce timer absorbs keystrokes.
|
||||
const q = query.trim();
|
||||
const activeTypes = [
|
||||
showItems ? ('shopping_item' as SearchResultType) : null,
|
||||
showTasks ? ('task' as SearchResultType) : null,
|
||||
showNotes ? ('note' as SearchResultType) : null
|
||||
].filter((t): t is SearchResultType => t !== null);
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (q.length < 2 || !$currentCollective) {
|
||||
results = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
const col = $currentCollective.id;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const types = activeTypes.length === 3 ? undefined : activeTypes;
|
||||
results = await runSearch(col, q, types ? { types } : {});
|
||||
loading = false;
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
const grouped = $derived({
|
||||
shopping_item: results.filter((r) => r.result_type === 'shopping_item'),
|
||||
task: results.filter((r) => r.result_type === 'task'),
|
||||
note: results.filter((r) => r.result_type === 'note')
|
||||
});
|
||||
|
||||
function linkFor(r: SearchRow): string {
|
||||
switch (r.result_type) {
|
||||
case 'shopping_item':
|
||||
return r.list_id ? `/lists/${r.list_id}` : '/lists';
|
||||
case 'task':
|
||||
return r.list_id ? `/tasks/${r.list_id}` : '/tasks';
|
||||
case 'note':
|
||||
return `/notes/${r.id}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="px-8 pb-4 pt-8">
|
||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.nav_search()}
|
||||
</p>
|
||||
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||
{m.search_title()}
|
||||
</h1>
|
||||
<svelte:head><title>{m.search_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex flex-col gap-3 border-b border-black/5 px-6 py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.search_title()}</h1>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<Search size={14} strokeWidth={1.5} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
bind:value={query}
|
||||
placeholder={m.search_placeholder()}
|
||||
class="w-full rounded-md bg-surface-raised pl-9 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-1 focus:ring-black/20 dark:focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filter chips -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showItems = !showItems)}
|
||||
aria-pressed={showItems}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors
|
||||
{showItems
|
||||
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-surface-raised text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
|
||||
>
|
||||
<ShoppingCart size={12} strokeWidth={1.5} />
|
||||
{m.search_filter_lists()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showTasks = !showTasks)}
|
||||
aria-pressed={showTasks}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors
|
||||
{showTasks
|
||||
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-surface-raised text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
|
||||
>
|
||||
<CheckSquare size={12} strokeWidth={1.5} />
|
||||
{m.search_filter_tasks()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showNotes = !showNotes)}
|
||||
aria-pressed={showNotes}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors
|
||||
{showNotes
|
||||
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'bg-surface-raised text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
|
||||
>
|
||||
<FileText size={12} strokeWidth={1.5} />
|
||||
{m.search_filter_notes()}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||
<!-- Fase 4: search content -->
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if query.trim().length < 2}
|
||||
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
|
||||
{:else if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if results.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.search_empty()}</p>
|
||||
{:else}
|
||||
{#if grouped.shopping_item.length > 0}
|
||||
<section data-testid="search-group-shopping_item" class="mb-6">
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<ShoppingCart size={12} strokeWidth={1.5} />
|
||||
{m.search_group_shopping_items()}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.shopping_item as r (r.id)}
|
||||
<a
|
||||
href={linkFor(r)}
|
||||
data-testid="search-result"
|
||||
class="block rounded-md bg-surface-raised px-3 py-2 text-sm text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<p class="font-medium">{r.title ?? ''}</p>
|
||||
{#if r.snippet && r.snippet !== r.title}
|
||||
<p class="truncate text-xs text-text-secondary">{r.snippet}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if grouped.task.length > 0}
|
||||
<section data-testid="search-group-task" class="mb-6">
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<CheckSquare size={12} strokeWidth={1.5} />
|
||||
{m.search_group_tasks()}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.task as r (r.id)}
|
||||
<a
|
||||
href={linkFor(r)}
|
||||
data-testid="search-result"
|
||||
class="block rounded-md bg-surface-raised px-3 py-2 text-sm text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<p class="font-medium">{r.title ?? ''}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if grouped.note.length > 0}
|
||||
<section data-testid="search-group-note" class="mb-6">
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<FileText size={12} strokeWidth={1.5} />
|
||||
{m.search_group_notes()}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.note as r (r.id)}
|
||||
<a
|
||||
href={linkFor(r)}
|
||||
data-testid="search-result"
|
||||
class="block rounded-md bg-surface-raised px-3 py-2 text-sm text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
{#if r.title}
|
||||
<p class="font-medium">{r.title}</p>
|
||||
{/if}
|
||||
<p class="truncate text-xs text-text-secondary">{r.snippet}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
49
apps/web/tests/e2e/search.test.ts
Normal file
49
apps/web/tests/e2e/search.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* SR-series (UI) — Fase 4.1
|
||||
*
|
||||
* Global search at /search. Debounced input (300ms) feeds
|
||||
* search_in_collective() and groups results by type (Lists / Tasks / Notes).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
test.describe('Global search', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
});
|
||||
|
||||
test('SR-01: searching "milk" surfaces the seeded shopping item', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
const input = page.getByPlaceholder(/search|buscar/i);
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill('Milk');
|
||||
|
||||
// Wait beyond the 300ms debounce.
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// The shopping item group exists and contains a row with "Milk".
|
||||
const group = page.getByTestId('search-group-shopping_item');
|
||||
await expect(group).toBeVisible({ timeout: 5_000 });
|
||||
await expect(group.getByText(/milk/i).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('SR-02: deactivating the Lists filter hides shopping items from results', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
const input = page.getByPlaceholder(/search|buscar/i);
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill('Milk');
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Seeded "Milk" lives in shopping_items — it must be visible before the filter toggle
|
||||
await expect(page.getByTestId('search-group-shopping_item')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Deactivate the "Lists" chip (multi-toggle filter). The shopping_item group disappears.
|
||||
await page.getByRole('button', { name: /^lists$|^listas$/i }).click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await expect(page.getByTestId('search-group-shopping_item')).toHaveCount(0, {
|
||||
timeout: 3_000
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user