Files
gbrain/test/e2e/postgres-bootstrap.test.ts
T
Garry TanandClaude Opus 4.8 b19eb35548 fix(security): schema-lint hardening migration (search_path + view security_invoker)
Migration v120 brings existing brains to the same posture as fresh installs:

- ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view
  honors the caller's RLS instead of the owner's (the view-through-RLS bypass).
- ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions
  (both engines, IF EXISTS so engine-only functions are skipped; body untouched,
  so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171.
- Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor
  superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install
  no longer aborts (#1385).

Fresh-install function definitions in schema.sql / pglite-schema.ts are
born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a
new CI guard (wired into verify) that fails if a trigger function in the schema
base files is added without SET search_path. Postgres-only assertions live in the
bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:38:45 -07:00

121 lines
5.4 KiB
TypeScript

/**
* E2E test for PostgresEngine forward-reference bootstrap.
*
* Codex caught that `test/e2e/helpers.ts:74` uses the standalone
* `db.initSchema()` from `src/core/db.ts`, which only runs SCHEMA_SQL and
* never calls runMigrations(). A test using that helper would NOT exercise
* `PostgresEngine.initSchema()`'s reordered path, producing false-positive
* coverage. This test deliberately bypasses the standard helper and
* instantiates `PostgresEngine` directly, calling `engine.initSchema()` so
* the bootstrap → SCHEMA_SQL → runMigrations sequence runs end-to-end.
*
* Covers issues #366, #375, #378 — Postgres-side wedges where pre-v0.18
* brains crashed on `column "source_id" does not exist`.
*
* NOTE: snapshot-based historical state simulation is out of scope for this
* wave (would require maintaining historical schema dumps). The test
* mutates a fresh-LATEST brain to a pre-v0.18 shape; codex flagged this as
* approximate. Acceptable here because the bootstrap's contract is narrow:
* "given a brain that lacks the specific forward-references, initSchema
* produces a brain at LATEST." The test exercises exactly that contract.
*
* Run: DATABASE_URL=postgresql://... bun run test:e2e test/e2e/postgres-bootstrap.test.ts
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { LATEST_VERSION } from '../../src/core/migrate.ts';
const DATABASE_URL = process.env.DATABASE_URL;
const skip = !DATABASE_URL;
describe.skipIf(skip)('PostgresEngine forward-reference bootstrap (E2E)', () => {
let engine: PostgresEngine;
beforeAll(async () => {
engine = new PostgresEngine();
await engine.connect({ database_url: DATABASE_URL! });
}, 30_000);
afterAll(async () => {
await engine.disconnect();
});
test('PostgresEngine.initSchema applies bootstrap → SCHEMA_SQL → migrations on pre-v0.18 brain', async () => {
// First call: bring the test DB to LATEST shape so we have something to mutate.
await engine.initSchema();
// Clear data from prior tests in the suite. Adding a UNIQUE(slug)
// constraint below would fail if multi-source fixtures left rows with
// duplicate slugs across sources (which is valid under the composite
// UNIQUE this test is undoing).
const conn = (engine as any).sql;
await conn.unsafe(`TRUNCATE pages, content_chunks, links, tags, raw_data, timeline_entries, page_versions, ingest_log RESTART IDENTITY CASCADE`);
// Mutate to pre-v0.18 shape: drop source_id and the sources table.
// The advisory lock is released between initSchema calls, so this
// direct DDL won't deadlock.
await conn.unsafe(`
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
DROP INDEX IF EXISTS idx_pages_source_id;
ALTER TABLE pages DROP COLUMN IF EXISTS source_id CASCADE;
DROP TABLE IF EXISTS sources CASCADE;
`);
await engine.setConfig('version', '20');
// The path under test: full PostgresEngine.initSchema() including the
// bootstrap call, SCHEMA_SQL replay, and runMigrations chain.
await engine.initSchema();
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
// Verify the forward-referenced column exists after upgrade.
const colCheck = await conn`
SELECT column_name FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'pages'
AND column_name = 'source_id'
`;
expect(colCheck).toHaveLength(1);
// Verify the default source row was seeded.
const srcCheck = await conn`SELECT id FROM sources WHERE id = 'default'`;
expect(srcCheck).toHaveLength(1);
});
test('PostgresEngine.initSchema is idempotent on a brain already at LATEST', async () => {
// Fresh-LATEST brain. Calling initSchema again must not error and must
// not regress the version.
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=');
}
});
});