Tasks 2 and 3 verified their work with throwaway scripts against a real spawned server that were never committed, so npm test protected none of it - index.ts's auth gate had zero automated coverage, and the @ts-nocheck agent-network.ts/self-improvement.ts rate-limit wiring had none beyond listTools(). Adds two hermetic integration suites (real spawned server, localhost only, no wallet/chain calls): - auth-gating.test.ts: both HTTP routes' 401/403 gating, the auth-before- session-lookup ordering, cross-identity rejection, token-leak scanning, and that the server survives all of it (the reviewer's severe finding - a missing early `return` after a 401 falls through into a second writeHead() and crashes the whole process via an unhandled rejection). - rate-limit-gating.test.ts: per-identity isolation and that exhaustion on a vector.ts tool also rate-limits agent-network.ts and self-improvement.ts tools for that identity, through the real registered tools rather than limiterFor() in isolation. Extends test/setup.ts additively (spawnServer/stopRawServer/RawServerHandle) so these tests can set MCP_AUTH_TOKENS and drive per-request Authorization headers by hand; startServer()/stopServer()/callTool() are unchanged and the existing run.test.ts still fails for exactly its pre-existing reason. Wires a new test:integration:auth script into CI's build-and-test job. All five mutations from the review were applied to a working-tree copy, built, and reverted per-mutation to prove the new tests actually fail: - removing either route's early `return` after a 401: caught (cascading ECONNREFUSED once the process crashes; a dedicated liveness test targets this directly) - the cross-identity check's undefined-lookup branch: not independently catchable - transports/sessionIdentities are always populated and cleared together, synchronously, so that state is unreachable via HTTP; documented in the report, verified a same-region operator-flip mutation IS caught cleanly - module-scope `identity` reference in self-improvement.ts: crashes the server at startup (ReferenceError at import time), caught via the before() hook - checkRateLimit restored to module scope in agent-network.ts: caught cleanly, isError:true / "rateLimiter is not defined" instead of the well-formed rate-limit text No file under any src/ changed. test:unit 67/67, test:smoke 26/26 (23 tools, 0 drift), typecheck clean, new suite 12/12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.1 KiB
TypeScript
131 lines
5.1 KiB
TypeScript
import { spawn, ChildProcess } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
|
|
const PROJECT_ROOT = resolve(import.meta.dirname!, '..');
|
|
|
|
export interface ServerContext {
|
|
client: Client;
|
|
process: ChildProcess;
|
|
port: number;
|
|
}
|
|
|
|
export function getMnemonic(): string {
|
|
const mnemonic = readFileSync(resolve(PROJECT_ROOT, 'mnemonic.txt'), 'utf-8').trim();
|
|
const wordCount = mnemonic.split(/\s+/).length;
|
|
const validLengths = [12, 15, 18, 21, 24];
|
|
if (!validLengths.includes(wordCount)) {
|
|
throw new Error(`mnemonic.txt has ${wordCount} words, expected one of: ${validLengths.join(', ')}`);
|
|
}
|
|
return mnemonic;
|
|
}
|
|
|
|
export async function startServer(): Promise<ServerContext> {
|
|
const port = 3100 + Math.floor(Math.random() * 900);
|
|
const child = spawn('node', ['build/index.js'], {
|
|
cwd: PROJECT_ROOT,
|
|
env: { ...process.env, PORT: String(port) },
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error('Server start timeout (60s)')), 60_000);
|
|
child.stderr!.on('data', (chunk: Buffer) => {
|
|
if (chunk.toString().includes('listening on port')) {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
}
|
|
});
|
|
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
child.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}`)); });
|
|
});
|
|
|
|
const transport = new SSEClientTransport(new URL(`http://localhost:${port}/sse`));
|
|
const client = new Client({ name: 'integration-test', version: '1.0.0' });
|
|
await client.connect(transport);
|
|
|
|
return { client, process: child, port };
|
|
}
|
|
|
|
export async function stopServer(ctx: ServerContext): Promise<void> {
|
|
try { await ctx.client.close(); } catch {}
|
|
ctx.process.kill('SIGTERM');
|
|
}
|
|
|
|
export interface RawServerHandle {
|
|
process: ChildProcess;
|
|
port: number;
|
|
/** Snapshot of everything the server has written to stderr so far. */
|
|
stderr(): string;
|
|
}
|
|
|
|
/**
|
|
* Spawn the built server directly, without connecting an SDK client.
|
|
*
|
|
* startServer() above always connects a default Client with no Authorization
|
|
* header, which is exactly right for the pre-existing suites (auth is never
|
|
* configured there) but wrong for auth-gating/rate-limit tests: those need
|
|
* MCP_AUTH_TOKENS set, several distinct Authorization headers driven by hand
|
|
* (including deliberately-wrong ones), and a captured stderr stream to prove
|
|
* no token leaks into it. A bare "add an env param" to startServer() can't
|
|
* serve those needs without either connecting a client that immediately 401s
|
|
* (auth on, no header) or changing ServerContext's shape for every existing
|
|
* caller - so this is a separate, additive function instead.
|
|
*/
|
|
export async function spawnServer(envOverrides: Record<string, string> = {}): Promise<RawServerHandle> {
|
|
// Different port range than startServer()'s 3100-3999, and retried on
|
|
// failure: this suite spawns more servers per run than the pre-existing
|
|
// ones, so a single random draw is more likely to collide under CI
|
|
// parallelism (multiple test files spawning concurrently).
|
|
const maxAttempts = 5;
|
|
let lastErr: Error | undefined;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const port = 4100 + Math.floor(Math.random() * 1800);
|
|
const child = spawn('node', ['build/index.js'], {
|
|
cwd: PROJECT_ROOT,
|
|
env: { ...process.env, PORT: String(port), ...envOverrides },
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
|
|
let stderrBuf = '';
|
|
child.stderr!.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); });
|
|
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error('Server start timeout (60s)')), 60_000);
|
|
const onData = () => {
|
|
if (stderrBuf.includes('listening on port')) {
|
|
clearTimeout(timeout);
|
|
child.stderr!.off('data', onData);
|
|
resolve();
|
|
}
|
|
};
|
|
child.stderr!.on('data', onData);
|
|
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
child.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}. stderr so far: ${stderrBuf}`)); });
|
|
});
|
|
return { process: child, port, stderr: () => stderrBuf };
|
|
} catch (err) {
|
|
lastErr = err as Error;
|
|
try { child.kill('SIGKILL'); } catch {}
|
|
}
|
|
}
|
|
throw lastErr ?? new Error('spawnServer: exhausted retries');
|
|
}
|
|
|
|
export async function stopRawServer(handle: RawServerHandle): Promise<void> {
|
|
handle.process.kill('SIGTERM');
|
|
}
|
|
|
|
export async function callTool(client: Client, name: string, args: Record<string, unknown> = {}): Promise<string> {
|
|
const result = await client.callTool({ name, arguments: args });
|
|
const content = result.content as Array<{ type: string; text?: string }>;
|
|
return content?.[0]?.text ?? '';
|
|
}
|
|
|
|
export function wait(seconds: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
|
}
|