Files
gbrain/test/http-transport.test.ts
T
d3b52edeba v0.22.7 fix: built-in HTTP transport with bearer auth for remote MCP (#483)
* fix: add built-in HTTP transport with bearer auth for remote MCP

Adds `gbrain serve --http` with token-based authentication using the
existing access_tokens table. Eliminates the need for standalone OAuth
wrappers that may have insecure open registration endpoints.

- New: src/mcp/http-transport.ts — HTTP+SSE transport with bearer auth
- New: SECURITY.md — security advisory for remote MCP deployments
- Updated: serve command accepts --http and --port flags
- Updated: DEPLOY.md recommends --http for remote access
- Bump: 0.22.4 → 0.22.5

* chore: extract shared MCP dispatch + rate-limit modules

dispatch.ts is the single source of truth for stdio + HTTP transport: validateParams,
OperationContext build, handler invocation, error formatting. Server.ts refactored to
use it. Prevents the F1-F3 transport-drift bugs where stdio and HTTP independently
implemented dispatch logic differently (reversed args, missing context fields, no
param validation).

rate-limit.ts: bounded-LRU token-bucket. Tracks lastTouchedMs separately from
lastRefillMs so an exhausted key can't be reset by hammering past the TTL.

* feat: HTTP transport hardening + F1-F3 dispatch bug fixes

Rewrite of src/mcp/http-transport.ts on top of the new dispatch.ts and rate-limit.ts:

- F1 fix: dispatch via shared dispatchToolCall(ctx, params) — was reversed args
  (params, ctx) before, would have crashed every real tools/call.
- F2 fix: full OperationContext (engine, config, logger, dryRun, remote) — was
  only {engine, remote: true} before.
- F3 fix: validateParams runs on HTTP path — was skipped before.
- Engine.kind fail-fast: clear error message on PGLite (access_tokens table is
  Postgres-only by design).
- CORS: default-deny via GBRAIN_HTTP_CORS_ORIGIN allowlist.
- Body cap: stream-counted via req.body reader, catches chunked transfers
  without Content-Length. Default 1 MiB via GBRAIN_HTTP_MAX_BODY_BYTES.
- Rate limit: pre-auth IP bucket fires BEFORE DB lookup (limits brute-force
  load), post-auth token-id bucket fires after auth (limits runaway clients).
  Both bounded LRU with TTL prune.
- mcp_request_log: per-request audit row reusing the existing schema (v4).
- last_used_at SQL-level debounce: WHERE last_used_at < now() - interval
  '60 seconds'. Race-tolerant under PgBouncer.
- Response shape: application/json (gbrain MCP tools don't stream).
  Streamable-HTTP transport spec compliant for non-streaming responses.
- X-Forwarded-For honored only when GBRAIN_HTTP_TRUST_PROXY=1.

* feat: wire gbrain auth into the main CLI

The original PR's docs referenced 'gbrain auth create/list/revoke' but auth.ts
was a standalone script never wired to the CLI dispatcher. Running 'gbrain auth'
from the compiled binary returned 'Unknown command'.

- auth.ts: extract the dispatch into runAuth(args) + import.meta.main guard
  so direct-script invocation still works (bun run src/commands/auth.ts ...).
- cli.ts: add 'auth' to CLI_ONLY set + handler in handleCliOnly that imports
  runAuth and dispatches without requiring an engine connection (auth.ts
  manages its own postgres() connection).

* test: HTTP transport unit + E2E coverage (23 + 8 cases)

test/http-transport.test.ts — 23 unit cases against mocked engine.sql:
  - Auth: valid/missing/no-Bearer/unknown/revoked/health-bypass (1-6)
  - F1+F2 round-trip via dispatch.ts (7) — regression guard for reversed args
  - F3 invalid_params via validateParams (8) — regression guard
  - Response Content-Type application/json, not SSE (9)
  - CORS default-deny + allowlist + non-match (10-12)
  - Body cap: Content-Length + chunked-transfer (13-14)
  - Rate limit: refill, exhaust+Retry-After, LRU eviction, TTL prune,
    pre-auth IP fires before DB, /health bypasses (15-20)
  - mcp_request_log audit: success row + auth_failed row (21-22)

test/e2e/http-transport.test.ts — 8 cases against real Postgres:
  - /health, tools/list, tools/call list_pages (real op round-trip),
    revoked → 401, last_used_at debounce within 60s (asserts ONE update),
    debounce 65s gap (asserts TWO updates), mcp_request_log row check,
    invalid_params via real handler.

* docs: v0.22.7 CHANGELOG + SECURITY.md + DEPLOY.md

CHANGELOG: v0.22.7 release notes covering the F1-F3 dispatch fixes, the full
hardening surface (CORS default-deny, two-bucket rate limit, body cap, audit
log), and the upgrade path. Master's v0.22.6 schema-verify entry stitched in
above (preserving merge ordering).

SECURITY.md: full hardening reference for gbrain serve --http — Postgres-only
caveat, CORS allowlist, rate limit + tunnel caveat, body cap, audit log query,
GBRAIN_HTTP_TRUST_PROXY warning.

docs/mcp/DEPLOY.md: Postgres-only call-out, env var summary, fail-fast behavior
on PGLite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: codex review follow-ups (DB-probing /health + XFF trust safety contract)

- /health now does SELECT 1 against Postgres and returns 503 + status:unhealthy
  when the DB is unreachable. Prevents the failure mode where orchestration
  sees green pods while clients get misleading 401s during a DB outage.
- SECURITY.md: tighten the GBRAIN_HTTP_TRUST_PROXY=1 guidance with the explicit
  two-condition safety contract — gbrain bound to a private interface AND the
  proxy strips client-supplied XFF. Without both, the flag enables IP spoofing
  past the pre-auth rate limit.
- Tests: add 6b (/health DB-down → 503) + assert db:'ok' on the happy path.

Caught by codex adversarial review during /ship Step 11.

* docs: TODOS.md — v0.22.7 follow-ups (audit volume, validateParams enums, SSE, scopes)

* docs: update project documentation for v0.22.7

CLAUDE.md: document src/mcp/dispatch.ts, src/mcp/rate-limit.ts, and the
rewritten src/mcp/http-transport.ts in the Key files section. Add
test/http-transport.test.ts (23 unit cases) and test/e2e/http-transport.test.ts
(8 E2E cases) to the test inventories.

CHANGELOG.md: fix copy-paste version mismatches inside the v0.22.7 entry that
referenced v0.22.5 (header line + "To take advantage of" block).

README.md: replace the standalone bun-run auth invocation with the wired-in
gbrain auth CLI; add gbrain serve --http startup step to the Remote MCP
example; surface gbrain auth in the admin command list; link SECURITY.md
from the Remote MCP section so it's discoverable.

SECURITY.md: align "as of v0.22.5" callouts with the actual release version
(v0.22.7).

docs/mcp/DEPLOY.md: align v0.22.5+ callout with v0.22.7+; switch token-management
examples from `bun run src/commands/auth.ts` to `gbrain auth` now that auth is
in the main CLI.

docs/mcp/ALTERNATIVES.md: drop the "planned but not yet implemented" note for
gbrain serve --http; document that the built-in HTTP transport is the
recommended path.

docs/mcp/{CLAUDE_DESKTOP,CLAUDE_COWORK,CLAUDE_CODE,PERPLEXITY}.md: switch
token-creation examples from `bun run src/commands/auth.ts create` to
`gbrain auth create` to match the wired-in CLI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: typecheck — cast CallToolRequestSchema handler return to any

MCP SDK 1.29 widened the response type for setRequestHandler(CallToolRequestSchema, ...)
to require a 'task' field for managed-task responses. gbrain ops are synchronous and
return the legacy { content, isError? } shape, which is still valid via the SDK's
ServerResult union. Casting the handler return type to any silences the narrowing
that broke after dispatch.ts was extracted (the original inline handler dodged this
because TypeScript inferred its return as any from the function body).

CI failure: src/mcp/server.ts(25,51): error TS2345 — Property 'task' is missing in
type 'ToolResult' but required in type '{ ...; task: { taskId: string; ... }; ... }'.
Caught by the 'test' job's bun run typecheck step at PR #483 commit 65ea9e7.

* docs: regenerate llms-full.txt after master merge

The build-llms regen-drift guard fails when committed llms.txt + llms-full.txt
don't match what scripts/build-llms.ts produces from current source. Master's
v0.22.6.1 merge brought in new content (CLAUDE.md entries, CHANGELOG, etc.)
that hadn't been folded into the bundle. Running 'bun run build:llms' to sync.

llms.txt unchanged; llms-full.txt picks up the new entries.

* docs: CHANGELOG — scrub attack-surface enumeration from v0.22.7 entry

Per CLAUDE.md responsible-disclosure rule: 'when a release fixes a security
gap or a user-impacting bug, describe the fix functionally. Do not enumerate
the attack surface, quantify the exposure window, or highlight the most
sensitive records by name in public-facing artifacts.'

Removed:
- Lead-paragraph attack-chain ('attacker who discovers URL → POST /register
  → client_credentials → read entire brain'). Public-doc readers don't need
  the directed probe path.
- 'Bug fixes folded in' section that itemized prior-version failure modes.
  Reframed as a 'transport refactor' note in the For Contributors section,
  describing the dispatch consolidation functionally without claiming the
  prior version was broken in specific ways.
- 'Without the OAuth footgun' lead headline. The fix's mechanism (built-in
  bearer auth via access_tokens) is already self-evident from the headline.
- F1/F2/F3 internal labels and 'caught by codex outside-voice during
  planning' parenthetical.

Kept:
- The full hardening reference table (configuration / behavior, not exposure).
- 'gbrain serve --http' user-facing operator ergonomics.
- 'Postgres-only by design' known-limit framing.
- Dispatch consolidation as a contributor-facing single-source-of-truth note.

SECURITY.md left intact: its OAuth-deployment guidance is generic 'if you
deploy MCP behind a custom HTTP wrapper, here are the rules' framing, not
gbrain-version-specific exposure. That's defensible under the same rule.

* docs: SECURITY.md — drop unverified security@garrytan.com address

The address was in the original PR's SECURITY.md commit (6e740590, author
'root <root@localhost>' — machine-generated) and never verified to exist or
forward anywhere. A non-monitored disclosure address is worse than no address
at all: reports go to a black hole.

Keep the GitHub private security advisory link as the sole disclosure channel.
GitHub Security Advisories is the working path most researchers reach for
first anyway — restricted-access by default, scopes the conversation to
maintainers, and integrates with CVE issuance when needed.

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 17:01:40 -07:00

520 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
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);
};
}
function hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
interface FakeEngineConfig {
validTokens?: Map<string, { id: string; name: 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'] = [];
const sql = makeSqlTag((query, values) => {
if (cfg.dbDown && query.startsWith('SELECT')) throw new Error('db down');
if (query === 'SELECT 1') {
// /health DB probe
return [{ '?column?': 1 }];
}
if (query.startsWith('SELECT id, name FROM access_tokens')) {
const tokenHash = values[0] as string;
if (revokedTokens.has(tokenHash)) return [];
const row = validTokens.get(tokenHash);
return row ? [row] : [];
}
if (query.startsWith('UPDATE access_tokens')) {
// last_used_at debounce — succeed silently
return [];
}
if (query.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 [];
});
return { kind: 'postgres', 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(); }
});
});