mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* feat(migration): v35 auto-RLS event trigger — new tables always secure Postgres event trigger that fires on every CREATE TABLE and auto-enables Row Level Security. Prevents the face_detections bug: tables created outside gbrain migrations (Baku, manual SQL, other apps sharing the same Supabase project) were silently unprotected until gbrain doctor caught it. This is the Supabase-recommended approach — no dashboard toggle exists. Migration v35 (auto_rls_event_trigger): - CREATE FUNCTION auto_enable_rls() — event trigger handler - CREATE EVENT TRIGGER auto_rls_on_create_table — fires on ddl_command_end - PGLite: no-op (no RLS engine, no event triggers) Tests (3 cases): - Event trigger exists after migration - New table automatically gets RLS enabled - auto_enable_rls function exists Closes the gap identified in production on 2026-05-04 when face_detections was found without RLS. * feat(migration): v35 — drop FORCE, public-only, bundle backfill, cover CTAS+SELECT INTO Apply the corrections surfaced by /plan-eng-review + /codex consult against the original PR #612. The trigger now matches v24/v29/schema.sql posture (ENABLE only, no FORCE), scopes to the public schema, and covers all three table-creation syntaxes Postgres reports. Bundles a one-time backfill of every existing public.* table without RLS, honoring doctor.ts's GBRAIN:RLS_EXEMPT regex and quoting identifiers via format('%I.%I'). Drops the EXCEPTION wrap inside the trigger so per-table failures abort the offending CREATE TABLE (loud rollback) rather than producing a silent permissive default. Drops the hand-rolled privilege pre-check — the runner already fails loud on permission errors and gates the version bump. Breaking change: operators with intentionally-RLS-off public tables must add the GBRAIN:RLS_EXEMPT comment before upgrading or the backfill will flip them on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Wintermute <wintermute@garrytan.com> Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
8.6 KiB
TypeScript
178 lines
8.6 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
|
|
describe('doctor command', () => {
|
|
test('doctor module exports runDoctor', async () => {
|
|
const { runDoctor } = await import('../src/commands/doctor.ts');
|
|
expect(typeof runDoctor).toBe('function');
|
|
});
|
|
|
|
test('LATEST_VERSION is importable from migrate', async () => {
|
|
const { LATEST_VERSION } = await import('../src/core/migrate.ts');
|
|
expect(typeof LATEST_VERSION).toBe('number');
|
|
});
|
|
|
|
test('CLI registers doctor command', async () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', '--help'],
|
|
cwd: import.meta.dir + '/..',
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(stdout).toContain('doctor');
|
|
expect(stdout).toContain('--fast');
|
|
});
|
|
|
|
test('frontmatter_integrity subcheck added in v0.22.4', async () => {
|
|
const fs = await import('fs');
|
|
const src = fs.readFileSync('src/commands/doctor.ts', 'utf8');
|
|
// Subcheck name and call into shared scanner are present.
|
|
expect(src).toContain("name: 'frontmatter_integrity'");
|
|
expect(src).toContain('scanBrainSources');
|
|
// Fix hint points at the right CLI command.
|
|
expect(src).toContain('gbrain frontmatter validate');
|
|
});
|
|
|
|
test('Check interface supports issues array', async () => {
|
|
// `Check` is a TypeScript interface — type-only, no runtime value.
|
|
// Importing it for type assertion is enough to validate the shape.
|
|
const check: import('../src/commands/doctor.ts').Check = {
|
|
name: 'resolver_health',
|
|
status: 'warn',
|
|
message: '2 issues',
|
|
issues: [{ type: 'unreachable', skill: 'test-skill', action: 'Add trigger row' }],
|
|
};
|
|
expect(check.issues).toHaveLength(1);
|
|
expect(check.issues![0].action).toContain('trigger');
|
|
});
|
|
|
|
test('runDoctor accepts null engine for filesystem-only mode', async () => {
|
|
const { runDoctor } = await import('../src/commands/doctor.ts');
|
|
// runDoctor should accept null engine — it runs filesystem checks only.
|
|
// Signature is (engine, args, dbSource?) — third param is optional and
|
|
// used by --fast to distinguish "no config" from "user skipped DB check".
|
|
// Function.length counts required params only (JS ignores ?-marked).
|
|
expect(runDoctor.length).toBeGreaterThanOrEqual(2);
|
|
expect(runDoctor.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
// Bug 7 — --fast should differentiate "no config anywhere" from "user
|
|
// chose --fast with GBRAIN_DATABASE_URL / config-file URL present".
|
|
test('getDbUrlSource reflects GBRAIN_DATABASE_URL env var', async () => {
|
|
const { getDbUrlSource } = await import('../src/core/config.ts');
|
|
const orig = process.env.GBRAIN_DATABASE_URL;
|
|
const origAlt = process.env.DATABASE_URL;
|
|
try {
|
|
process.env.GBRAIN_DATABASE_URL = 'postgresql://test@localhost/x';
|
|
expect(getDbUrlSource()).toBe('env:GBRAIN_DATABASE_URL');
|
|
delete process.env.GBRAIN_DATABASE_URL;
|
|
process.env.DATABASE_URL = 'postgresql://test@localhost/x';
|
|
expect(getDbUrlSource()).toBe('env:DATABASE_URL');
|
|
} finally {
|
|
if (orig === undefined) delete process.env.GBRAIN_DATABASE_URL;
|
|
else process.env.GBRAIN_DATABASE_URL = orig;
|
|
if (origAlt === undefined) delete process.env.DATABASE_URL;
|
|
else process.env.DATABASE_URL = origAlt;
|
|
}
|
|
});
|
|
|
|
test('doctor --fast emits source-specific message when URL present', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
// The source-aware message must reference the variable name so users
|
|
// know where their URL is coming from.
|
|
expect(source).toContain('Skipping DB checks (--fast mode, URL present from');
|
|
// The null-source fallback must still mention both config + env paths.
|
|
expect(source).toContain('GBRAIN_DATABASE_URL');
|
|
});
|
|
|
|
// v0.12.2 reliability wave — doctor detects JSONB double-encode + truncated
|
|
// bodies and points users at the standalone `gbrain repair-jsonb` command.
|
|
// Detection only; repair lives in src/commands/repair-jsonb.ts.
|
|
test('doctor source contains jsonb_integrity and markdown_body_completeness checks', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
expect(source).toContain('jsonb_integrity');
|
|
expect(source).toContain('markdown_body_completeness');
|
|
expect(source).toContain('gbrain repair-jsonb');
|
|
});
|
|
|
|
test('jsonb_integrity check covers the four JSONB sites fixed in v0.12.1', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
expect(source).toMatch(/table:\s*'pages'.*col:\s*'frontmatter'/);
|
|
expect(source).toMatch(/table:\s*'raw_data'.*col:\s*'data'/);
|
|
expect(source).toMatch(/table:\s*'ingest_log'.*col:\s*'pages_updated'/);
|
|
expect(source).toMatch(/table:\s*'files'.*col:\s*'metadata'/);
|
|
});
|
|
|
|
// v0.18 RLS hardening — regression guards for PR #336 + schema backfill.
|
|
// These are structural assertions on the source string so a silent revert
|
|
// of the severity or the IN-filter removal fails loudly without a live DB.
|
|
test('RLS check scans ALL public tables (no hardcoded tablename IN list near the RLS block)', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
expect(rlsBlock.length).toBeGreaterThan(0);
|
|
// Old pattern — must not come back. If it does, we're filtering the scan
|
|
// to a hardcoded set and every plugin/user table is invisible again.
|
|
expect(rlsBlock).not.toMatch(/tablename\s+IN\s*\(/);
|
|
// New semantics: the scan query has no WHERE-IN filter, just schemaname='public'.
|
|
expect(rlsBlock).toMatch(/FROM\s+pg_tables\b[\s\S]{0,200}schemaname\s*=\s*'public'/);
|
|
});
|
|
|
|
test('RLS check raises status=fail with quoted-identifier remediation SQL', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
// Severity upgraded from 'warn' to 'fail' so `gbrain doctor` exits 1 on gaps.
|
|
expect(rlsBlock).toMatch(/status:\s*'fail'/);
|
|
// Remediation SQL uses quoted identifiers — safe for names with hyphens,
|
|
// reserved words, mixed case.
|
|
expect(rlsBlock).toContain('ALTER TABLE "public"."');
|
|
expect(rlsBlock).toContain('ENABLE ROW LEVEL SECURITY');
|
|
});
|
|
|
|
test('RLS check skips on PGLite (no PostgREST, not applicable)', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
expect(rlsBlock).toMatch(/engine\.kind\s*===\s*'pglite'/);
|
|
expect(rlsBlock).toContain('PGLite');
|
|
});
|
|
|
|
test('RLS check reads pg_description and recognizes the GBRAIN:RLS_EXEMPT escape hatch', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
expect(rlsBlock).toContain('obj_description');
|
|
expect(rlsBlock).toContain('GBRAIN:RLS_EXEMPT');
|
|
// The regex must require a non-empty reason= segment. "Blood" is in the
|
|
// requirement to write a real justification, not just the prefix.
|
|
expect(rlsBlock).toMatch(/reason=/);
|
|
});
|
|
|
|
// v0.26.7 — rls_event_trigger check (post-install drift detector for v35).
|
|
// Lives AFTER `// 6. Schema version` so the existing `// 5. RLS` slice
|
|
// tests stay intact (codex correction).
|
|
test('rls_event_trigger check exists, scoped after schema_version, healthy on (O,A) only', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const idx7 = source.indexOf('// 7. RLS event trigger');
|
|
const idx8 = source.indexOf('// 8. Embedding health');
|
|
expect(idx7).toBeGreaterThan(0);
|
|
expect(idx8).toBeGreaterThan(idx7);
|
|
const block = source.slice(idx7, idx8);
|
|
expect(block).toContain("name: 'rls_event_trigger'");
|
|
// Healthy set is origin (`O`) or always (`A`). `R` is replica-only and
|
|
// would not fire in normal sessions; `D` is disabled. Both are warn states.
|
|
expect(block).toMatch(/evtenabled\s*!==\s*'O'[\s\S]*?evtenabled\s*!==\s*'A'/);
|
|
// PGLite skip path is required (no event triggers there).
|
|
expect(block).toMatch(/engine\.kind\s*===\s*'pglite'/);
|
|
// Recovery command names the migration version explicitly.
|
|
expect(block).toContain('--force-retry 35');
|
|
});
|
|
});
|