diff --git a/tests/unit/_mitmHandlerHarness.ts b/tests/unit/_mitmHandlerHarness.ts new file mode 100644 index 0000000000..7d5a4c15b7 --- /dev/null +++ b/tests/unit/_mitmHandlerHarness.ts @@ -0,0 +1,97 @@ +/** + * Test harness for MitmHandlerBase subclasses. + * + * Mocks `globalThis.fetch` so handlers exercise their full intercept() path + * (router round-trip + SSE pipe) without touching the network. Returns the + * captured payload, response chunks written to the fake ServerResponse, and + * the final status code. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import type { MitmHandlerBase } from "../../src/mitm/handlers/base.ts"; + +export interface HarnessResult { + fetchCalled: boolean; + fetchUrl: string | null; + fetchHeaders: Record; + fetchBody: string; + status: number; + responseChunks: string[]; +} + +function fakeReq(headers: Record = {}): IncomingMessage { + return { + method: "POST", + url: "/v1/chat/completions", + headers: { + host: "api.example.com", + "user-agent": "ut", + ...headers, + }, + } as unknown as IncomingMessage; +} + +function fakeRes(): { res: ServerResponse; out: HarnessResult } { + const out: HarnessResult = { + fetchCalled: false, + fetchUrl: null, + fetchHeaders: {}, + fetchBody: "", + status: 0, + responseChunks: [], + }; + let headersSent = false; + const res = { + get headersSent() { + return headersSent; + }, + writeHead(s: number) { + out.status = s; + headersSent = true; + }, + write(c: Buffer | string) { + out.responseChunks.push(typeof c === "string" ? c : c.toString()); + return true; + }, + end(c?: Buffer | string) { + if (c) out.responseChunks.push(typeof c === "string" ? c : c.toString()); + }, + } as unknown as ServerResponse; + return { res, out }; +} + +export async function runHandler( + handler: MitmHandlerBase, + body: unknown, + mappedModel: string, + opts: { + upstreamStatus?: number; + upstreamBody?: string; + headers?: Record; + } = {} +): Promise { + const { res, out } = fakeRes(); + const req = fakeReq(opts.headers); + const buf = Buffer.from(typeof body === "string" ? body : JSON.stringify(body)); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init: RequestInit) => { + out.fetchCalled = true; + out.fetchUrl = String(url); + out.fetchHeaders = (init?.headers ?? {}) as Record; + out.fetchBody = typeof init?.body === "string" ? init.body : ""; + const upstreamBody = opts.upstreamBody ?? "data: hello\n\n"; + const status = opts.upstreamStatus ?? 200; + const stream = Readable.toWeb( + Readable.from(Buffer.from(upstreamBody)) + ) as unknown as ReadableStream; + return new Response(stream, { status }); + }) as unknown as typeof fetch; + + try { + await handler.intercept(req, res, buf, mappedModel); + } finally { + globalThis.fetch = originalFetch; + } + return out; +} diff --git a/tests/unit/mitm-detection.test.ts b/tests/unit/mitm-detection.test.ts new file mode 100644 index 0000000000..62996097ee --- /dev/null +++ b/tests/unit/mitm-detection.test.ts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { detectAgent, DETECTORS } from "../../src/mitm/detection/index.ts"; +import type { AgentId } from "../../src/mitm/types.ts"; + +test("DETECTORS — provides an entry for every AgentId", () => { + const ids: AgentId[] = [ + "antigravity", + "kiro", + "copilot", + "codex", + "cursor", + "zed", + "claude-code", + "open-code", + "trae", + ]; + for (const id of ids) { + assert.equal(typeof DETECTORS[id], "function", `missing detector for ${id}`); + } +}); + +test("detectAgent — trae always reports not installed (investigating)", () => { + const r = detectAgent("trae"); + assert.equal(r.installed, false); +}); + +test("detectAgent — returns installed=true when fs.existsSync hits a known path", () => { + // Inject a mock existsSync that returns true for every probed path so the + // dispatch path is exercised regardless of host environment. + const original = fs.existsSync; + let called = 0; + (fs as unknown as { existsSync: (p: fs.PathLike) => boolean }).existsSync = () => { + called++; + return true; + }; + try { + // antigravity uses pure existsSync probes — first hit wins. + const r = detectAgent("antigravity"); + assert.equal(r.installed, true); + assert.equal(typeof r.path, "string"); + } finally { + (fs as unknown as { existsSync: typeof fs.existsSync }).existsSync = original; + } + assert.ok(called >= 1); +}); + +test("detectAgent — antigravity probe returns DetectionResult shape", () => { + const r = detectAgent("antigravity"); + assert.equal(typeof r.installed, "boolean"); + if (r.installed) assert.equal(typeof r.path, "string"); +}); + +test("detectAgent — gracefully handles thrown detectors", () => { + // Unknown id falls through to default false branch. + const r = detectAgent("nonexistent" as AgentId); + assert.equal(r.installed, false); +}); + +test("detectAgent — runs all detectors without throwing", () => { + const ids: AgentId[] = [ + "antigravity", + "kiro", + "copilot", + "codex", + "cursor", + "zed", + "claude-code", + "open-code", + "trae", + ]; + for (const id of ids) { + const r = detectAgent(id); + assert.equal(typeof r.installed, "boolean"); + } +}); diff --git a/tests/unit/mitm-handler-antigravity.test.ts b/tests/unit/mitm-handler-antigravity.test.ts new file mode 100644 index 0000000000..2a6f32e723 --- /dev/null +++ b/tests/unit/mitm-handler-antigravity.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AntigravityHandler } from "../../src/mitm/handlers/antigravity.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("antigravity handler — forwards to OmniRoute and pipes SSE", async () => { + const r = await runHandler( + new AntigravityHandler(), + { model: "gpt-4o", messages: [{ role: "user", content: "hi" }] }, + "claude-3.5-sonnet", + { upstreamBody: "data: hello\n\ndata: world\n\n" } + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + assert.ok(r.responseChunks.join("").includes("hello")); +}); + +test("antigravity handler — propagates upstream failure as 500", async () => { + const r = await runHandler( + new AntigravityHandler(), + { model: "gpt-4o" }, + "claude-3.5-sonnet", + { upstreamStatus: 500, upstreamBody: "boom" } + ); + assert.equal(r.status, 500); + const body = r.responseChunks.join(""); + // Error must NOT include raw stack trace (Hard Rule #12 sanitization). + assert.ok(!body.includes("at /")); +}); diff --git a/tests/unit/mitm-handler-base.test.ts b/tests/unit/mitm-handler-base.test.ts new file mode 100644 index 0000000000..d2c5c4a56a --- /dev/null +++ b/tests/unit/mitm-handler-base.test.ts @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../../src/mitm/types.ts"; +import { MitmHandlerBase } from "../../src/mitm/handlers/base.ts"; + +// Concrete subclass exposing the protected helpers for testing. +class TestHandler extends MitmHandlerBase { + readonly agentId: AgentId = "antigravity"; + + async intercept(): Promise { + // Not exercised in this suite. + } + + publicExtract(buf: Buffer): string | null { + return this.extractSourceModel(buf); + } + + async publicHookStart( + req: IncomingMessage, + body: Buffer, + mapped: string + ): Promise> { + return this.hookBufferStart(req, body, mapped); + } +} + +function fakeReq(headers: Record = {}): IncomingMessage { + return { + method: "POST", + url: "/v1/chat/completions", + headers: { + host: "api.example.com", + "user-agent": "ut", + // 50-char opaque token — long enough to be masked by LONG_TOKEN rule. + authorization: "Bearer abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKL", + ...headers, + }, + } as unknown as IncomingMessage; +} + +test("base.extractSourceModel — reads body.model from JSON", () => { + const h = new TestHandler(); + const buf = Buffer.from(JSON.stringify({ model: "gpt-4o", messages: [] })); + assert.equal(h.publicExtract(buf), "gpt-4o"); +}); + +test("base.extractSourceModel — non-JSON body returns null", () => { + const h = new TestHandler(); + assert.equal(h.publicExtract(Buffer.from("not json")), null); +}); + +test("base.extractSourceModel — missing model field returns null", () => { + const h = new TestHandler(); + const buf = Buffer.from(JSON.stringify({ messages: [] })); + assert.equal(h.publicExtract(buf), null); +}); + +test("base.hookBufferStart — local stub returns InterceptedRequest with sanitized headers", async () => { + const h = new TestHandler(); + const req = fakeReq(); + const body = Buffer.from(JSON.stringify({ model: "gpt-4o" })); + const r = await h.publicHookStart(req, body, "claude-3.5-sonnet"); + + assert.equal(r.agent, "antigravity"); + assert.equal(r.source, "agent-bridge"); + assert.equal(r.mappedModel, "claude-3.5-sonnet"); + assert.equal(r.sourceModel, "gpt-4o"); + assert.equal(r.host, "api.example.com"); + assert.equal(r.status, "in-flight"); + // sanitizeHeaders should mask the long opaque token in `authorization`. + const auth = r.requestHeaders["authorization"]; + assert.ok( + !auth || + (typeof auth === "string" && + !auth.includes("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKL")), + `sanitizeHeaders failed to mask authorization: ${JSON.stringify(auth)}` + ); +}); + +test("base.hookBufferStart — body is captured (default shouldCaptureBody=true)", async () => { + const h = new TestHandler(); + const req = fakeReq(); + const body = Buffer.from(JSON.stringify({ model: "gpt-4o" })); + const r = await h.publicHookStart(req, body, "x"); + assert.equal(r.requestSize, body.length); + assert.ok(typeof r.requestBody === "string"); +}); + +test("base.writeError — writes sanitized JSON error body", async () => { + const h = new TestHandler(); + let status = 0; + let payload = ""; + const res = { + headersSent: false, + writeHead(s: number) { + status = s; + }, + end(p: string) { + payload = p; + }, + } as unknown as ServerResponse; + + // Calling a protected method through `any` to avoid leaking it on + // the production surface area. + await (h as unknown as { writeError: MitmHandlerBase["writeError"] }).writeError( + res, + new Error("boom"), + 502 + ); + assert.equal(status, 502); + const obj = JSON.parse(payload); + assert.equal(obj.error.type, "mitm_error"); + assert.ok(typeof obj.error.message === "string"); +}); diff --git a/tests/unit/mitm-handler-claudeCode.test.ts b/tests/unit/mitm-handler-claudeCode.test.ts new file mode 100644 index 0000000000..e02fe8c99b --- /dev/null +++ b/tests/unit/mitm-handler-claudeCode.test.ts @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ClaudeCodeHandler } from "../../src/mitm/handlers/claudeCode.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("claude-code handler — happy path forwards to /v1/messages", async () => { + const r = await runHandler( + new ClaudeCodeHandler(), + { model: "claude-3.5-sonnet", messages: [] }, + "claude-opus-4.5" + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + assert.ok(r.fetchUrl?.endsWith("/v1/messages")); + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "claude-opus-4.5"); +}); diff --git a/tests/unit/mitm-handler-codex.test.ts b/tests/unit/mitm-handler-codex.test.ts new file mode 100644 index 0000000000..58f1f45a42 --- /dev/null +++ b/tests/unit/mitm-handler-codex.test.ts @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CodexHandler } from "../../src/mitm/handlers/codex.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("codex handler — forwards Chat Completions payload via OmniRoute", async () => { + const r = await runHandler( + new CodexHandler(), + { model: "gpt-4.1", messages: [] }, + "gpt-4o-mini" + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + assert.ok(r.fetchUrl?.endsWith("/v1/chat/completions")); + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "gpt-4o-mini"); +}); diff --git a/tests/unit/mitm-handler-copilot.test.ts b/tests/unit/mitm-handler-copilot.test.ts new file mode 100644 index 0000000000..f29007cfcb --- /dev/null +++ b/tests/unit/mitm-handler-copilot.test.ts @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CopilotHandler } from "../../src/mitm/handlers/copilot.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("copilot handler — rewrites model and forwards to /v1/chat/completions", async () => { + const r = await runHandler( + new CopilotHandler(), + { model: "gpt-4o", messages: [] }, + "claude-3.5-sonnet" + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + assert.ok(r.fetchUrl?.endsWith("/v1/chat/completions")); + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "claude-3.5-sonnet"); + // AgentBridge correlation headers must be present. + const headers = r.fetchHeaders as Record; + assert.equal(headers["x-omniroute-source"], "agent-bridge"); + assert.equal(headers["x-omniroute-agent"], "copilot"); +}); diff --git a/tests/unit/mitm-handler-cursor.test.ts b/tests/unit/mitm-handler-cursor.test.ts new file mode 100644 index 0000000000..f28752830e --- /dev/null +++ b/tests/unit/mitm-handler-cursor.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CursorHandler } from "../../src/mitm/handlers/cursor.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("cursor handler — happy path forwards mapped model", async () => { + const r = await runHandler( + new CursorHandler(), + { model: "claude-sonnet-4.5", messages: [] }, + "gpt-4o" + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "gpt-4o"); +}); diff --git a/tests/unit/mitm-handler-kiro.test.ts b/tests/unit/mitm-handler-kiro.test.ts new file mode 100644 index 0000000000..832a31336a --- /dev/null +++ b/tests/unit/mitm-handler-kiro.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { KiroHandler } from "../../src/mitm/handlers/kiro.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("kiro handler — forwards Anthropic-style body to OmniRoute /v1/messages", async () => { + const r = await runHandler( + new KiroHandler(), + { model: "claude-3.5-sonnet", messages: [{ role: "user", content: "hi" }] }, + "claude-sonnet-4.5", + { upstreamBody: "event: message_start\n\n" } + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + // Router URL must point at /v1/messages for the Anthropic path. + assert.ok(r.fetchUrl?.endsWith("/v1/messages")); + // Body must have been rewritten with mapped model. + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "claude-sonnet-4.5"); +}); diff --git a/tests/unit/mitm-handler-openCode.test.ts b/tests/unit/mitm-handler-openCode.test.ts new file mode 100644 index 0000000000..2bb80a5123 --- /dev/null +++ b/tests/unit/mitm-handler-openCode.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { OpenCodeHandler } from "../../src/mitm/handlers/openCode.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("open-code handler — happy path forwards Chat Completions payload", async () => { + const r = await runHandler( + new OpenCodeHandler(), + { model: "gpt-4o", messages: [] }, + "claude-3.5-sonnet" + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "claude-3.5-sonnet"); +}); diff --git a/tests/unit/mitm-handler-trae.test.ts b/tests/unit/mitm-handler-trae.test.ts new file mode 100644 index 0000000000..ba342e18ce --- /dev/null +++ b/tests/unit/mitm-handler-trae.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { TraeHandler } from "../../src/mitm/handlers/trae.ts"; + +test("trae handler — intercept throws structured error (viability=investigating)", async () => { + const h = new TraeHandler(); + const req = { + method: "POST", + url: "/x", + headers: { host: "trae.invalid" }, + } as unknown as IncomingMessage; + const res = { + headersSent: false, + writeHead() {}, + end() {}, + } as unknown as ServerResponse; + + await assert.rejects( + () => h.intercept(req, res, Buffer.from("{}"), "gpt-4o"), + /investigation|invalid|not.*implement/i + ); +}); diff --git a/tests/unit/mitm-handler-zed.test.ts b/tests/unit/mitm-handler-zed.test.ts new file mode 100644 index 0000000000..e207852f09 --- /dev/null +++ b/tests/unit/mitm-handler-zed.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ZedHandler } from "../../src/mitm/handlers/zed.ts"; +import { runHandler } from "./_mitmHandlerHarness.ts"; + +test("zed handler — happy path forwards Chat Completions payload", async () => { + const r = await runHandler( + new ZedHandler(), + { model: "gpt-4o", messages: [] }, + "claude-3.5-sonnet" + ); + assert.ok(r.fetchCalled); + assert.equal(r.status, 200); + const sent = JSON.parse(r.fetchBody); + assert.equal(sent.model, "claude-3.5-sonnet"); +}); diff --git a/tests/unit/mitm-targets-resolve.test.ts b/tests/unit/mitm-targets-resolve.test.ts new file mode 100644 index 0000000000..ea14b8b2bf --- /dev/null +++ b/tests/unit/mitm-targets-resolve.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ALL_TARGETS, resolveTarget } from "../../src/mitm/targets/index.ts"; + +test("resolveTarget — antigravity host resolves to antigravity target", () => { + const t = resolveTarget("cloudcode-pa.googleapis.com"); + assert.ok(t); + assert.equal(t?.id, "antigravity"); +}); + +test("resolveTarget — kiro host resolves to kiro target (Anthropic)", () => { + // api.anthropic.com is shared by kiro and claude-code; the first match wins. + const t = resolveTarget("api.anthropic.com"); + assert.ok(t); + assert.ok(t?.id === "kiro" || t?.id === "claude-code"); +}); + +test("resolveTarget — copilot host resolves", () => { + assert.equal(resolveTarget("api.githubcopilot.com")?.id, "copilot"); +}); + +test("resolveTarget — cursor host resolves", () => { + assert.equal(resolveTarget("api2.cursor.sh")?.id, "cursor"); +}); + +test("resolveTarget — case-insensitive match", () => { + assert.equal(resolveTarget("API.ZED.DEV")?.id, "zed"); +}); + +test("resolveTarget — unknown host returns null", () => { + assert.equal(resolveTarget("example.com"), null); + assert.equal(resolveTarget(""), null); +}); + +test("ALL_TARGETS — registers exactly nine targets", () => { + assert.equal(ALL_TARGETS.length, 9); + const ids = new Set(ALL_TARGETS.map((t) => t.id)); + assert.equal(ids.size, 9); + assert.ok(ids.has("antigravity")); + assert.ok(ids.has("kiro")); + assert.ok(ids.has("copilot")); + assert.ok(ids.has("codex")); + assert.ok(ids.has("cursor")); + assert.ok(ids.has("zed")); + assert.ok(ids.has("claude-code")); + assert.ok(ids.has("open-code")); + assert.ok(ids.has("trae")); +}); + +test("ALL_TARGETS — trae is marked viability=investigating", () => { + const trae = ALL_TARGETS.find((t) => t.id === "trae"); + assert.equal(trae?.viability, "investigating"); +}); diff --git a/tests/unit/mitm-targets-route.test.ts b/tests/unit/mitm-targets-route.test.ts new file mode 100644 index 0000000000..b7567f1477 --- /dev/null +++ b/tests/unit/mitm-targets-route.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { routeConnection } from "../../src/mitm/targets/index.ts"; + +test("routeConnection — default bypass (bank) wins over target match", () => { + const r = routeConnection("my.bank.example", []); + assert.equal(r.kind, "bypass"); +}); + +test("routeConnection — user bypass glob beats target", () => { + const r = routeConnection("api.githubcopilot.com", ["*githubcopilot*"]); + assert.equal(r.kind, "bypass"); +}); + +test("routeConnection — known target returns target route", () => { + const r = routeConnection("api.githubcopilot.com", []); + assert.equal(r.kind, "target"); + if (r.kind === "target") assert.equal(r.target.id, "copilot"); +}); + +test("routeConnection — unknown host returns passthrough", () => { + const r = routeConnection("example.com", []); + assert.equal(r.kind, "passthrough"); +}); + +test("routeConnection — empty hostname returns passthrough", () => { + const r = routeConnection("", []); + assert.equal(r.kind, "passthrough"); +}); + +test("routeConnection — precedence: bypass > target > passthrough", () => { + // Default bypass (bank) takes precedence even if we add the host to the + // copilot target hypothetically — here we just exercise the three branches. + assert.equal(routeConnection("acme.bank.com", []).kind, "bypass"); + assert.equal(routeConnection("api.zed.dev", []).kind, "target"); + assert.equal(routeConnection("unrelated.example", []).kind, "passthrough"); +});