Files
gbrain/test/http-transport.test.ts
T
6af0c91e53 v0.41.3.0 fix(security/mcp): OAuth CORS lockdown + pre-register without DCR + validator surface (#1403)
* v0.41.3.0 fix(security/mcp): OAuth CORS lockdown, pre-register without DCR, validator surface

Three expanded cherry-picks plus codex-surfaced live-CORS fix, parser
rewrite, atomicity fix, DCR validator gate, SECURITY.md reconciliation.

What ships
- gbrain auth register-client gets --redirect-uri (repeatable) and
  --token-endpoint-auth-method flags so the SECURITY.md-recommended
  "pre-register without --enable-dcr" path actually works for claude.ai
  and ChatGPT custom connectors.
- ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = {client_secret_post,
  client_secret_basic, none} validator gates all three registration
  entry points (CLI, admin endpoint, DCR /register) so --enable-dcr is
  no longer the looser path.
- Live Express OAuth server (/mcp, /token, /authorize, /register,
  /revoke) was using default-wide-open cors() middleware — every
  origin could complete a token exchange from a logged-in operator's
  browser. Now default-deny; allowlist via GBRAIN_HTTP_CORS_ORIGIN.
- GBRAIN_HTTP_TRUST_PROXY env var on Express server with the same
  semantics as the legacy bearer transport already had. Default
  'loopback' preserved. SECURITY.md doc rewritten to match reality
  (was lying that trust proxy was "disabled by default" while code
  hardcoded 'loopback').
- Admin endpoint registration now atomic — INSERT-then-UPDATE for
  public clients replaced with single INSERT via the new
  registerClientManual(..., tokenEndpointAuthMethod) parameter (codex
  outside-voice F4 catch).
- Legacy transport corsHeaders + corsPreflightHeaders consolidated
  into one function gated on the allowlist for BOTH Allow-Origin and
  Allow-Methods/Headers (codex F1; #983 thematically).

Surfaced by D7 codex outside-voice review on the v0.41.3 plan:
F1 (live Express CORS wide-open), F2 (indexOf parser couldn't do
repeatable flags), F3 (client_secret_basic missing from validator),
F4 (admin endpoint INSERT-then-UPDATE atomicity), F5 (DCR path
bypassed validator), F6 (env var already existed on legacy transport),
F7 (SECURITY.md vs impl doc disagreement).

Tests: 183 directly-touched cases green. Three new test files
(test/serve-http-trust-proxy.test.ts, test/serve-http-cors.test.ts,
test/auth-register-client-args.test.ts) + 18 new oauth.test.ts cases
+ 4 IRON RULE CORS preflight regressions.

Plan: ~/.claude/plans/system-instruction-you-are-working-wise-piglet.md
(D1-D11 captured, codex outside-voice integrated, GSTACK REVIEW REPORT
verdict CLEARED).

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

* fix(test): audit-writer readRecent calendar-boundary flake

writer.log() uses real `new Date()` for filename computation, but the
test mocked `now` to 2026-05-22. When CI runs on a date in a different
ISO week (e.g. 2026-05-25 W22 vs the mocked W21), log() writes to one
file but readRecent(now) reads a different one — zero events overlap,
expect(2).toBe(0) fails.

Fix: write events directly to the file matching the test's mocked
`now` via writer.computeFilename(now), same pattern the cross-week
straddle test (line 234+) already used for the previous-week event.

Pre-existing test bug, surfaced when CI rolled past the week boundary
the original author wrote against. Not introduced by v0.41.3.0; fix
included here because /ship found it.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:57:30 -07:00

625 lines
25 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';
// 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(); }
});
// v0.41.3 (T6 IRON RULE regression): pre-fix corsPreflightHeaders emitted
// Access-Control-Allow-Methods + Access-Control-Allow-Headers to every
// Origin unconditionally, leaking the API surface to attackers probing
// OPTIONS. The fix consolidates corsHeaders + corsPreflightHeaders into
// one function gated on the allowlist. These 4 cases pin the matrix.
describe('CORS preflight (T6 — consolidated corsHeaders)', () => {
test('preflight + no allowlist + any Origin → NO Allow-Methods/Headers', async () => {
const srv = await startTest({});
try {
const r = await fetch(`${srv.url}/mcp`, {
method: 'OPTIONS',
headers: { 'Origin': 'https://evil.example', 'Access-Control-Request-Method': 'POST' },
});
expect(r.headers.get('access-control-allow-origin')).toBeNull();
expect(r.headers.get('access-control-allow-methods')).toBeNull();
expect(r.headers.get('access-control-allow-headers')).toBeNull();
} finally { srv.stop(); }
});
test('preflight + allowlist set + matching Origin → echoes ACAO + Methods + Headers', async () => {
const srv = await startTest({ corsOrigin: 'https://claude.ai' });
try {
const r = await fetch(`${srv.url}/mcp`, {
method: 'OPTIONS',
headers: { 'Origin': 'https://claude.ai', 'Access-Control-Request-Method': 'POST' },
});
expect(r.headers.get('access-control-allow-origin')).toBe('https://claude.ai');
expect(r.headers.get('access-control-allow-methods')).toContain('POST');
expect(r.headers.get('access-control-allow-headers')).toContain('Authorization');
expect(r.headers.get('vary')).toBe('Origin');
} finally { srv.stop(); }
});
test('preflight + allowlist set + NON-matching Origin → NO Allow-Methods/Headers (the regression)', async () => {
const srv = await startTest({ corsOrigin: 'https://claude.ai' });
try {
const r = await fetch(`${srv.url}/mcp`, {
method: 'OPTIONS',
headers: { 'Origin': 'https://evil.example', 'Access-Control-Request-Method': 'POST' },
});
expect(r.headers.get('access-control-allow-origin')).toBeNull();
// THE BUG: pre-fix these two headers leaked unconditionally
expect(r.headers.get('access-control-allow-methods')).toBeNull();
expect(r.headers.get('access-control-allow-headers')).toBeNull();
} finally { srv.stop(); }
});
test('actual POST + allowlist set + NON-matching Origin → NO ACAO (browser blocks downstream)', async () => {
const srv = await startTest({ corsOrigin: 'https://claude.ai' });
try {
const r = await fetch(`${srv.url}/mcp`, {
method: 'POST',
headers: { 'Origin': 'https://evil.example', 'Authorization': 'Bearer x', 'Content-Type': 'application/json' },
body: '{}',
});
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(); }
});
});