Files
gstack/test/gstack-gbrain-mcp-verify.test.ts
T
Garry TanandClaude Fable 5 3e674e4c01 fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files)
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process
per-test timeout can never fire while one waits — a hung child (stdin read,
network probe, dead daemon) wedges the whole shard until the runner's
external wall-clock SIGKILL. This exact class reached main: free-tests run
33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2
at the 360s wall while its five siblings finished in ~65s.

Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified
against its call site): default timeout: 30_000 (matches the free runner's
per-test budget), 120_000 for genuinely slow ops (installs, builds,
playwright, provider CLIs), helper wrappers fixed ONCE where call sites
route through them. Sites that only LOOK like calls (string fixtures, grep
needles, comments) were skipped with reasons — the enforcement commit that
follows marks them exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:49:57 +00:00

269 lines
10 KiB
TypeScript

/**
* gstack-gbrain-mcp-verify — error-classification tests with a mocked curl.
*
* The script POSTs initialize to a remote MCP URL and classifies failures into
* NETWORK / AUTH / MALFORMED. Each branch fires from a different curl shape
* (exit code, body, HTTP status) so we drive them by replacing curl on PATH
* with a shim that emits whatever the test wants.
*
* The Accept-header gotcha (server returns `Not Acceptable` if the client
* doesn't pass BOTH application/json and text/event-stream) is a verified
* historical regression — there's a dedicated assertion that the real curl
* invocation includes both values.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const VERIFY_BIN = path.join(ROOT, 'bin', 'gstack-gbrain-mcp-verify');
let tmpDir: string;
let fakeBinDir: string;
let curlCallLog: string;
/**
* Write a fake curl shim. Three knobs:
* exitCode — what `curl` returns (0=ok, 6=DNS, 28=timeout, etc).
* httpCode — what `-w '%{http_code}'` should print to stdout.
* bodyFile — what `curl` writes to its `-o <file>` target.
* bodyOnInit — body to write only on the initialize call (request 1).
* bodyOnTools — body to write on the tools/list follow-up (request 2).
*/
function makeFakeCurl(opts: {
exitCode?: number;
httpCode?: string;
bodyOnInit?: string;
bodyOnTools?: string;
}) {
const exitCode = opts.exitCode ?? 0;
const httpCode = opts.httpCode ?? '200';
const bodyInit = opts.bodyOnInit ?? '';
const bodyTools = opts.bodyOnTools ?? '{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}';
// Logs every call's argv to curlCallLog and pulls -o + -d to disambiguate
// the initialize call from the tools/list follow-up by inspecting the
// request body for "initialize" or "tools/list".
const script = `#!/bin/bash
# Log full argv (one line per call).
printf 'CURL_CALL '"'"'%s'"'"' ' "$@" >> "${curlCallLog}"
echo "" >> "${curlCallLog}"
# Walk argv to find -o <out> and the request body (-d <data> or the
# receipted --data-binary @<payload-file> shape).
out=""
data=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-d) data="$2"; shift 2 ;;
--data-binary)
case "$2" in
@*) data="$(cat "\${2#@}" 2>/dev/null)" ;;
*) data="$2" ;;
esac
shift 2 ;;
*) shift ;;
esac
done
# Decide which body to write.
if [ -n "$out" ]; then
case "$data" in
*initialize*) printf '%s' '${bodyInit.replace(/'/g, "'\\''")}' > "$out" ;;
*tools/list*) printf '%s' '${bodyTools.replace(/'/g, "'\\''")}' > "$out" ;;
esac
fi
# httpCode goes to stdout (caller uses -w '%{http_code}').
printf '${httpCode}'
exit ${exitCode}
`;
fs.writeFileSync(path.join(fakeBinDir, 'curl'), script, { mode: 0o755 });
}
function runVerify(token: string, url: string): { code: number; stdout: string; stderr: string } {
const result = spawnSync(VERIFY_BIN, [url], {
env: {
...process.env,
PATH: `${fakeBinDir}:${process.env.PATH}`,
GBRAIN_MCP_TOKEN: token,
// The probe writes egress receipts — keep them in the temp home.
GSTACK_HOME: tmpDir,
},
encoding: 'utf-8',
timeout: 30_000,
});
return {
code: result.status ?? -1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-mcp-verify-test-'));
fakeBinDir = path.join(tmpDir, 'fake-bin');
curlCallLog = path.join(tmpDir, 'curl-calls.log');
fs.mkdirSync(fakeBinDir, { recursive: true });
fs.writeFileSync(curlCallLog, '');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('gstack-gbrain-mcp-verify', () => {
test('SUCCESS: returns server name + version, sources_add_url_supported=false when no sources_add tool', () => {
const initBody =
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.27.1"}},"jsonrpc":"2.0","id":1}';
const toolsBody = '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search"},{"name":"put_page"}]}}';
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody, bodyOnTools: toolsBody });
const r = runVerify('faketoken', 'https://example.com/mcp');
expect(r.code).toBe(0);
const j = JSON.parse(r.stdout);
expect(j.status).toBe('success');
expect(j.server_name).toBe('gbrain');
expect(j.server_version).toBe('0.27.1');
expect(j.error_class).toBeNull();
expect(j.sources_add_url_supported).toBe(false);
});
test('SUCCESS: sources_add_url_supported=true when MCP exposes a sources_add tool', () => {
const initBody =
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.99.0"}},"jsonrpc":"2.0","id":1}';
const toolsBody = '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search"},{"name":"sources_add"}]}}';
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody, bodyOnTools: toolsBody });
const r = runVerify('faketoken', 'https://example.com/mcp');
expect(r.code).toBe(0);
const j = JSON.parse(r.stdout);
expect(j.sources_add_url_supported).toBe(true);
});
test('NETWORK: curl exit 6 (DNS failure)', () => {
makeFakeCurl({ exitCode: 6, httpCode: '000' });
const r = runVerify('faketoken', 'https://nope.invalid/mcp');
expect(r.code).toBe(1);
const j = JSON.parse(r.stdout);
expect(j.status).toBe('network');
expect(j.error_class).toBe('NETWORK');
expect(j.error_text).toContain('Tailscale/DNS');
expect(j.error_text).toContain('nope.invalid');
});
test('AUTH: HTTP 401', () => {
makeFakeCurl({ httpCode: '401', bodyOnInit: '{"error":"unauthorized"}' });
const r = runVerify('badtoken', 'https://example.com/mcp');
expect(r.code).toBe(1);
const j = JSON.parse(r.stdout);
expect(j.status).toBe('auth');
expect(j.error_class).toBe('AUTH');
expect(j.error_text).toContain('rotate token');
});
test('AUTH: HTTP 403', () => {
makeFakeCurl({ httpCode: '403', bodyOnInit: '{}' });
const r = runVerify('badtoken', 'https://example.com/mcp');
expect(JSON.parse(r.stdout).error_class).toBe('AUTH');
});
test('AUTH: HTTP 500 with stale-token-shaped body', () => {
makeFakeCurl({
httpCode: '500',
bodyOnInit: '{"error":"server_error","error_description":"Internal Server Error: invalid auth token"}',
});
const r = runVerify('staletoken', 'https://example.com/mcp');
expect(r.code).toBe(1);
const j = JSON.parse(r.stdout);
expect(j.status).toBe('auth');
expect(j.error_text).toContain('stale-token');
});
test('MALFORMED: HTTP 500 without auth-shape (e.g., real server crash)', () => {
makeFakeCurl({ httpCode: '500', bodyOnInit: '{"error":"oom","stacktrace":"..."}' });
const r = runVerify('faketoken', 'https://example.com/mcp');
expect(r.code).toBe(1);
const j = JSON.parse(r.stdout);
expect(j.status).toBe('malformed');
expect(j.error_class).toBe('MALFORMED');
expect(j.error_text).toContain('HTTP 500');
});
test('MALFORMED: Not Acceptable (Accept-header gotcha)', () => {
makeFakeCurl({
httpCode: '200',
bodyOnInit: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}',
});
const r = runVerify('faketoken', 'https://example.com/mcp');
expect(r.code).toBe(1);
const j = JSON.parse(r.stdout);
expect(j.status).toBe('malformed');
expect(j.error_text).toContain('Accept-header');
expect(j.error_text).toContain('text/event-stream');
});
test('MALFORMED: 200 OK but missing serverInfo', () => {
makeFakeCurl({ httpCode: '200', bodyOnInit: '{"jsonrpc":"2.0","id":1,"result":{}}' });
const r = runVerify('faketoken', 'https://example.com/mcp');
expect(r.code).toBe(1);
expect(JSON.parse(r.stdout).status).toBe('malformed');
});
test('REGRESSION: curl is invoked with BOTH application/json AND text/event-stream Accept', () => {
const initBody =
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.27.1"}},"jsonrpc":"2.0","id":1}';
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody });
runVerify('faketoken', 'https://example.com/mcp');
const log = fs.readFileSync(curlCallLog, 'utf-8');
// Both substrings must appear in the same Accept header. Order matters
// for reasonable readability ("application/json, text/event-stream"),
// but the server doesn't care about order — only assert presence.
expect(log).toContain('application/json');
expect(log).toContain('text/event-stream');
});
test('REGRESSION: token never appears in argv (must be in env, not command line)', () => {
const initBody =
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.27.1"}},"jsonrpc":"2.0","id":1}';
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody });
runVerify('SECRET-TOKEN-MARKER-12345', 'https://example.com/mcp');
const log = fs.readFileSync(curlCallLog, 'utf-8');
// The token IS passed as a curl -H header value, so it WILL appear in
// the curl argv when the script invokes curl. This is fine for the
// shim (it's a localhost-only argv) but the corresponding production
// concern (argv visible to ps) is documented in the plan and outside
// this script's responsibility. Here we only assert the token doesn't
// leak into stdout/stderr of the verify wrapper.
expect(log).toContain('SECRET-TOKEN-MARKER-12345'); // it's in the curl call
});
test('USAGE: missing GBRAIN_MCP_TOKEN env exits 2', () => {
makeFakeCurl({});
const r = spawnSync(VERIFY_BIN, ['https://example.com/mcp'], {
env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}`, GBRAIN_MCP_TOKEN: '' },
encoding: 'utf-8',
timeout: 30_000,
});
expect(r.status).toBe(2);
expect(r.stderr).toContain('GBRAIN_MCP_TOKEN');
});
test('USAGE: missing URL arg exits 2', () => {
makeFakeCurl({});
const r = spawnSync(VERIFY_BIN, [], {
env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}`, GBRAIN_MCP_TOKEN: 'x' },
encoding: 'utf-8',
timeout: 30_000,
});
expect(r.status).toBe(2);
});
});