diff --git a/package.json b/package.json index 73eaf18a9..267237d7e 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "ci:select-e2e": "bun run scripts/select-e2e.ts", "typecheck": "tsc --noEmit", "check:jsonb": "scripts/check-jsonb-pattern.sh", + "check:search-path": "scripts/check-search-path.sh", "check:no-double-retry": "scripts/check-no-double-retry.sh", "check:batch-audit-site": "scripts/check-batch-audit-site.sh", "check:worker-lock-renewal-shape": "scripts/check-worker-lock-renewal-shape.sh", diff --git a/scripts/check-search-path.sh b/scripts/check-search-path.sh new file mode 100755 index 000000000..8158d9489 --- /dev/null +++ b/scripts/check-search-path.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# CI guard (#1647 / #171): every trigger function in the canonical schema base +# files MUST pin `SET search_path`. Without it, an unqualified reference inside +# the function body resolves through the caller's search_path, so a same-named +# object in a user-controlled schema could shadow it. Migration v120 ALTERs +# existing brains; this guard keeps fresh-install function definitions correct +# so a NEW trigger function can't reintroduce the gap. Mirrors the +# check-jsonb-pattern.sh guard philosophy (a written rule caused the disease; +# a guard cures it). +# +# Scope: schema base files only (src/schema.sql, src/core/pglite-schema.ts). +# Historical migration bodies in migrate.ts are append-only and not rescanned; +# the runtime doctor probe (pg_proc.proconfig) covers the live post-migration +# state on real brains. +# +# Usage: scripts/check-search-path.sh +# Exit: 0 when all trigger functions pin search_path, 1 otherwise. + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$ROOT" + +FILES="src/schema.sql src/core/pglite-schema.ts src/core/schema-embedded.ts" + +# A hardened header reads `... RETURNS trigger SET search_path = ... AS $tag$`. +# An UNHARDENED one reads `... RETURNS trigger AS $tag$` — match that form and +# (belt-and-suspenders) drop any line that already mentions search_path. +BAD="$(grep -nEi 'CREATE OR REPLACE FUNCTION [a-z_]+\(\) RETURNS trigger AS ' $FILES 2>/dev/null | grep -vi 'search_path' || true)" + +if [ -n "$BAD" ]; then + echo "ERROR: trigger function(s) missing SET search_path in schema base files:" + echo "$BAD" + echo + echo "Add 'SET search_path = pg_catalog, public' to the function header, e.g.:" + echo " CREATE OR REPLACE FUNCTION foo() RETURNS trigger SET search_path = pg_catalog, public AS \$\$" + echo "See #1647 / #171." + exit 1 +fi + +echo "OK: all trigger functions in schema base files pin search_path" diff --git a/scripts/run-verify-parallel.sh b/scripts/run-verify-parallel.sh index 5aed8b982..f03c560b9 100755 --- a/scripts/run-verify-parallel.sh +++ b/scripts/run-verify-parallel.sh @@ -38,6 +38,7 @@ CHECKS=( "check:proposal-pii" "check:test-names" "check:jsonb" + "check:search-path" "check:source-id-projection" "check:source-config-leak" "check:progress" diff --git a/src/core/migrate.ts b/src/core/migrate.ts index acc57aec2..54155c58e 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -862,7 +862,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF NOT has_bypass THEN -- Fail the migration loudly instead of WARNING + version-bump. -- The runner unconditionally records schema_version on success, @@ -1151,7 +1151,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF NOT has_bypass THEN RAISE EXCEPTION 'v29 cathedral_ii_code_edges_rls: role % does not have BYPASSRLS privilege — cannot enable RLS safely. Re-run as postgres (or another BYPASSRLS role). The migration will retry automatically on the next initSchema call.', current_user; END IF; @@ -1239,7 +1239,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF has_bypass THEN ALTER TABLE takes ENABLE ROW LEVEL SECURITY; ALTER TABLE synthesis_evidence ENABLE ROW LEVEL SECURITY; @@ -1341,7 +1341,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF has_bypass THEN ALTER TABLE dream_verdicts ENABLE ROW LEVEL SECURITY; END IF; @@ -1380,7 +1380,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF NOT has_bypass THEN RAISE EXCEPTION 'v31 eval_capture_tables: role % does not have BYPASSRLS privilege — cannot enable RLS safely. Re-run as postgres (or another BYPASSRLS role). The migration will retry automatically on the next initSchema call.', current_user; END IF; @@ -1499,7 +1499,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF has_bypass THEN ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY; ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY; @@ -1720,7 +1720,7 @@ export const MIGRATIONS: Migration[] = [ has_bypass BOOLEAN; r record; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF NOT has_bypass THEN -- Same posture as v24: raise to abort the migration so the runner -- leaves config.version unbumped and retries on the next call. @@ -2112,7 +2112,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF has_bypass THEN ALTER TABLE drift_decisions ENABLE ROW LEVEL SECURITY; END IF; @@ -2365,7 +2365,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF has_bypass THEN ALTER TABLE facts ENABLE ROW LEVEL SECURITY; END IF; @@ -4389,7 +4389,7 @@ export const MIGRATIONS: Migration[] = [ DECLARE has_bypass BOOLEAN; BEGIN - SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user; + SELECT EXISTS (SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'USAGE') AND (r.rolbypassrls OR r.rolsuper)) INTO has_bypass; -- #1385: superuser + inherited-role BYPASSRLS, not just the role's own rolbypassrls IF has_bypass THEN ALTER TABLE take_domain_assignments ENABLE ROW LEVEL SECURITY; END IF; @@ -5367,6 +5367,78 @@ export const MIGRATIONS: Migration[] = [ END $$; `, }, + { + version: 120, + name: 'schema_lint_hardening_search_path_security_invoker', + // v0.42 schema-lint hardening wave (#1647 / #171). + // + // (b) security_invoker on the page_links view: pre-fix the view ran with + // the definer/owner's privileges, so the anon / PostgREST role could + // read `links` (which has RLS) THROUGH the view, bypassing RLS. This + // is the single ERROR-severity Supabase lint. Postgres-only — PGLite + // is embedded/single-user with no anon role and no PostgREST, so the + // view has no RLS-bypass surface there (and security_invoker carries + // no benefit). Guarded with IF EXISTS for very old brains. + // + // (a)/(#171) search_path on every gbrain-owned trigger/event function: + // an unqualified reference (e.g. `FROM timeline_entries`) resolves + // through the caller's search_path, so a same-named object in a + // user-controlled schema could shadow it. Pinning search_path closes + // that. ALTER FUNCTION (NOT CREATE OR REPLACE) leaves each body + // untouched — lowest drift risk, and critically safe for the + // load-bearing `auto_enable_rls` event-trigger function (codex #3). + // The IF EXISTS loop is engine-agnostic and skips functions a given + // brain never created (e.g. auto_enable_rls + the NOTIFY/chunk + // trigger functions are Postgres-only — codex #4). + // + // Regression guard is a doctor probe (pg_proc.proconfig) + scripts/ + // check-search-path.sh, NOT a migration verify-hook — hooks don't run on + // brains already stamped past this version (learning: migration-verify-hook- + // never-runs-on-stamped-brains). Fresh installs are born correct: the + // function defs in schema.sql / pglite-schema.ts carry SET search_path too. + idempotent: true, + sql: '', // engine-specific via sqlFor + sqlFor: { + postgres: ` + ALTER VIEW IF EXISTS page_links SET (security_invoker = on); + + DO $$ + DECLARE fn text; + BEGIN + FOREACH fn IN ARRAY ARRAY[ + 'bump_page_generation_fn','bump_page_generation_clock_fn', + 'update_chunk_search_vector','update_page_search_vector', + 'notify_minion_job_change','auto_enable_rls' + ] LOOP + IF EXISTS ( + SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = fn + ) THEN + EXECUTE format('ALTER FUNCTION public.%I() SET search_path = pg_catalog, public', fn); + END IF; + END LOOP; + END $$; + `, + pglite: ` + DO $$ + DECLARE fn text; + BEGIN + FOREACH fn IN ARRAY ARRAY[ + 'bump_page_generation_fn','bump_page_generation_clock_fn', + 'update_chunk_search_vector','update_page_search_vector', + 'notify_minion_job_change' + ] LOOP + IF EXISTS ( + SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = fn + ) THEN + EXECUTE format('ALTER FUNCTION public.%I() SET search_path = pg_catalog, public', fn); + END IF; + END LOOP; + END $$; + `, + }, + }, ]; export const LATEST_VERSION = MIGRATIONS.length > 0 diff --git a/src/core/pglite-schema.ts b/src/core/pglite-schema.ts index 1a0bc6c19..e73367748 100644 --- a/src/core/pglite-schema.ts +++ b/src/core/pglite-schema.ts @@ -124,7 +124,7 @@ CREATE TABLE IF NOT EXISTS pages ( -- bookmark gate fires for any cache row stored before the new page existed. -- UPDATE: bumps only when content columns IS DISTINCT FROM (allow-list of -- 10 widened per D6) so read-time mutations don't invalidate every cache. -CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger AS $func$ +CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$ BEGIN IF (TG_OP = 'INSERT') THEN NEW.generation := COALESCE((SELECT MAX(generation) FROM pages), 0) + 1; @@ -179,7 +179,7 @@ SELECT setval('page_generation_clock_seq', GREATEST( COALESCE((SELECT MAX(generation) FROM pages), 0) )); -CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$ +CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$ BEGIN PERFORM nextval('page_generation_clock_seq'); RETURN NULL; @@ -1016,7 +1016,7 @@ ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector; CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector); -CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS $$ +CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger SET search_path = pg_catalog, public AS $$ DECLARE timeline_text TEXT; BEGIN diff --git a/src/core/schema-embedded.ts b/src/core/schema-embedded.ts index 32beb37d0..8fb66be2d 100644 --- a/src/core/schema-embedded.ts +++ b/src/core/schema-embedded.ts @@ -164,7 +164,7 @@ CREATE TABLE IF NOT EXISTS pages ( -- content columns IS DISTINCT FROM (allow-list widened per D6 + codex #3 -- to include title/type/page_kind/corpus_generation/content_hash) so -- read-time mutations don't invalidate every cache row. -CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger AS \$func\$ +CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger SET search_path = pg_catalog, public AS \$func\$ BEGIN IF (TG_OP = 'INSERT') THEN NEW.generation := COALESCE((SELECT MAX(generation) FROM pages), 0) + 1; @@ -244,7 +244,7 @@ SELECT setval('page_generation_clock_seq', GREATEST( COALESCE((SELECT MAX(generation) FROM pages), 0) )); -CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS \$func\$ +CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger SET search_path = pg_catalog, public AS \$func\$ BEGIN PERFORM nextval('page_generation_clock_seq'); RETURN NULL; @@ -360,7 +360,7 @@ CREATE INDEX IF NOT EXISTS content_chunks_stale_idx -- NL queries ("how do we handle errors") rank doc-comment hits above body text. -- BEFORE INSERT OR UPDATE OF specific columns — only refires when those change, -- not on every chunk update (e.g., embedding refresh doesn't trigger rebuild). -CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS \$fn\$ +CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER SET search_path = pg_catalog, public AS \$fn\$ BEGIN NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') || @@ -819,7 +819,7 @@ ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector; CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector); -- Function to rebuild search_vector for a page -CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS \$\$ +CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger SET search_path = pg_catalog, public AS \$\$ DECLARE timeline_text TEXT; BEGIN @@ -1358,7 +1358,7 @@ CREATE INDEX IF NOT EXISTS think_ab_results_recent_idx ON think_ab_results (source_id, ran_at DESC); -- NOTIFY trigger for real-time job events (Postgres only, not PGLite) -CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger AS \$\$ +CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger SET search_path = pg_catalog, public AS \$\$ BEGIN PERFORM pg_notify('minion_jobs', json_build_object( 'id', NEW.id, 'status', NEW.status, 'name', NEW.name, diff --git a/src/schema.sql b/src/schema.sql index dcf9455e1..292240f2f 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS pages ( -- content columns IS DISTINCT FROM (allow-list widened per D6 + codex #3 -- to include title/type/page_kind/corpus_generation/content_hash) so -- read-time mutations don't invalidate every cache row. -CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger AS $func$ +CREATE OR REPLACE FUNCTION bump_page_generation_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$ BEGIN IF (TG_OP = 'INSERT') THEN NEW.generation := COALESCE((SELECT MAX(generation) FROM pages), 0) + 1; @@ -240,7 +240,7 @@ SELECT setval('page_generation_clock_seq', GREATEST( COALESCE((SELECT MAX(generation) FROM pages), 0) )); -CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger AS $func$ +CREATE OR REPLACE FUNCTION bump_page_generation_clock_fn() RETURNS trigger SET search_path = pg_catalog, public AS $func$ BEGIN PERFORM nextval('page_generation_clock_seq'); RETURN NULL; @@ -356,7 +356,7 @@ CREATE INDEX IF NOT EXISTS content_chunks_stale_idx -- NL queries ("how do we handle errors") rank doc-comment hits above body text. -- BEFORE INSERT OR UPDATE OF specific columns — only refires when those change, -- not on every chunk update (e.g., embedding refresh doesn't trigger rebuild). -CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS $fn$ +CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER SET search_path = pg_catalog, public AS $fn$ BEGIN NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') || @@ -815,7 +815,7 @@ ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector; CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector); -- Function to rebuild search_vector for a page -CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS $$ +CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger SET search_path = pg_catalog, public AS $$ DECLARE timeline_text TEXT; BEGIN @@ -1354,7 +1354,7 @@ CREATE INDEX IF NOT EXISTS think_ab_results_recent_idx ON think_ab_results (source_id, ran_at DESC); -- NOTIFY trigger for real-time job events (Postgres only, not PGLite) -CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger AS $$ +CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger SET search_path = pg_catalog, public AS $$ BEGIN PERFORM pg_notify('minion_jobs', json_build_object( 'id', NEW.id, 'status', NEW.status, 'name', NEW.name, diff --git a/test/e2e/postgres-bootstrap.test.ts b/test/e2e/postgres-bootstrap.test.ts index 1924829f1..32b31f32f 100644 --- a/test/e2e/postgres-bootstrap.test.ts +++ b/test/e2e/postgres-bootstrap.test.ts @@ -90,4 +90,31 @@ describe.skipIf(skip)('PostgresEngine forward-reference bootstrap (E2E)', () => await engine.initSchema(); expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION)); }); + + // Migration v120 — schema-lint hardening (#1647 / #171). Postgres-only + // assertions (security_invoker has no surface on embedded PGLite). + test('v120: page_links view runs with security_invoker=on (#1647b)', async () => { + await engine.initSchema(); + const rows = await engine.executeRaw<{ reloptions: string[] | null }>( + `SELECT c.reloptions FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'page_links' AND c.relkind = 'v'`, + ); + expect(rows.length).toBe(1); + expect(JSON.stringify(rows[0].reloptions ?? [])).toContain('security_invoker=on'); + }); + + test('v120: trigger + event-trigger functions pin search_path, incl auto_enable_rls (#1647a/#171)', async () => { + await engine.initSchema(); + const rows = await engine.executeRaw<{ proname: string; proconfig: unknown }>( + `SELECT p.proname, p.proconfig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname IN ('bump_page_generation_fn','bump_page_generation_clock_fn', + 'update_chunk_search_vector','update_page_search_vector', + 'notify_minion_job_change','auto_enable_rls')`, + ); + expect(rows.length).toBeGreaterThanOrEqual(5); + for (const r of rows) { + expect(JSON.stringify(r.proconfig ?? [])).toContain('search_path='); + } + }); }); diff --git a/test/migration-v120.test.ts b/test/migration-v120.test.ts new file mode 100644 index 000000000..b6fac0c06 --- /dev/null +++ b/test/migration-v120.test.ts @@ -0,0 +1,48 @@ +/** + * Migration v120 — schema-lint hardening (#1647 / #171). + * + * Validates the search_path pin lands on the PGLite trigger functions and that + * the migration is idempotent. (PGLite is Postgres 17.5, so this also + * empirically confirms `ALTER FUNCTION ... SET search_path` runs on PGLite — + * the engine-asymmetry concern from the eng-review codex pass.) The + * security_invoker + auto_enable_rls assertions are Postgres-only and live in + * the Postgres bootstrap E2E. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { runMigrations } from '../src/core/migrate.ts'; + +describe('migration v120 — search_path hardening', () => { + let engine: PGLiteEngine; + + beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); // applies all migrations through LATEST_VERSION (incl. v120) + }); + + afterAll(async () => { + await engine.disconnect(); + }); + + test('PGLite trigger functions carry SET search_path after migrations', async () => { + const rows = await engine.executeRaw<{ proname: string; proconfig: unknown }>( + `SELECT p.proname, p.proconfig + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname IN ('bump_page_generation_fn','bump_page_generation_clock_fn','update_page_search_vector')`, + ); + expect(rows.length).toBe(3); + for (const r of rows) { + // proconfig is a text[] like {search_path=pg_catalog, public}; coerce to a + // string so the assertion is robust to driver array shape. + expect(JSON.stringify(r.proconfig ?? [])).toContain('search_path='); + } + }, 30000); + + test('re-running migrations after initSchema is idempotent (0 applied, no error)', async () => { + const res = await runMigrations(engine); + expect(res.applied).toBe(0); + }, 30000); +});