Files
gbrain/test/thin-client-source-scope.test.ts
T
Garry TanandClaude Fable 5 ddf0ddc007 test(thin-client): use withEnv() instead of direct process.env mutation (test-isolation R1)
CI verify failed on check:test-isolation — thin-client-source-scope.test.ts
mutated process.env.GBRAIN_SOURCE via beforeEach/afterEach. Wrapped each
test body in withEnv() from test/helpers/with-env.ts instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:04:05 -07:00

116 lines
4.9 KiB
TypeScript

/**
* #2098: thin-client routing dropped --source / GBRAIN_SOURCE / .gbrain-source.
*
* The local CLI path resolves source scope in makeContext (ctx.sourceId); the
* thin-client route short-circuits before that and sent params verbatim, so
* `gbrain query --source X` against a remote brain silently searched unscoped
* (the server op ignores the unknown `source` key).
*
* applyThinClientSourceScope runs the engine-free tiers (flag → env → dotfile)
* and maps the result onto the op's `source_id` wire param. These tests fail
* without the fix (params.source_id stays undefined / params.source leaks).
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { applyThinClientSourceScope, parseOpArgs } from '../src/cli.ts';
import { operationsByName } from '../src/core/operations.ts';
import { withEnv } from './helpers/with-env.ts';
const queryOp = operationsByName.query;
describe('applyThinClientSourceScope (#2098)', () => {
test('--source maps onto the query op wire param source_id', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const params = parseOpArgs(queryOp, ['find things', '--source', 'wiki']);
expect(params.source).toBe('wiki'); // pre-fix state: wrong key
applyThinClientSourceScope(queryOp, params, '/');
expect(params.source_id).toBe('wiki');
expect('source' in params).toBe(false); // never leaks the unknown key
});
});
test('GBRAIN_SOURCE env tier fires when no flag is passed', async () => {
await withEnv({ GBRAIN_SOURCE: 'gstack' }, () => {
const params = parseOpArgs(queryOp, ['find things']);
applyThinClientSourceScope(queryOp, params, '/');
expect(params.source_id).toBe('gstack');
});
});
test('.gbrain-source dotfile tier fires when flag and env are absent', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const tmp = mkdtempSync(join(tmpdir(), 'gbrain-thin-scope-'));
try {
writeFileSync(join(tmp, '.gbrain-source'), 'essays\n');
const params = parseOpArgs(queryOp, ['find things']);
applyThinClientSourceScope(queryOp, params, tmp);
expect(params.source_id).toBe('essays');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test('explicit --source-id on the wire wins over ambient env scope', async () => {
await withEnv({ GBRAIN_SOURCE: 'gstack' }, () => {
const params = parseOpArgs(queryOp, ['find things', '--source-id', 'wiki']);
applyThinClientSourceScope(queryOp, params, '/');
expect(params.source_id).toBe('wiki');
});
});
test('--source together with --source-id is rejected loudly', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const params = parseOpArgs(queryOp, ['q', '--source', 'a', '--source-id', 'b']);
expect(() => applyThinClientSourceScope(queryOp, params, '/')).toThrow(/not both/);
});
});
test('invalid --source value is rejected loudly', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const params = parseOpArgs(queryOp, ['q', '--source', 'Bad_Value!']);
expect(() => applyThinClientSourceScope(queryOp, params, '/')).toThrow(/Invalid --source/);
});
});
test('--source on an op with no source_id wire param errors instead of silently dropping', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const op = operationsByName.add_tag;
expect('source_id' in op.params).toBe(false);
const params = { slug: 'x', tag: 'y', source: 'wiki' };
expect(() => applyThinClientSourceScope(op, params, '/')).toThrow(/--source/);
});
});
test('ambient env scope on an op with no source_id wire param is ignored (no throw)', async () => {
await withEnv({ GBRAIN_SOURCE: 'wiki' }, () => {
const op = operationsByName.add_tag;
const params: Record<string, unknown> = { slug: 'x', tag: 'y' };
applyThinClientSourceScope(op, params, '/');
expect(params.source_id).toBeUndefined();
});
});
test('ops that declare their OWN source param are left untouched', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const op = operationsByName.put_raw_data;
expect('source' in op.params).toBe(true);
const params: Record<string, unknown> = { slug: 'x', source: 'crustdata', data: {} };
applyThinClientSourceScope(op, params, '/');
expect(params.source).toBe('crustdata');
expect(params.source_id).toBeUndefined();
});
});
test('no scope from any tier leaves params unchanged', async () => {
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
const params = parseOpArgs(queryOp, ['find things']);
applyThinClientSourceScope(queryOp, params, '/');
expect(params.source_id).toBeUndefined();
});
});
});