Files
ruvnet--RuView/tools/ruview-mcp/tests/http-transport.test.ts
T
Claude 9109269329 fix(npm): implement ADR-263/264/265 — harness fail-closed + async MCP, rvagent packaging/transport/naming, npm CI+provenance gate
ADR-263 (@ruvnet/ruview 0.2.0), O1-O9:
- claim-check fails closed on empty input (CLI exit 2, empty_text tool error)
- MCP stdio server dispatches tools/call asynchronously (promise-based spawn);
  ping answers while a 3s fake verify runs — pinned by new e2e test
- optionalDependencies dropped: cold npx installs exactly 1 package
  (MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix)
- bounded rolling output tails replace spawnSync 1MiB maxBuffer
- node_monitor port passed via sys.argv, never spliced into python -c source
- serverInfo.version read from package.json; resources/prompts stubs
- skills single-sourced: prepack sync script generates .claude/skills/ copies
- which() = memoized dep-free PATH scan
- tools underscore-canonical (ruview_claim_check, ...) + dotted aliases
- guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label
  scrubbing, quantitative-claims-only; packaging reproducer hints
- 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins

ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9:
- exports fixed: types-first, phantom dist/index.cjs require target removed
- tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED,
  npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src)
- Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport +
  one MCP server per session (mcp-session-id routing), 1MiB body cap (413),
  port-aware localhost origin gate; dual-transport description now true
- tools renamed underscore-canonical with dotted router-only aliases
- single Zod validation gate; advertised inputSchema generated from the same
  Zod source (zod-to-json-schema)
- train_count: parent log fds closed (was leaking 2/job); job records
  persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded
  log-tail reads
- detectCogBinary probes its candidates instead of dead-coding them
- version from package.json; @types/express dropped; @types/jest -> 29
- README rewritten to match reality (no phantom subcommands/policy layer)
- 99/99 jest tests (incl. new session/body-cap suite + previously-broken
  manifest suite); stdio handshake + HTTP session flow smoke-tested live

ADR-265 D1-D4:
- .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests,
  version-literal grep (D3), pack-content/size gate, tarball-install smoke
  test (catches the ADR-264 F1 class), README claim-check (D4)
- .github/workflows/ruview-npm-release.yml: publish from CI only with
  npm publish --provenance
- @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to
  @ruvnet/ruview); version single-sourced
- ci.yml NODE_VERSION 18 -> 20

ADR statuses updated to Accepted/implemented; harness manifest re-pinned;
ADR-263/264/265 + both package READMEs pass claim-check.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
2026-07-02 13:03:20 +00:00

232 lines
8.4 KiB
TypeScript

