mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/**
|
|
* v0.34.1 (#861 — P0 source-isolation leak seal) E2E regression.
|
|
*
|
|
* The wave's IRON RULE: every read-side op must filter by source_id when
|
|
* the caller supplies it via SearchOpts/PageFilters/TraverseOpts. Pre-fix,
|
|
* authenticated MCP clients scoped to source-A could enumerate source-B
|
|
* pages via search / query / list_pages / traverse_graph / find_experts.
|
|
*
|
|
* Runs against PGLite in-memory so the test exercises both engines' parity
|
|
* surface (the schema-drift E2E covers Postgres independently) without
|
|
* needing a Docker container.
|
|
*
|
|
* Coverage: searchKeyword, searchVector (synthetic embedding), listPages,
|
|
* traverseGraph, traversePaths. find_experts is exercised via the same
|
|
* hybridSearch path the op handler calls.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let chunkEmbedDim = 0;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
const dim = await (engine as any).db.query(
|
|
`SELECT atttypmod FROM pg_attribute
|
|
WHERE attrelid = 'content_chunks'::regclass AND attname = 'embedding'`,
|
|
);
|
|
chunkEmbedDim = (dim.rows[0] as { atttypmod: number }).atttypmod;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
// Two sources so we can prove the filter excludes cross-source rows.
|
|
// 'default' source is created by initSchema's seed; we add src-b.
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, config) VALUES ('src-b', 'src-b', '{}'::jsonb) ON CONFLICT DO NOTHING`,
|
|
);
|
|
// Seed one person page in each source. Same slug intentionally —
|
|
// proves the composite (source_id, slug) key is honored, not just slug.
|
|
// upsertChunks is needed because searchKeyword scans content_chunks, not
|
|
// pages.compiled_truth directly. Each page gets one chunk that mirrors
|
|
// its compiled_truth so search-by-keyword has something to find.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice Source-A',
|
|
compiled_truth: 'Alice works on widgets in source A. Important context here.',
|
|
timeline: '',
|
|
frontmatter: {
|
|
message_id: '<source-a@example.com>',
|
|
thread_id: 'thread-source-a',
|
|
subject: 'Source A exact subject',
|
|
},
|
|
}, { sourceId: 'default' });
|
|
await engine.upsertChunks('people/alice', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'Alice works on widgets in source A. Important context here.',
|
|
chunk_source: 'compiled_truth',
|
|
token_count: 12,
|
|
embedding: Float32Array.from({ length: chunkEmbedDim }, (_, i) => i === 0 ? 1 : 0),
|
|
}], { sourceId: 'default' });
|
|
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice Source-B',
|
|
compiled_truth: 'Alice works on gadgets in source B. Important context here.',
|
|
timeline: '',
|
|
frontmatter: {
|
|
message_id: '<source-b@example.com>',
|
|
thread_id: 'thread-source-b',
|
|
subject: 'Source B exact subject',
|
|
},
|
|
}, { sourceId: 'src-b' });
|
|
await engine.upsertChunks('people/alice', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'Alice works on gadgets in source B. Important context here.',
|
|
chunk_source: 'compiled_truth',
|
|
token_count: 12,
|
|
embedding: Float32Array.from({ length: chunkEmbedDim }, (_, i) => i === 1 ? 1 : 0),
|
|
}], { sourceId: 'src-b' });
|
|
|
|
await engine.putPage('people/bob', {
|
|
type: 'person',
|
|
title: 'Bob Source-B Only',
|
|
compiled_truth: 'Bob lives only in source B. Important context here.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
}, { sourceId: 'src-b' });
|
|
await engine.upsertChunks('people/bob', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'Bob lives only in source B. Important context here.',
|
|
chunk_source: 'compiled_truth',
|
|
token_count: 11,
|
|
}], { sourceId: 'src-b' });
|
|
});
|
|
|
|
describe('v0.34.1 source-isolation regression (#861)', () => {
|
|
test('searchKeyword with sourceId=default excludes src-b rows', async () => {
|
|
const results = await engine.searchKeyword('widgets', { sourceId: 'default' });
|
|
// Should find Alice from source-A only (mentions "widgets"). src-b's
|
|
// Alice mentions "gadgets" not "widgets" but the test guards against
|
|
// accidentally pulling in src-b rows via a missing WHERE clause.
|
|
expect(results.length).toBeGreaterThan(0);
|
|
for (const r of results) {
|
|
expect(r.source_id).toBe('default');
|
|
expect(r.message_id).toBe('<source-a@example.com>');
|
|
expect(r.thread_id).toBe('thread-source-a');
|
|
expect(r.source_subject).toBe('Source A exact subject');
|
|
}
|
|
});
|
|
|
|
test('searchKeyword with sourceId=src-b excludes default rows', async () => {
|
|
const results = await engine.searchKeyword('gadgets', { sourceId: 'src-b' });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
for (const r of results) {
|
|
expect(r.source_id).toBe('src-b');
|
|
expect(r.message_id).toBe('<source-b@example.com>');
|
|
expect(r.thread_id).toBe('thread-source-b');
|
|
expect(r.source_subject).toBe('Source B exact subject');
|
|
}
|
|
});
|
|
|
|
test('searchKeyword with sourceIds=[default,src-b] returns both', async () => {
|
|
// Federated read (D9 array form) returns the union.
|
|
const results = await engine.searchKeyword('Important context', {
|
|
sourceIds: ['default', 'src-b'],
|
|
});
|
|
const sources = new Set(results.map(r => r.source_id));
|
|
expect(sources.has('default')).toBe(true);
|
|
expect(sources.has('src-b')).toBe(true);
|
|
});
|
|
|
|
test('searchKeyword with sourceIds=[default] only returns default', async () => {
|
|
// Array form with a single element behaves like scalar.
|
|
const results = await engine.searchKeyword('Important context', {
|
|
sourceIds: ['default'],
|
|
});
|
|
for (const r of results) {
|
|
expect(r.source_id).toBe('default');
|
|
}
|
|
});
|
|
|
|
test('searchKeyword with no source scope returns all sources', async () => {
|
|
// Local CLI / unscoped callers preserve pre-v0.34 behavior.
|
|
const results = await engine.searchKeyword('Important context');
|
|
const sources = new Set(results.map(r => r.source_id));
|
|
expect(sources.size).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('listPages with sourceId=default hides src-b rows', async () => {
|
|
const pages = await engine.listPages({ sourceId: 'default', limit: 100 });
|
|
expect(pages.length).toBeGreaterThan(0);
|
|
expect(pages.find(p => p.title === 'Bob Source-B Only')).toBeUndefined();
|
|
expect(pages.find(p => p.title === 'Alice Source-B')).toBeUndefined();
|
|
});
|
|
|
|
test('listPages with sourceIds=[default,src-b] returns the union', async () => {
|
|
const pages = await engine.listPages({ sourceIds: ['default', 'src-b'], limit: 100 });
|
|
const titles = new Set(pages.map(p => p.title));
|
|
expect(titles.has('Alice Source-A')).toBe(true);
|
|
expect(titles.has('Alice Source-B')).toBe(true);
|
|
expect(titles.has('Bob Source-B Only')).toBe(true);
|
|
});
|
|
|
|
test('traverseGraph with sourceId=default does not surface src-b roots', async () => {
|
|
// Seeding the walk at src-b's bob with sourceId=default produces an
|
|
// empty result — the seed itself is filtered out, so the walk never
|
|
// discovers anything. Pre-fix, the walk would still return bob's
|
|
// neighbors via cross-source edge following.
|
|
const nodes = await engine.traverseGraph('people/bob', 5, { sourceId: 'default' });
|
|
expect(nodes.length).toBe(0);
|
|
});
|
|
|
|
test('traverseGraph with sourceId=src-b finds the src-b page', async () => {
|
|
const nodes = await engine.traverseGraph('people/bob', 5, { sourceId: 'src-b' });
|
|
expect(nodes.length).toBeGreaterThanOrEqual(1);
|
|
expect(nodes.find(n => n.slug === 'people/bob')).toBeDefined();
|
|
});
|
|
|
|
test('traversePaths with sourceId=default does not surface src-b paths', async () => {
|
|
// Same seed-filter check via the edge-based traversal.
|
|
const paths = await engine.traversePaths('people/bob', { depth: 3, sourceId: 'default' });
|
|
expect(paths.length).toBe(0);
|
|
});
|
|
|
|
test('searchVector with sourceId filters HNSW candidate pool', async () => {
|
|
const fixtures = [
|
|
{
|
|
sourceId: 'default', embeddingIndex: 0,
|
|
message_id: '<source-a@example.com>', thread_id: 'thread-source-a',
|
|
source_subject: 'Source A exact subject',
|
|
},
|
|
{
|
|
sourceId: 'src-b', embeddingIndex: 1,
|
|
message_id: '<source-b@example.com>', thread_id: 'thread-source-b',
|
|
source_subject: 'Source B exact subject',
|
|
},
|
|
];
|
|
|
|
for (const fixture of fixtures) {
|
|
const synth = Float32Array.from(
|
|
{ length: chunkEmbedDim },
|
|
(_, i) => i === fixture.embeddingIndex ? 1 : 0,
|
|
);
|
|
const results = await engine.searchVector(synth, { sourceId: fixture.sourceId });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
for (const r of results) {
|
|
expect(r.source_id).toBe(fixture.sourceId);
|
|
expect(r.message_id).toBe(fixture.message_id);
|
|
expect(r.thread_id).toBe(fixture.thread_id);
|
|
expect(r.source_subject).toBe(fixture.source_subject);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('AuthInfo path: ctx.sourceId scoping propagates through op handlers', async () => {
|
|
// The op handler at operations.ts:search threads ctx.sourceId via
|
|
// sourceScopeOpts. Simulate the dispatcher's threading and verify
|
|
// the engine sees the filter — this is the layer the regression
|
|
// tests need to cover most directly.
|
|
const { operations } = await import('../../src/core/operations.ts');
|
|
const searchOp = operations.find(o => o.name === 'search');
|
|
expect(searchOp).toBeDefined();
|
|
|
|
const ctx = {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: true,
|
|
sourceId: 'src-b',
|
|
};
|
|
const result = await searchOp!.handler(ctx as any, { query: 'gadgets' });
|
|
const rows = result as Array<{ source_id?: string }>;
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
for (const r of rows) {
|
|
expect(r.source_id).toBe('src-b');
|
|
}
|
|
});
|
|
|
|
test('AuthInfo.allowedSources path: ctx.auth.allowedSources widens read scope', async () => {
|
|
// D9 federated read: when AuthInfo.allowedSources is populated, the
|
|
// sourceScopeOpts helper produces sourceIds array (array wins over
|
|
// scalar ctx.sourceId). Verify the op handler routes through this.
|
|
const { operations } = await import('../../src/core/operations.ts');
|
|
const searchOp = operations.find(o => o.name === 'search');
|
|
const ctx = {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: true,
|
|
sourceId: 'default', // scalar would scope to default-only
|
|
auth: {
|
|
token: 'test',
|
|
clientId: 'test',
|
|
scopes: ['read'],
|
|
sourceId: 'default',
|
|
allowedSources: ['default', 'src-b'], // ... but the array wins
|
|
},
|
|
};
|
|
const result = await searchOp!.handler(ctx as any, { query: 'Important context' });
|
|
const rows = result as Array<{ source_id?: string }>;
|
|
const sources = new Set(rows.map(r => r.source_id));
|
|
expect(sources.has('default')).toBe(true);
|
|
expect(sources.has('src-b')).toBe(true);
|
|
});
|
|
|
|
test('#876 federated_read empty array means no federated reads', async () => {
|
|
// sourceScopeOpts treats allowedSources: [] (explicit empty) as "no
|
|
// federated scope" and falls back to scalar sourceId. An empty array
|
|
// MUST NOT widen scope to "all sources" — that's the silent-widen
|
|
// footgun. Verify the precedence ladder is correct.
|
|
const { operations } = await import('../../src/core/operations.ts');
|
|
const searchOp = operations.find(o => o.name === 'search');
|
|
const ctx = {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: true,
|
|
sourceId: 'default',
|
|
auth: {
|
|
token: 'test',
|
|
clientId: 'test',
|
|
scopes: ['read'],
|
|
sourceId: 'default',
|
|
allowedSources: [], // explicit empty — must NOT widen scope
|
|
},
|
|
};
|
|
const result = await searchOp!.handler(ctx as any, { query: 'Important context' });
|
|
const rows = result as Array<{ source_id?: string }>;
|
|
for (const r of rows) {
|
|
expect(r.source_id).toBe('default');
|
|
}
|
|
});
|
|
});
|