mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
setupDB() TRUNCATEs every data table on whatever DATABASE_URL points at, and run-e2e.sh deliberately preserves an exported DATABASE_URL — one stray environment variable away from wiping a production brain, with no guard of any kind (found during an independent review, 2026-07-18). assertSafeE2eDatabaseUrl (pure, unit-tested) now runs before any connection: allowed when the database name carries "test" as a word segment (the gbrain_test convention used by CI and .env.testing.example), or when GBRAIN_E2E_ALLOW_DB names the exact database intentionally. Refusal is loud and actionable. 7 unit tests, no DB required. Co-authored-by: Aleksei Razsadin <razsoc.01@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
/**
|
|
* E2E test helpers: DB lifecycle, fixture import, timing, and diagnostics.
|
|
*
|
|
* Usage in test files:
|
|
* import { setupDB, teardownDB, importFixtures, time } from './helpers.ts';
|
|
* beforeAll(async () => { await setupDB(); await importFixtures(); });
|
|
* afterAll(async () => { await teardownDB(); });
|
|
*/
|
|
|
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
import { join, resolve, relative, dirname, basename, extname } from 'path';
|
|
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
|
import * as db from '../../src/core/db.ts';
|
|
import { importFromContent } from '../../src/core/import-file.ts';
|
|
import { parseMarkdown } from '../../src/core/markdown.ts';
|
|
|
|
// Load .env.testing if present
|
|
const envPath = resolve(import.meta.dir, '../../.env.testing');
|
|
if (existsSync(envPath)) {
|
|
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eq = trimmed.indexOf('=');
|
|
if (eq === -1) continue;
|
|
const key = trimmed.slice(0, eq);
|
|
const val = trimmed.slice(eq + 1);
|
|
if (!process.env[key]) process.env[key] = val;
|
|
}
|
|
}
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
const FIXTURES_DIR = resolve(import.meta.dir, 'fixtures');
|
|
|
|
let engine: PostgresEngine | null = null;
|
|
|
|
const ALL_TABLES = [
|
|
// v0.31: facts must come BEFORE pages too (FK to sources, but tests
|
|
// seed via direct SQL so the row stays referenced until truncated).
|
|
'facts',
|
|
// v0.28: takes + synthesis_evidence MUST come BEFORE pages because they FK pages.id
|
|
'synthesis_evidence',
|
|
'takes',
|
|
'content_chunks',
|
|
'links',
|
|
'tags',
|
|
'raw_data',
|
|
'timeline_entries',
|
|
'page_versions',
|
|
'ingest_log',
|
|
'files',
|
|
// v0.43 (#2095): volunteered-context feedback log — no FK to pages (slug
|
|
// join), but stale rows poison stats/count assertions across runs.
|
|
'context_volunteer_events',
|
|
'pages', // last because of foreign keys
|
|
'config',
|
|
'minion_attachments',
|
|
'minion_inbox',
|
|
'minion_jobs',
|
|
];
|
|
|
|
/**
|
|
* Check if a real database is available for E2E tests.
|
|
*/
|
|
export function hasDatabase(): boolean {
|
|
return !!DATABASE_URL;
|
|
}
|
|
|
|
/**
|
|
* Production guard: setupDB() TRUNCATEs every data table on whatever
|
|
* DATABASE_URL points at, and run-e2e.sh deliberately preserves an exported
|
|
* DATABASE_URL — so a developer with a production URL in their environment
|
|
* would wipe their real brain by running the suite. Refuse unless the
|
|
* database name identifies itself as a test database ("test" as a word
|
|
* segment, e.g. gbrain_test — the CI/.env.testing.example convention), or
|
|
* the operator explicitly opts the exact name in via GBRAIN_E2E_ALLOW_DB.
|
|
*
|
|
* Exported for unit testing; pure — no connection is made.
|
|
*/
|
|
export function assertSafeE2eDatabaseUrl(
|
|
url: string,
|
|
env: Record<string, string | undefined> = process.env,
|
|
): void {
|
|
let dbName: string;
|
|
try {
|
|
dbName = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
} catch {
|
|
throw new Error(`E2E guard: DATABASE_URL is not a parseable URL; refusing to run destructive setup.`);
|
|
}
|
|
if (!dbName) {
|
|
throw new Error(`E2E guard: DATABASE_URL has no database name; refusing to run destructive setup.`);
|
|
}
|
|
if (/(^|[_-])test([_-]|$)/i.test(dbName)) return;
|
|
if (env.GBRAIN_E2E_ALLOW_DB && env.GBRAIN_E2E_ALLOW_DB === dbName) return;
|
|
throw new Error(
|
|
`E2E guard: database "${dbName}" does not look like a test database ` +
|
|
`(expected "test" as a name segment, e.g. gbrain_test). setupDB() would ` +
|
|
`TRUNCATE every data table in it. If this is intentional, set ` +
|
|
`GBRAIN_E2E_ALLOW_DB=${dbName} to opt in explicitly.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Connect to DB, run schema init, truncate all tables.
|
|
* Call in beforeAll() of each test file.
|
|
*/
|
|
export async function setupDB(): Promise<PostgresEngine> {
|
|
if (!DATABASE_URL) {
|
|
throw new Error('DATABASE_URL not set. Copy .env.testing.example to .env.testing and configure it.');
|
|
}
|
|
assertSafeE2eDatabaseUrl(DATABASE_URL);
|
|
|
|
// Disconnect any prior connection (clean slate)
|
|
await db.disconnect();
|
|
|
|
// Connect fresh
|
|
await db.connect({ database_url: DATABASE_URL });
|
|
await db.initSchema();
|
|
|
|
// Truncate all data tables (preserves schema + extensions).
|
|
// Some tables (e.g. v0.28 takes/synthesis_evidence) only exist after
|
|
// migrations run via engine.connect() below, so skip non-existent tables.
|
|
const conn = db.getConnection();
|
|
for (const table of ALL_TABLES) {
|
|
try {
|
|
await conn.unsafe(`TRUNCATE ${table} CASCADE`);
|
|
} catch (e: unknown) {
|
|
const code = (e as { code?: string })?.code;
|
|
if (code !== '42P01') throw e; // 42P01 = undefined_table; ignore those
|
|
}
|
|
}
|
|
|
|
// Re-seed config (initSchema inserts default config rows)
|
|
await conn.unsafe(`
|
|
INSERT INTO config (key, value) VALUES ('schema_version', '1')
|
|
ON CONFLICT (key) DO NOTHING
|
|
`);
|
|
|
|
engine = new PostgresEngine();
|
|
await engine.connect({ database_url: DATABASE_URL });
|
|
// Apply MIGRATIONS via the engine path. db.initSchema above only runs the
|
|
// embedded SCHEMA_SQL baseline; migrations like v31 (takes) live in the
|
|
// MIGRATIONS array and only run when engine.initSchema() executes them.
|
|
// Idempotent: re-running migrations on an already-migrated DB is a no-op.
|
|
await engine.initSchema();
|
|
return engine;
|
|
}
|
|
|
|
/**
|
|
* Disconnect from DB. Call in afterAll() of each test file.
|
|
*/
|
|
export async function teardownDB(): Promise<void> {
|
|
if (engine) {
|
|
await engine.disconnect();
|
|
engine = null;
|
|
}
|
|
await db.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Get the current engine instance.
|
|
*/
|
|
export function getEngine(): PostgresEngine {
|
|
if (!engine) throw new Error('setupDB() must be called first');
|
|
return engine;
|
|
}
|
|
|
|
/**
|
|
* Get a raw DB connection for direct queries.
|
|
*/
|
|
export function getConn() {
|
|
return db.getConnection();
|
|
}
|
|
|
|
/**
|
|
* Import all fixture files from test/e2e/fixtures/ into the brain.
|
|
* Returns the list of import results.
|
|
*/
|
|
export async function importFixtures() {
|
|
const e = getEngine();
|
|
const results: Array<{ slug: string; status: string; chunks: number }> = [];
|
|
|
|
const files = findMarkdownFiles(FIXTURES_DIR);
|
|
for (const filePath of files) {
|
|
const relPath = relative(FIXTURES_DIR, filePath);
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const parsed = parseMarkdown(content, relPath);
|
|
const result = await importFromContent(e, parsed.slug, content, { noEmbed: true });
|
|
results.push(result);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Import a single fixture by its relative path within fixtures/.
|
|
*/
|
|
export async function importFixture(relativePath: string) {
|
|
const e = getEngine();
|
|
const filePath = join(FIXTURES_DIR, relativePath);
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const parsed = parseMarkdown(content, relativePath);
|
|
return importFromContent(e, parsed.slug, content, { noEmbed: true });
|
|
}
|
|
|
|
/**
|
|
* Recursively find all .md files in a directory.
|
|
*/
|
|
function findMarkdownFiles(dir: string): string[] {
|
|
const results: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
const stat = statSync(full);
|
|
if (stat.isDirectory()) {
|
|
results.push(...findMarkdownFiles(full));
|
|
} else if (extname(entry) === '.md') {
|
|
results.push(full);
|
|
}
|
|
}
|
|
return results.sort();
|
|
}
|
|
|
|
/**
|
|
* Time a function and return [result, durationMs].
|
|
*/
|
|
export async function time<T>(fn: () => Promise<T>): Promise<[T, number]> {
|
|
const start = performance.now();
|
|
const result = await fn();
|
|
const dur = performance.now() - start;
|
|
return [result, dur];
|
|
}
|
|
|
|
/**
|
|
* Dump DB state for debugging on test failure.
|
|
*/
|
|
export async function dumpDBState(): Promise<string> {
|
|
const conn = db.getConnection();
|
|
const pages = await conn.unsafe(`SELECT slug, type, title FROM pages ORDER BY slug`);
|
|
const chunkCount = await conn.unsafe(`SELECT count(*) as n FROM content_chunks`);
|
|
const linkCount = await conn.unsafe(`SELECT count(*) as n FROM links`);
|
|
const tagCount = await conn.unsafe(`SELECT count(*) as n FROM tags`);
|
|
|
|
const lines = [
|
|
`=== DB STATE DUMP ===`,
|
|
`Pages (${pages.length}):`,
|
|
...pages.map((p: any) => ` ${p.slug} [${p.type}] "${p.title}"`),
|
|
`Chunks: ${chunkCount[0]?.n ?? 0}`,
|
|
`Links: ${linkCount[0]?.n ?? 0}`,
|
|
`Tags: ${tagCount[0]?.n ?? 0}`,
|
|
`=== END DUMP ===`,
|
|
];
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Get the fixtures directory path.
|
|
*/
|
|
export const FIXTURES_PATH = FIXTURES_DIR;
|
|
|
|
/**
|
|
* Rewind schema state to `targetVersion` and re-apply migrations in
|
|
* version order up to (and including) `targetVersion`.
|
|
*
|
|
* Used by the v15→v23 chain E2E to simulate the field report's starting
|
|
* state (schema at v15, ~100 real rows, kick off full migration). Before
|
|
* this helper, no CLI flag or test hook existed to stop the migration
|
|
* chain at an intermediate version — `gbrain init --migrate-only` always
|
|
* ran to latest.
|
|
*
|
|
* Caveats:
|
|
* - Postgres-only (the v15→v23 chain only matters for Postgres anyway;
|
|
* PGLite's schema is monolithic).
|
|
* - Destructive to existing DDL: dropping a migration that created
|
|
* a table leaves tables behind. This helper re-runs migrations from
|
|
* the CURRENT version (whatever config.version says) upward. It
|
|
* does NOT rewind down. Pair with `setupDB()` to truncate first.
|
|
* - After calling this, the schema is whatever `v1..targetVersion`
|
|
* produced. Columns added by v(targetVersion+1)+ will be missing.
|
|
*
|
|
* Call order in a test:
|
|
* const engine = await setupDB(); // latest schema, empty tables
|
|
* await conn.unsafe('ALTER TABLE ...'); // optional: drop forward columns
|
|
* await setConfigVersion(1); // reset schema version
|
|
* await runMigrationsUpTo(engine, 15); // advance to v15
|
|
* // ... seed fixture data at v15 shape ...
|
|
* await runMigrationsFromCurrent(engine); // advance to latest
|
|
*/
|
|
export async function runMigrationsUpTo(
|
|
engine: PostgresEngine,
|
|
targetVersion: number,
|
|
): Promise<void> {
|
|
const { MIGRATIONS } = await import('../../src/core/migrate.ts');
|
|
const sorted = [...MIGRATIONS].sort((a, b) => a.version - b.version);
|
|
|
|
const currentStr = await engine.getConfig('version');
|
|
const current = parseInt(currentStr || '1', 10);
|
|
|
|
const pending = sorted.filter(m => m.version > current && m.version <= targetVersion);
|
|
|
|
for (const m of pending) {
|
|
const sql = m.sqlFor?.[engine.kind] ?? m.sql;
|
|
if (sql) {
|
|
// Inline the transactional wrap from runMigrationSQL so we can
|
|
// stop cleanly at targetVersion without re-invoking the full
|
|
// runMigrations loop.
|
|
await engine.transaction(async (tx) => {
|
|
await tx.runMigration(m.version, sql);
|
|
});
|
|
}
|
|
if (m.handler) {
|
|
await m.handler(engine);
|
|
}
|
|
await engine.setConfig('version', String(m.version));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset `config.version` to the given value. Used to simulate a brain
|
|
* at an older schema state before applying partial migrations via
|
|
* `runMigrationsUpTo`. Does NOT undo DDL — just moves the version
|
|
* marker that the migration runner uses to decide what's pending.
|
|
*/
|
|
export async function setConfigVersion(version: number): Promise<void> {
|
|
const conn = db.getConnection();
|
|
await conn.unsafe(`
|
|
INSERT INTO config (key, value) VALUES ('version', $1)
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
`, [String(version)]);
|
|
}
|