mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(serve): clean up stdio MCP server on client disconnect
The PGLite write lock leaked indefinitely when the parent of `gbrain serve`
disconnected. Three root causes: serve.ts never called engine.disconnect()
after startMcpServer() resolved; cli.ts short-circuited with a "serve doesn't
disconnect" comment; and the MCP SDK's StdioServerTransport only listens for
'data'/'error' on stdin, never 'end'/'close', so even a clean stdin EOF never
reached the SDK.
Net effect: the next `gbrain serve` waited for the in-process 5-minute stale-
lock check or hung indefinitely.
stdio path now installs a unified lifecycle:
- SIGTERM/SIGINT/SIGHUP all funnel into one idempotent shutdown path
(SIGHUP coverage matters for Claude Desktop on macOS / MCP gateway
restarts; SIGINT for Ctrl-C; SIGTERM for daemon shutdown).
- stdin 'end' (clean EOF) and 'close' (parent SIGKILL with pipe still
open) both trigger the same graceful path. TTY stdin skips the watchers
so interactive `gbrain serve` is unaffected.
- Parent-process watchdog polls the live kernel parent PID via spawnSync
('ps','-o','ppid=','-p',PID) every 5s. process.ppid is cached at process
creation by Bun (and Node) and never refreshes on re-parent — empirical
evidence on macOS shows ps reports the new parent within one tick while
process.ppid stays at the original PID indefinitely (oven-sh/bun#30305).
- Watchdog fires on `getParentPid() !== initialParentPid` (any reparent),
not just `=== 1`. Catches launchd / systemd / tmux / parent-shell-with-
PR_SET_CHILD_SUBREAPER cases where the kernel re-anchors us to a non-1
subreaper PID. Codex review caught the original `=== 1` was incomplete.
- One-shot startup probe verifies `spawnSync('ps')` actually works on this
host. If the probe fails (stripped containers / busybox without procps),
we skip installing the watchdog interval entirely AND emit a loud stderr
line — the operator sees "watchdog disabled" instead of an installed-
but-never-fires phantom that silently falls back to cached process.ppid.
- 5-second cleanup deadline: if engine.disconnect() wedges (PGLite WASM
stall, etc.), the process still calls process.exit(0). The abandoned
lock dir is reclaimed on the next start by the existing stale-lock
check in pglite-lock.ts.
- Optional `--stdio-idle-timeout <sec>`: default OFF safety net for
parents that leak the pipe but never close it. Strict parsing rejects
`abc` / `30junk` / `-1` / `1.5` / blank values explicitly so a typo
doesn't silently disable the safety net (closes #446).
Test seam: ServeOptions { stdin, signals, exit, log, startMcpServer,
getParentPid, setInterval, clearInterval, probeWatchdog } lets the
lifecycle be unit-tested deterministically without spawning a real Bun
child or booting the MCP SDK.
22 test cases covering signals, stdin EOF, TTY skip, watchdog reparent
(both PID-1 and subreaper-PID-N cases), ps-unavailable degraded mode,
idle timeout, idempotent shutdown, and cleanup-deadline behavior.
Closes #413, #446. Supersedes #591.
Co-Authored-By: Aragorn2046 <noreply@github.com>
Co-Authored-By: seungsu-kr <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth): route HTTP auth/admin SQL through active engine
`gbrain auth` and `gbrain serve --http` previously routed every SQL
through the postgres.js singleton in src/core/db.ts, which silently fell
back to a file-backed PGLite when DATABASE_URL was set but the config
file disagreed. The HTTP transport's verbatim use of the singleton also
made `gbrain serve --http` Postgres-only, even though the
`access_tokens` and `mcp_request_log` tables exist in both engine
schemas.
Auth, OAuth, admin, file uploads, and HTTP-transport SQL now run through
`engine.executeRaw` via a deliberately narrow tagged-template adapter
(`src/core/sql-query.ts`). The contract is scalar-binds-only — adding
JSONB or fragment composition would invite the adapter to drift into a
partial postgres.js clone. JSONB writes use a separate
`executeRawJsonb(engine, sql, scalarParams, jsonbParams)` helper that
composes positional `$N::jsonb` casts and passes objects through
`engine.executeRaw`. The CI guard at `scripts/check-jsonb-pattern.sh`
doesn't fire because the helper is a method call, not the banned
`${JSON.stringify(x)}::jsonb` template-literal interpolation, and the
v0.12.0 double-encode bug class doesn't apply to positional binding via
`postgres.js`'s `unsafe()` (verified by
`test/e2e/auth-permissions.test.ts:67` on Postgres and the new
`test/sql-query.test.ts` on PGLite).
Migrated call sites:
- src/commands/auth.ts: takes-holders writes (lines 52, 86) →
executeRawJsonb. List, revoke, register-client, revoke-client →
SqlQuery via withConfiguredSql() helper that opens an engine, runs
the callback, disconnects.
- src/commands/serve-http.ts: ~25 call sites including the four
mcp_request_log.params INSERTs (now write real JSONB objects, not
JSON-encoded strings — the read side `params->>'op'` returns the
operation name, closing CLAUDE.md's outstanding "JSON-string-into-
JSONB" note as a side effect). The /admin/api/requests dynamic
filter pattern (postgres.js fragment composition) is rewritten as
parametrized SQL string + params array.
- src/mcp/http-transport.ts: legacy bearer-auth path. The
Postgres-only fail-fast at startup is removed because both schemas
now carry access_tokens + mcp_request_log.
- src/core/oauth-provider.ts: SqlQuery / SqlValue types relocated
from here to sql-query.ts as the canonical home (Codex finding #8).
- src/commands/files.ts: all 5 db.getConnection() sites (lines 104,
139, 252, 326, 355). The line-256 INSERT into files.metadata uses
executeRawJsonb; the other four are scalar-only SqlQuery (Codex
finding #6 — scope was bigger than the plan's "lone INSERT" framing).
- src/core/config.ts: env-var DATABASE_URL inference. When dbUrl is
set, infer Postgres engine and clear the stale database_path.
Engine-internal sql.json() sites in src/core/postgres-engine.ts (5
sites: lines 520, 1689, 1728, 1790, 2313) STAY UNCHANGED. They live
inside PostgresEngine itself, where the postgres.js template-tag
sql.json() pattern is correct — those methods are only loaded when
Postgres is the active engine, so there's no PGLite-routing concern.
Migration v45 (mcp_request_log_params_jsonb_normalize): one-shot UPDATE
that lifts pre-v0.31 string-shaped JSONB rows to objects so the
/admin/api/requests endpoint at serve-http.ts:605 returns one
consistent shape to the admin SPA. Idempotent (subsequent runs find no
rows where jsonb_typeof = 'string'). Closes the mixed-shape window
that would otherwise have made post-deploy admin reads break.
Tests:
- test/sql-query.test.ts: 7 cases covering scalar binds, the
.json() rejection (defense in depth — SqlQuery is scalar-only),
JSONB round-trip with `jsonb_typeof = 'object'` and `->>`
semantics, the v0.12.0 double-encode regression guard, null
JSONB handling, and the scalars-then-jsonb call shape.
- test/config-env.test.ts: migrated from PR's manual `restoreEnv()`
in afterEach to the canonical `withEnv()` helper at
test/helpers/with-env.ts (CLAUDE.md R1 / codex finding D3).
Five cases covering DATABASE_URL precedence, GBRAIN_DATABASE_URL
operator override, file-only config, env-only config, and the
no-config null path.
- test/e2e/auth-takes-holders-pglite.test.ts: 6 cases against
in-memory PGLite (no DATABASE_URL gate). Covers create / update /
read of access_tokens.permissions, mcp_request_log.params object
+ null writes, and the migration v45 normalizer (seed
string-shaped row, run UPDATE, assert object shape; second-run
no-op for idempotency).
- test/http-transport.test.ts: mock updated to intercept
engine.executeRaw (the new code path) instead of the postgres.js
template tag. 24 cases pass.
Plan reference: ~/.claude/plans/system-instruction-you-are-working-peppy-moore.md.
Codex outside-voice review applied: D-codex-1, D-codex-2, D-codex-5,
D-codex-8, D-codex-9, D-codex-10 (and D1, D5 reversed by codex).
Closes the architectural intent of #681. Supersedes its branch.
Co-Authored-By: codex-bot <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update CLAUDE.md key files for v0.31.3
Annotate the v0.31.3 changes in the canonical Key Files section:
new src/core/sql-query.ts adapter (#681), src/commands/serve.ts stdio
cleanup (#676), v0.31.3 amendments to auth.ts / serve-http.ts /
oauth-provider.ts surfaces, and migration v46 normalizer in migrate.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: regenerate llms-full.txt for v0.31.3 docs sync
CI's build-llms test asserts the committed llms.txt + llms-full.txt
match what scripts/build-llms.ts produces from current source state.
CLAUDE.md was amended by /document-release post-merge (new entries for
src/core/sql-query.ts and src/commands/serve.ts; amended notes on
auth.ts / serve-http.ts / migrate.ts), so the inlined-bundle fell out
of sync. Regenerated via `bun run build:llms`.
llms.txt unchanged (curated index — no new web URLs added).
llms-full.txt updated to inline the new CLAUDE.md content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Aragorn2046 <noreply@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
565 lines
22 KiB
TypeScript
565 lines
22 KiB
TypeScript
/**
|
||
* Unit tests for src/mcp/http-transport.ts.
|
||
*
|
||
* Covers:
|
||
* - Auth path (valid, missing header, no Bearer prefix, unknown, revoked, /health bypass)
|
||
* - F1+F2+F3 round-trip guards (handler arg order, full OperationContext, param validation)
|
||
* - JSON-only response shape (no SSE)
|
||
* - CORS default-deny + allowlist
|
||
* - Body cap (Content-Length + chunked)
|
||
* - Rate limit (token + IP buckets, LRU eviction, TTL prune, /health bypass)
|
||
*
|
||
* No DATABASE_URL needed — engine.sql is mocked. E2E coverage of the real Postgres
|
||
* round-trip lives in test/e2e/http-transport.test.ts.
|
||
*/
|
||
|
||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||
import { createHash } from 'crypto';
|
||
import { startHttpTransport } from '../src/mcp/http-transport.ts';
|
||
import { RateLimiter } from '../src/mcp/rate-limit.ts';
|
||
|
||
type SqlResult = unknown[] | unknown;
|
||
type SqlHandler = (query: string, values: unknown[]) => SqlResult | Promise<SqlResult>;
|
||
|
||
interface FakeEngine {
|
||
kind: 'postgres';
|
||
// v0.31 wave: http-transport.ts now routes SQL through
|
||
// `sqlQueryForEngine(engine)` which calls `engine.executeRaw(sql, params)`.
|
||
// The mock intercepts `executeRaw` directly. The legacy `sql` template
|
||
// tag is preserved as a fallback for any code path we missed (none
|
||
// expected after the migration, but harmless if it sticks around).
|
||
executeRaw: <T = Record<string, unknown>>(sql: string, params?: unknown[]) => Promise<T[]>;
|
||
sql: ReturnType<typeof makeSqlTag>;
|
||
audit: { token_name: string | null; operation: string; status: string; latency_ms: number }[];
|
||
}
|
||
|
||
function makeSqlTag(handler: SqlHandler) {
|
||
return (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||
let query = '';
|
||
for (let i = 0; i < strings.length; i++) {
|
||
query += strings[i];
|
||
if (i < values.length) query += '?';
|
||
}
|
||
const result = handler(query.trim(), values);
|
||
return Promise.resolve(result);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Normalize a SQL string for pattern-matching: collapse whitespace,
|
||
* lowercase. Helps the mock's `executeRaw` match the queries that
|
||
* `sqlQueryForEngine` builds (multi-line, $1/$2/etc. placeholders) the
|
||
* same way the legacy template-tag mock matched the older single-line
|
||
* shapes.
|
||
*/
|
||
function normalizeSql(sql: string): string {
|
||
return sql.replace(/\s+/g, ' ').trim().toLowerCase();
|
||
}
|
||
|
||
function hash(token: string): string {
|
||
return createHash('sha256').update(token).digest('hex');
|
||
}
|
||
|
||
interface FakeEngineConfig {
|
||
/**
|
||
* v0.28: row shape mirrors the production SELECT, including the
|
||
* `permissions` JSONB column. Default permissions = {takes_holders: ['world']}
|
||
* when unset, matching the migration v33 default.
|
||
*/
|
||
validTokens?: Map<string, { id: string; name: string; permissions?: { takes_holders?: string[] } }>;
|
||
/** Tokens that are present but revoked (revoked_at IS NOT NULL — query returns empty). */
|
||
revokedTokens?: Set<string>;
|
||
/** If true, every SELECT throws (simulating DB outage). */
|
||
dbDown?: boolean;
|
||
}
|
||
|
||
function makeFakeEngine(cfg: FakeEngineConfig = {}): FakeEngine {
|
||
const validTokens = cfg.validTokens ?? new Map();
|
||
const revokedTokens = cfg.revokedTokens ?? new Set();
|
||
const audit: FakeEngine['audit'] = [];
|
||
|
||
// Legacy template-tag handler. Preserved so any non-migrated call path
|
||
// still has a place to land (defense in depth). The new code path routes
|
||
// through executeRaw below.
|
||
const handle = (query: string, values: unknown[]): unknown[] => {
|
||
if (cfg.dbDown && query.toLowerCase().includes('select')) throw new Error('db down');
|
||
|
||
if (query === 'SELECT 1' || normalizeSql(query) === 'select 1') {
|
||
return [{ '?column?': 1 }];
|
||
}
|
||
|
||
const norm = normalizeSql(query);
|
||
|
||
// SELECT id, name, permissions FROM access_tokens WHERE token_hash = $1 AND revoked_at IS NULL
|
||
if (norm.startsWith('select id, name from access_tokens') ||
|
||
norm.startsWith('select id, name, permissions from access_tokens')) {
|
||
const tokenHash = values[0] as string;
|
||
if (revokedTokens.has(tokenHash)) return [];
|
||
const row = validTokens.get(tokenHash);
|
||
if (!row) return [];
|
||
const rowWithPerms = { ...row, permissions: row.permissions ?? { takes_holders: ['world'] } };
|
||
return [rowWithPerms];
|
||
}
|
||
|
||
if (norm.startsWith('update access_tokens')) {
|
||
// last_used_at debounce — succeed silently
|
||
return [];
|
||
}
|
||
|
||
if (norm.startsWith('insert into mcp_request_log')) {
|
||
audit.push({
|
||
token_name: values[0] as string | null,
|
||
operation: values[1] as string,
|
||
latency_ms: values[2] as number,
|
||
status: values[3] as string,
|
||
});
|
||
return [];
|
||
}
|
||
|
||
return [];
|
||
};
|
||
|
||
const sql = makeSqlTag(handle);
|
||
|
||
// v0.31: sqlQueryForEngine + executeRawJsonb both call engine.executeRaw.
|
||
// The new SQL strings carry $N positional placeholders (not the legacy
|
||
// template-tag `?`), but the queries themselves match by their leading
|
||
// text. We normalize whitespace so multi-line SQL bodies match the same
|
||
// way single-line shapes did.
|
||
const executeRaw = async <T = Record<string, unknown>>(
|
||
rawSql: string,
|
||
params?: unknown[],
|
||
): Promise<T[]> => {
|
||
const result = handle(rawSql, params ?? []);
|
||
return Promise.resolve(result as T[]);
|
||
};
|
||
|
||
return { kind: 'postgres', executeRaw, sql, audit };
|
||
}
|
||
|
||
interface TestServer {
|
||
url: string;
|
||
stop: () => void;
|
||
engine: FakeEngine;
|
||
ipLimiter: RateLimiter;
|
||
tokenLimiter: RateLimiter;
|
||
}
|
||
|
||
let mockNow = 0;
|
||
function freezeClock(at: number) { mockNow = at; }
|
||
function advanceClock(deltaMs: number) { mockNow += deltaMs; }
|
||
|
||
async function startTest(cfg: FakeEngineConfig & { lruCap?: number; ipLimit?: number; tokenLimit?: number; corsOrigin?: string; bodyCap?: number; trustProxy?: boolean } = {}): Promise<TestServer> {
|
||
if (cfg.corsOrigin) process.env.GBRAIN_HTTP_CORS_ORIGIN = cfg.corsOrigin;
|
||
else delete process.env.GBRAIN_HTTP_CORS_ORIGIN;
|
||
if (cfg.bodyCap) process.env.GBRAIN_HTTP_MAX_BODY_BYTES = String(cfg.bodyCap);
|
||
else delete process.env.GBRAIN_HTTP_MAX_BODY_BYTES;
|
||
if (cfg.trustProxy) process.env.GBRAIN_HTTP_TRUST_PROXY = '1';
|
||
else delete process.env.GBRAIN_HTTP_TRUST_PROXY;
|
||
|
||
const engine = makeFakeEngine(cfg);
|
||
const clock = () => mockNow || Date.now();
|
||
const ipLimiter = new RateLimiter(
|
||
{ limit: cfg.ipLimit ?? 1000, windowMs: 60_000, lruCap: cfg.lruCap ?? 10000 },
|
||
clock,
|
||
);
|
||
const tokenLimiter = new RateLimiter(
|
||
{ limit: cfg.tokenLimit ?? 1000, windowMs: 60_000, lruCap: cfg.lruCap ?? 10000 },
|
||
clock,
|
||
);
|
||
const server = await startHttpTransport({
|
||
port: 0,
|
||
engine: engine as any,
|
||
limiters: { ip: ipLimiter, token: tokenLimiter },
|
||
});
|
||
return {
|
||
url: `http://localhost:${(server as any).port}`,
|
||
stop: () => (server as any).stop(true),
|
||
engine,
|
||
ipLimiter,
|
||
tokenLimiter,
|
||
};
|
||
}
|
||
|
||
function rpc(method: string, params?: unknown, id: number = 1) {
|
||
return JSON.stringify({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) });
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Auth path
|
||
// --------------------------------------------------------------------------
|
||
|
||
describe('http-transport: auth', () => {
|
||
let srv: TestServer;
|
||
const VALID_TOKEN = 'valid-token-abc';
|
||
const REVOKED_TOKEN = 'revoked-token-xyz';
|
||
|
||
beforeAll(async () => {
|
||
srv = await startTest({
|
||
validTokens: new Map([[hash(VALID_TOKEN), { id: 'tok-1', name: 'test' }]]),
|
||
revokedTokens: new Set([hash(REVOKED_TOKEN)]),
|
||
});
|
||
});
|
||
afterAll(() => srv.stop());
|
||
|
||
test('1. valid token → 200 + tools/list returns ops', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${VALID_TOKEN}`, 'Content-Type': 'application/json' },
|
||
body: rpc('tools/list'),
|
||
});
|
||
expect(r.status).toBe(200);
|
||
const body = await r.json();
|
||
expect(body.result.tools).toBeArray();
|
||
expect(body.result.tools.length).toBeGreaterThan(0);
|
||
expect(body.jsonrpc).toBe('2.0');
|
||
});
|
||
|
||
test('2. missing Authorization header → 401', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: rpc('tools/list'),
|
||
});
|
||
expect(r.status).toBe(401);
|
||
});
|
||
|
||
test('3. header missing Bearer prefix → 401', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': VALID_TOKEN, 'Content-Type': 'application/json' },
|
||
body: rpc('tools/list'),
|
||
});
|
||
expect(r.status).toBe(401);
|
||
});
|
||
|
||
test('4. unknown token → 401', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer not-a-real-token', 'Content-Type': 'application/json' },
|
||
body: rpc('tools/list'),
|
||
});
|
||
expect(r.status).toBe(401);
|
||
});
|
||
|
||
test('5. revoked token → 401', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${REVOKED_TOKEN}`, 'Content-Type': 'application/json' },
|
||
body: rpc('tools/list'),
|
||
});
|
||
expect(r.status).toBe(401);
|
||
});
|
||
|
||
test('6. /health → 200 without auth, body has expected fields, probes DB', async () => {
|
||
const r = await fetch(`${srv.url}/health`);
|
||
expect(r.status).toBe(200);
|
||
const body = await r.json();
|
||
expect(body.status).toBe('ok');
|
||
expect(body.transport).toBe('http');
|
||
expect(body.version).toBeString();
|
||
expect(body.db).toBe('ok');
|
||
});
|
||
|
||
test('6b. /health → 503 when DB is unreachable', async () => {
|
||
const dbDownSrv = await startTest({ dbDown: true });
|
||
try {
|
||
const r = await fetch(`${dbDownSrv.url}/health`);
|
||
expect(r.status).toBe(503);
|
||
const body = await r.json();
|
||
expect(body.status).toBe('unhealthy');
|
||
expect(body.db).toBe('unreachable');
|
||
} finally { dbDownSrv.stop(); }
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------------------------
|
||
// F1+F2+F3 regression guards (the actual existing-PR bugs)
|
||
// --------------------------------------------------------------------------
|
||
|
||
describe('http-transport: tools/call dispatch', () => {
|
||
let srv: TestServer;
|
||
const TOK = 'tok-fix';
|
||
|
||
beforeAll(async () => {
|
||
srv = await startTest({ validTokens: new Map([[hash(TOK), { id: 'tok-fix-id', name: 'fix' }]]) });
|
||
});
|
||
afterAll(() => srv.stop());
|
||
|
||
test('7. tools/call with a real op (list_pages) round-trips successfully (F1+F2 guard)', async () => {
|
||
// list_pages doesn't need real DB rows in this stub — it'll call engine methods we don't mock,
|
||
// so we expect EITHER a successful tool-result OR an isError result with a meaningful message.
|
||
// The point is that the handler IS invoked with (ctx, params) order — not (params, ctx).
|
||
// If F1 regressed, the handler would receive {limit: 1} as ctx and crash trying to read ctx.engine.
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' },
|
||
body: rpc('tools/call', { name: 'list_pages', arguments: { limit: 1 } }),
|
||
});
|
||
expect(r.status).toBe(200);
|
||
const body = await r.json();
|
||
expect(body.jsonrpc).toBe('2.0');
|
||
expect(body.result).toBeDefined();
|
||
expect(body.result.content).toBeArray();
|
||
// Either success (handler ran) or a structured error (handler ran and returned an error)
|
||
// — both prove dispatch reached the handler with the correct shape.
|
||
});
|
||
|
||
test('8. tools/call with malformed params → 200 wrapping an isError result (F3 guard via dispatch.ts)', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' },
|
||
// get_page expects `slug` as required string; passing a number triggers validateParams
|
||
body: rpc('tools/call', { name: 'get_page', arguments: { slug: 42 } }),
|
||
});
|
||
expect(r.status).toBe(200);
|
||
const body = await r.json();
|
||
expect(body.result.isError).toBe(true);
|
||
const text = body.result.content[0].text;
|
||
expect(text).toContain('invalid_params');
|
||
});
|
||
|
||
test('9. /mcp response has Content-Type: application/json (not SSE)', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' },
|
||
body: rpc('tools/list'),
|
||
});
|
||
expect(r.headers.get('content-type')).toContain('application/json');
|
||
expect(r.headers.get('content-type')).not.toContain('event-stream');
|
||
});
|
||
|
||
test('9b. unknown tool name → 200 wrapping an isError result', async () => {
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' },
|
||
body: rpc('tools/call', { name: 'definitely_not_a_real_tool', arguments: {} }),
|
||
});
|
||
expect(r.status).toBe(200);
|
||
const body = await r.json();
|
||
expect(body.result.isError).toBe(true);
|
||
expect(body.result.content[0].text).toContain('Unknown tool');
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------------------------
|
||
// CORS
|
||
// --------------------------------------------------------------------------
|
||
|
||
describe('http-transport: CORS', () => {
|
||
test('10. no GBRAIN_HTTP_CORS_ORIGIN + browser request → no ACAO header', async () => {
|
||
const srv = await startTest({});
|
||
try {
|
||
const r = await fetch(`${srv.url}/health`, { headers: { 'Origin': 'https://evil.example' } });
|
||
expect(r.headers.get('access-control-allow-origin')).toBeNull();
|
||
} finally { srv.stop(); }
|
||
});
|
||
|
||
test('11. env set + matching Origin → ACAO echoes', async () => {
|
||
const srv = await startTest({ corsOrigin: 'https://claude.ai' });
|
||
try {
|
||
const r = await fetch(`${srv.url}/health`, { headers: { 'Origin': 'https://claude.ai' } });
|
||
expect(r.headers.get('access-control-allow-origin')).toBe('https://claude.ai');
|
||
expect(r.headers.get('vary')).toBe('Origin');
|
||
} finally { srv.stop(); }
|
||
});
|
||
|
||
test('12. env set + non-matching Origin → no ACAO header', async () => {
|
||
const srv = await startTest({ corsOrigin: 'https://claude.ai' });
|
||
try {
|
||
const r = await fetch(`${srv.url}/health`, { headers: { 'Origin': 'https://evil.example' } });
|
||
expect(r.headers.get('access-control-allow-origin')).toBeNull();
|
||
} finally { srv.stop(); }
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Body cap
|
||
// --------------------------------------------------------------------------
|
||
|
||
describe('http-transport: body cap', () => {
|
||
const TOK = 'body-cap-tok';
|
||
|
||
test('13. Content-Length over cap → 413', async () => {
|
||
const srv = await startTest({
|
||
validTokens: new Map([[hash(TOK), { id: 'b-1', name: 'b' }]]),
|
||
bodyCap: 100,
|
||
});
|
||
try {
|
||
const big = 'x'.repeat(200);
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' },
|
||
body: big,
|
||
});
|
||
expect(r.status).toBe(413);
|
||
} finally { srv.stop(); }
|
||
});
|
||
|
||
test('14. chunked transfer (no Content-Length) over cap → 413', async () => {
|
||
const srv = await startTest({
|
||
validTokens: new Map([[hash(TOK), { id: 'b-2', name: 'b' }]]),
|
||
bodyCap: 100,
|
||
});
|
||
try {
|
||
// Build a chunked body via a ReadableStream — Bun fetch sends without Content-Length.
|
||
const stream = new ReadableStream({
|
||
start(controller) {
|
||
for (let i = 0; i < 10; i++) controller.enqueue(new TextEncoder().encode('y'.repeat(50)));
|
||
controller.close();
|
||
},
|
||
});
|
||
const r = await fetch(`${srv.url}/mcp`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' },
|
||
body: stream as any,
|
||
// @ts-expect-error Bun fetch supports duplex for streaming bodies
|
||
duplex: 'half',
|
||
});
|
||
expect(r.status).toBe(413);
|
||
} finally { srv.stop(); }
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Rate limit
|
||
// --------------------------------------------------------------------------
|
||
|
||
describe('http-transport: rate limit', () => {
|
||
const TOK = 'rl-tok';
|
||
|
||
test('15. token bucket: refill mechanic over time', async () => {
|
||
freezeClock(1000);
|
||
const srv = await startTest({
|
||
validTokens: new Map([[hash(TOK), { id: 'rl-id', name: 'rl' }]]),
|
||
tokenLimit: 2,
|
||
ipLimit: 100,
|
||
});
|
||
try {
|
||
// Use up 2 tokens
|
||
const ok1 = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(ok1.status).toBe(200);
|
||
const ok2 = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(ok2.status).toBe(200);
|
||
|
||
// Third should 429
|
||
const blocked = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(blocked.status).toBe(429);
|
||
|
||
// Advance past the refill window (60s for 2 limit = 30s/token; advance 35s)
|
||
advanceClock(35_000);
|
||
const refilled = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(refilled.status).toBe(200);
|
||
} finally { srv.stop(); freezeClock(0); }
|
||
});
|
||
|
||
test('16. token bucket exhausted → 429 + Retry-After header', async () => {
|
||
freezeClock(1000);
|
||
const srv = await startTest({
|
||
validTokens: new Map([[hash(TOK), { id: 'rl16', name: 'rl' }]]),
|
||
tokenLimit: 1,
|
||
ipLimit: 100,
|
||
});
|
||
try {
|
||
await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
const r = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(r.status).toBe(429);
|
||
expect(r.headers.get('retry-after')).not.toBeNull();
|
||
expect(parseInt(r.headers.get('retry-after')!, 10)).toBeGreaterThan(0);
|
||
} finally { srv.stop(); freezeClock(0); }
|
||
});
|
||
|
||
test('17. LRU eviction at cap (insert > cap evicts LRU)', () => {
|
||
let now = 0;
|
||
const lim = new RateLimiter({ limit: 10, windowMs: 60_000, lruCap: 3 }, () => now);
|
||
lim.check('a'); now += 1;
|
||
lim.check('b'); now += 1;
|
||
lim.check('c'); now += 1;
|
||
expect(lim.size).toBe(3);
|
||
lim.check('d'); now += 1;
|
||
expect(lim.size).toBe(3);
|
||
// 'a' should have been evicted (oldest by insertion). After re-checking 'a' it's a fresh bucket again.
|
||
// Easiest verification: hammer 'a' should NOT be already exhausted — fresh bucket starts at limit.
|
||
for (let i = 0; i < 10; i++) {
|
||
const r = lim.check('a');
|
||
expect(r.allowed).toBe(true);
|
||
}
|
||
// 11th should fail (no refill since clock barely moved)
|
||
expect(lim.check('a').allowed).toBe(false);
|
||
});
|
||
|
||
test('18. TTL prune (entries older than 2× window evicted)', () => {
|
||
let now = 1000;
|
||
const lim = new RateLimiter({ limit: 10, windowMs: 1000, lruCap: 100 }, () => now);
|
||
lim.check('stale'); // touched at t=1000
|
||
expect(lim.size).toBe(1);
|
||
now = 1000 + 2001; // advance past 2× window
|
||
lim.check('fresh'); // triggers prune
|
||
expect(lim.size).toBe(1); // 'stale' evicted, only 'fresh' remains
|
||
});
|
||
|
||
test('19. pre-auth IP bucket fires BEFORE auth (DB not called when IP exhausted)', async () => {
|
||
freezeClock(1000);
|
||
const srv = await startTest({
|
||
ipLimit: 1,
|
||
tokenLimit: 100,
|
||
validTokens: new Map([[hash(TOK), { id: 'rl19', name: 'rl' }]]),
|
||
});
|
||
try {
|
||
// First request consumes IP token (will hit auth and succeed)
|
||
const r1 = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(r1.status).toBe(200);
|
||
// Second: IP bucket exhausted. We send WITHOUT auth header. Should be 429 (IP-limited),
|
||
// not 401 (auth-failed) — proving IP check happened first.
|
||
const r2 = await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
expect(r2.status).toBe(429);
|
||
} finally { srv.stop(); freezeClock(0); }
|
||
});
|
||
|
||
test('20. /health bypasses rate limit', async () => {
|
||
freezeClock(1000);
|
||
const srv = await startTest({ ipLimit: 1, tokenLimit: 1 });
|
||
try {
|
||
// Hammer health 5 times — none should 429
|
||
for (let i = 0; i < 5; i++) {
|
||
const r = await fetch(`${srv.url}/health`);
|
||
expect(r.status).toBe(200);
|
||
}
|
||
} finally { srv.stop(); freezeClock(0); }
|
||
});
|
||
});
|
||
|
||
// --------------------------------------------------------------------------
|
||
// mcp_request_log audit
|
||
// --------------------------------------------------------------------------
|
||
|
||
describe('http-transport: mcp_request_log audit', () => {
|
||
test('21. successful request → audit row with token_name + operation + status', async () => {
|
||
const TOK = 'audit-tok';
|
||
const srv = await startTest({ validTokens: new Map([[hash(TOK), { id: 'a-1', name: 'audit-test' }]]) });
|
||
try {
|
||
await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': `Bearer ${TOK}`, 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
// Audit insert is fire-and-forget; give it a tick to land in the fake handler
|
||
await new Promise(r => setTimeout(r, 10));
|
||
expect(srv.engine.audit.length).toBeGreaterThanOrEqual(1);
|
||
const row = srv.engine.audit[srv.engine.audit.length - 1];
|
||
expect(row.token_name).toBe('audit-test');
|
||
expect(row.operation).toBe('tools/list');
|
||
expect(row.status).toBe('success');
|
||
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
|
||
} finally { srv.stop(); }
|
||
});
|
||
|
||
test('22. failed auth → audit row with null token_name + auth_failed status', async () => {
|
||
const srv = await startTest({});
|
||
try {
|
||
await fetch(`${srv.url}/mcp`, { method: 'POST', headers: { 'Authorization': 'Bearer wrong', 'Content-Type': 'application/json' }, body: rpc('tools/list') });
|
||
await new Promise(r => setTimeout(r, 10));
|
||
expect(srv.engine.audit.length).toBeGreaterThanOrEqual(1);
|
||
const row = srv.engine.audit[srv.engine.audit.length - 1];
|
||
expect(row.token_name).toBeNull();
|
||
expect(row.status).toBe('auth_failed');
|
||
} finally { srv.stop(); }
|
||
});
|
||
});
|