/**
* ADR-124 §3 Architecture — Streamable HTTP transport security tests.
*
* Tests the Origin-validation middleware and bearer-token auth gate.
* No live MCP server needed for the guard logic — buildHttpApp is tested
* with a minimal stub McpServer that never actually processes JSON-RPC.
*
* Covered:
* 1. isOriginAllowed() unit tests — the pure function driving the gate
* 2. POST /mcp with cross-origin Origin → 403
* 3. POST /mcp with allowed Origin → passes Origin gate (non-403)
* 4. POST /mcp with no Origin header → passes Origin gate (non-403)
* 5. Bearer token required, wrong token → 401
* 6. Bearer token required, correct token + wildcard origin → passes (non-401)
*/
import * as http from "node:http";
import { isOriginAllowed, buildHttpApp } from "../src/http-transport.js";
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
// ── helpers ────────────────────────────────────────────────────────────────
function makeMockMcpServer(): McpServer {
return new McpServer(
{ name: "test-rvagent", version: "0.0.0" },
{ capabilities: { tools: {} } }
);
}
async function post(
port: number,
path: string,
headers: Record<string, string>,
body: string
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
method: "POST",
path,
headers: { "Content-Type": "application/json", ...headers },
},
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => { data += chunk.toString(); });
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
async function startServer(
opts: Parameters<typeof buildHttpApp>[1],
basePort: number
): Promise<{ port: number; close: () => Promise<void> }> {
const port = basePort + Math.floor(Math.random() * 100);
// Factory, not instance: each Streamable-HTTP session gets its own MCP
// Server (ADR-264 F7/O3).
const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => resolve());
});
const close = () =>
new Promise<void>((res, rej) =>
httpServer.close((e) => (e ? rej(e) : res()))
);
return { port, close };
}
const MCP_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
// ── 1. isOriginAllowed unit tests ──────────────────────────────────────────
describe("isOriginAllowed()", () => {
const allow = ["http://localhost", "http://127.0.0.1"];
it("allows undefined origin (non-browser request, no Origin header)", () => {
expect(isOriginAllowed(undefined, allow)).toBe(true);
});
it("allows an origin in the allowlist", () => {
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
});
it("rejects an origin NOT in the allowlist", () => {
expect(isOriginAllowed("https://evil.example.com", allow)).toBe(false);
});
it("allows anything when allowedOrigins includes '*'", () => {
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
});
// ADR-264 F7: real browser origins carry ports — localhost must match on
// hostname, any port, even with an empty allowlist.
it("allows localhost origins on any port", () => {
expect(isOriginAllowed("http://localhost:5173", [])).toBe(true);
expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true);
expect(isOriginAllowed("https://localhost:3001", [])).toBe(true);
});
it("rejects non-local origins even with a localhost-looking prefix", () => {
expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false);
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
});
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
});
});
// ── 2-4. Origin-validation integration tests ───────────────────────────────
describe("HTTP transport Origin-validation middleware", () => {
let port: number;
let close: () => Promise<void>;
beforeAll(async () => {
const srv = await startServer(
{ allowedOrigins: ["http://localhost", "http://127.0.0.1"] },
49200
);
port = srv.port;
close = srv.close;
});
afterAll(async () => { await close(); });
it("rejects cross-origin POST /mcp with 403", async () => {
const r = await post(port, "/mcp", { Origin: "https://evil.example.com" }, MCP_BODY);
expect(r.status).toBe(403);
const body = JSON.parse(r.body) as Record<string, unknown>;
expect(body["error"]).toMatch(/cross-origin/i);
});
it("passes Origin gate for http://localhost — status is not 403", async () => {
const r = await post(port, "/mcp", { Origin: "http://localhost" }, MCP_BODY);
expect(r.status).not.toBe(403);
});
it("passes Origin gate with no Origin header — status is not 403", async () => {
const r = await post(port, "/mcp", {}, MCP_BODY);
expect(r.status).not.toBe(403);
});
});
// ── 5-6. Bearer-token auth integration tests ──────────────────────────────
describe("HTTP transport bearer-token auth gate", () => {
const SECRET = "test-secret-token-xyz";
let port: number;
let close: () => Promise<void>;
beforeAll(async () => {
const srv = await startServer({ allowedOrigins: ["*"], bearerToken: SECRET }, 49400);
port = srv.port;
close = srv.close;
});
afterAll(async () => { await close(); });
it("rejects missing Authorization header with 401", async () => {
const r = await post(port, "/mcp", {}, MCP_BODY);
expect(r.status).toBe(401);
});
it("rejects wrong bearer token with 401", async () => {
const r = await post(port, "/mcp", { Authorization: "Bearer wrong" }, MCP_BODY);
expect(r.status).toBe(401);
});
it("passes auth gate with correct bearer token — status is not 401", async () => {
const r = await post(port, "/mcp", { Authorization: `Bearer ${SECRET}` }, MCP_BODY);
expect(r.status).not.toBe(401);
});
});
// ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ─────────────
describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
let port: number;
let close: () => Promise<void>;
beforeAll(async () => {
const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600);
port = srv.port;
close = srv.close;
});
afterAll(async () => { await close(); });
it("rejects oversized request bodies with 413", async () => {
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } });
const r = await post(port, "/mcp", {}, huge);
expect(r.status).toBe(413);
});
it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => {
const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id
expect(r.status).toBe(400);
const body = JSON.parse(r.body) as Record<string, unknown>;
expect(body["error"]).toMatch(/initialize/i);
});
it("rejects a POST with an unknown session id with 404", async () => {
const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY);
expect(r.status).toBe(404);
});
it("creates a fresh session (and MCP server) per initialize request", async () => {
const init = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "0.0.0" },
},
});
const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init);
expect([200, 406]).not.toContain(0); // sanity
expect(r.status).toBe(200);
});
});