diff --git a/tests/unit/mitm-manager-bypass-json.test.ts b/tests/unit/mitm-manager-bypass-json.test.ts new file mode 100644 index 0000000000..47dca8a683 --- /dev/null +++ b/tests/unit/mitm-manager-bypass-json.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for `writeBypassJson()` in src/mitm/manager.ts. + * + * The function persists user bypass patterns to `/mitm/bypass.json` + * which is read by `src/mitm/server.cjs` at boot to drive the CONNECT + * handler's bypass routing decision. + * + * Plan reference: 11-agent-bridge.plan.md §4.6 + master-plan-group-A.md §3.5. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-mitm-bypass-json-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const bypassDb = await import("../../src/lib/db/agentBridgeBypass.ts"); +const manager = await import("../../src/mitm/manager.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | null)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("writeBypassJson — creates mitm/ dir and writes JSON file", () => { + manager.writeBypassJson(["custom.example.com"]); + const file = path.join(TEST_DATA_DIR, "mitm", "bypass.json"); + assert.ok(fs.existsSync(file), "bypass.json must exist after write"); + const payload = JSON.parse(fs.readFileSync(file, "utf-8")); + assert.equal(payload.version, 1); + assert.ok(typeof payload.generatedAt === "string"); + assert.deepEqual(payload.patterns, ["custom.example.com"]); +}); + +test("writeBypassJson — empty array writes empty patterns", () => { + manager.writeBypassJson([]); + const file = path.join(TEST_DATA_DIR, "mitm", "bypass.json"); + const payload = JSON.parse(fs.readFileSync(file, "utf-8")); + assert.deepEqual(payload.patterns, []); +}); + +test("writeBypassJson — pulls from DB when no patterns argument passed", () => { + // Seed user patterns via the DB module. + bypassDb.replaceUserBypassPatterns(["*.from-db.example.com", "literal.com"]); + manager.writeBypassJson(); + const file = path.join(TEST_DATA_DIR, "mitm", "bypass.json"); + const payload = JSON.parse(fs.readFileSync(file, "utf-8")); + assert.deepEqual( + payload.patterns.sort(), + ["*.from-db.example.com", "literal.com"].sort() + ); +}); + +test("writeBypassJson — does NOT write default patterns (those live in server.cjs)", () => { + // Seed defaults via the DB module — these should NOT appear in the JSON. + bypassDb.seedDefaultBypassPatterns([ + "*.bank.test", + "*.gov.test", + "okta.com", + "auth0.com", + ]); + manager.writeBypassJson(); + const file = path.join(TEST_DATA_DIR, "mitm", "bypass.json"); + const payload = JSON.parse(fs.readFileSync(file, "utf-8")); + // No user patterns were set → user-only output is empty. + assert.deepEqual(payload.patterns, []); +}); + +test("writeBypassJson — JSON is well-formed and overwrites previous file", () => { + manager.writeBypassJson(["first.com"]); + manager.writeBypassJson(["second.com", "third.com"]); + const file = path.join(TEST_DATA_DIR, "mitm", "bypass.json"); + const payload = JSON.parse(fs.readFileSync(file, "utf-8")); + assert.deepEqual(payload.patterns, ["second.com", "third.com"]); +}); diff --git a/tests/unit/mitm-server-connect.test.ts b/tests/unit/mitm-server-connect.test.ts new file mode 100644 index 0000000000..bf3307ff2d --- /dev/null +++ b/tests/unit/mitm-server-connect.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for the bypass / passthrough / target routing primitives used by + * the CJS proxy in `src/mitm/server.cjs`. + * + * The CJS proxy itself cannot be required in tests (it spawns a TLS server + * and exits when ROUTER_API_KEY is missing). Instead, we exercise the + * `_internal/bypass.cjs` shim that `server.cjs` depends on. That shim + * carries all the routing logic — `server.cjs` is now a thin wiring layer + * around it. + * + * Plan reference: + * - 11-agent-bridge.plan.md §4.6 + * - master-plan-group-A.md §3.5 / §12 #16 + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const requireCjs = createRequire(import.meta.url); +const shim = requireCjs("../../src/mitm/_internal/bypass.cjs") as { + DEFAULT_BYPASS_PATTERNS: RegExp[]; + bypassGlobMatch: (h: string, p: string) => boolean; + routeBypass: ( + h: string, + targetHosts: Set | string[] | undefined, + userPatterns: string[] + ) => "bypass" | "target" | "passthrough"; + parseBypassJson: (raw: string) => string[]; +}; + +const TARGETS = new Set([ + "daily-cloudcode-pa.googleapis.com", + "api.githubcopilot.com", +]); + +test("DEFAULT_BYPASS_PATTERNS — at least the 4 mandatory regexes", () => { + assert.ok(shim.DEFAULT_BYPASS_PATTERNS.length >= 4); + for (const re of shim.DEFAULT_BYPASS_PATTERNS) { + assert.ok(re instanceof RegExp); + } +}); + +test("routeBypass — default bank pattern → bypass", () => { + assert.equal(shim.routeBypass("my.bank.example", TARGETS, []), "bypass"); + assert.equal(shim.routeBypass("secure.bank.com", TARGETS, []), "bypass"); +}); + +test("routeBypass — default gov pattern → bypass", () => { + assert.equal(shim.routeBypass("portal.gov.br", TARGETS, []), "bypass"); + assert.equal(shim.routeBypass("tax.gov", TARGETS, []), "bypass"); +}); + +test("routeBypass — default okta pattern → bypass", () => { + assert.equal(shim.routeBypass("mycorp.okta.com", TARGETS, []), "bypass"); + assert.equal(shim.routeBypass("okta.com", TARGETS, []), "bypass"); +}); + +test("routeBypass — default auth0 pattern → bypass", () => { + assert.equal(shim.routeBypass("myapp.auth0.com", TARGETS, []), "bypass"); +}); + +test("routeBypass — bypass beats target match (precedence)", () => { + // Hypothetical: user has an okta-hosted Copilot account. Bypass wins. + const targets = new Set(["my.okta.com"]); + assert.equal(shim.routeBypass("my.okta.com", targets, []), "bypass"); +}); + +test("routeBypass — known target hostname → target", () => { + assert.equal( + shim.routeBypass("daily-cloudcode-pa.googleapis.com", TARGETS, []), + "target" + ); + assert.equal(shim.routeBypass("api.githubcopilot.com", TARGETS, []), "target"); +}); + +test("routeBypass — unknown hostname → passthrough", () => { + assert.equal(shim.routeBypass("example.com", TARGETS, []), "passthrough"); + assert.equal(shim.routeBypass("api.openai.com", TARGETS, []), "passthrough"); +}); + +test("routeBypass — user glob pattern → bypass", () => { + const userPatterns = ["*.internal.example.com"]; + assert.equal( + shim.routeBypass("admin.internal.example.com", TARGETS, userPatterns), + "bypass" + ); + assert.equal( + shim.routeBypass("external.example.com", TARGETS, userPatterns), + "passthrough" + ); +}); + +test("routeBypass — empty hostname → passthrough", () => { + assert.equal(shim.routeBypass("", TARGETS, []), "passthrough"); + assert.equal( + shim.routeBypass(undefined as unknown as string, TARGETS, []), + "passthrough" + ); +}); + +test("routeBypass — targetHosts may be an array (not just Set)", () => { + const targetsArr = [ + "daily-cloudcode-pa.googleapis.com", + "api.githubcopilot.com", + ]; + assert.equal( + shim.routeBypass("api.githubcopilot.com", targetsArr, []), + "target" + ); + assert.equal(shim.routeBypass("example.com", targetsArr, []), "passthrough"); +}); + +test("routeBypass — case-insensitive on hostname", () => { + assert.equal(shim.routeBypass("MyApp.Okta.COM", TARGETS, []), "bypass"); + assert.equal( + shim.routeBypass( + "DAILY-cloudcode-pa.googleapis.com".toLowerCase(), + TARGETS, + [] + ), + "target" + ); +}); + +test("bypassGlobMatch — exact match (no wildcard)", () => { + assert.ok(shim.bypassGlobMatch("api.openai.com", "api.openai.com")); + assert.ok(!shim.bypassGlobMatch("api.openai.com", "api.anthropic.com")); +}); + +test("bypassGlobMatch — single wildcard at start", () => { + assert.ok(shim.bypassGlobMatch("foo.example.com", "*.example.com")); + assert.ok(shim.bypassGlobMatch("bar.example.com", "*.example.com")); + assert.ok(!shim.bypassGlobMatch("example.org", "*.example.com")); +}); + +test("bypassGlobMatch — wildcard at end", () => { + assert.ok(shim.bypassGlobMatch("api.example.com", "api.*")); + assert.ok(!shim.bypassGlobMatch("svc.example.com", "api.*")); +}); + +test("bypassGlobMatch — too many wildcards → rejected", () => { + // 10 wildcards → segments.length === 11, exceeds the cap. + const pat = "a*b*c*d*e*f*g*h*i*j*k"; + assert.equal(shim.bypassGlobMatch("abcdefghijk", pat), false); +}); + +test("bypassGlobMatch — case-insensitive", () => { + assert.ok(shim.bypassGlobMatch("FOO.EXAMPLE.COM", "*.example.com")); +}); + +test("bypassGlobMatch — does not throw on regex-special chars in pattern", () => { + // No regex compilation happens — the helper is a linear string walk. + assert.doesNotThrow(() => shim.bypassGlobMatch("test.com", "(invalid[")); +}); + +test("parseBypassJson — valid JSON with patterns array", () => { + const raw = JSON.stringify({ + version: 1, + patterns: ["*.internal.example.com", "Custom.Host.COM"], + }); + const parsed = shim.parseBypassJson(raw); + assert.deepEqual(parsed, ["*.internal.example.com", "custom.host.com"]); +}); + +test("parseBypassJson — empty input → []", () => { + assert.deepEqual(shim.parseBypassJson(""), []); +}); + +test("parseBypassJson — malformed JSON → []", () => { + assert.deepEqual(shim.parseBypassJson("not json"), []); +}); + +test("parseBypassJson — missing patterns property → []", () => { + assert.deepEqual(shim.parseBypassJson(JSON.stringify({ version: 1 })), []); +}); + +test("parseBypassJson — patterns not an array → []", () => { + assert.deepEqual( + shim.parseBypassJson(JSON.stringify({ patterns: "foo" })), + [] + ); +}); + +test("parseBypassJson — filters out non-string and empty entries", () => { + const raw = JSON.stringify({ + patterns: ["valid.com", "", null, 42, "Another.COM"], + }); + const parsed = shim.parseBypassJson(raw); + assert.deepEqual(parsed, ["valid.com", "another.com"]); +}); + +test("C2 header contract — server.cjs intercept must inject x-omniroute-source and x-omniroute-agent", async () => { + // This is a documentation/spec assertion: the exact header names that + // server.cjs::intercept must inject per master plan §3.5. If anyone + // edits server.cjs to remove or rename them, this test fails and + // flags the regression. + const fs = await import("node:fs"); + const path = await import("node:path"); + const url = await import("node:url"); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const serverPath = path.resolve(here, "../../src/mitm/server.cjs"); + const src = fs.readFileSync(serverPath, "utf-8"); + assert.match( + src, + /"x-omniroute-source":\s*"agent-bridge"/, + 'server.cjs must inject "x-omniroute-source: agent-bridge"' + ); + assert.match( + src, + /"x-omniroute-agent":\s*agentId/, + 'server.cjs must inject "x-omniroute-agent: " from the host→agent map' + ); + // Antigravity non-regression: the historical host must still resolve to + // the antigravity agent id, so the existing flow continues to work. + assert.match( + src, + /TARGET_HOST_AGENT\.set\(lower,\s*id\)/, + "server.cjs must populate TARGET_HOST_AGENT from targets.json" + ); + assert.match( + src, + /TARGET_HOST_AGENT\.set\(h,\s*"antigravity"\)/, + "server.cjs must seed antigravity baseline in TARGET_HOST_AGENT" + ); +}); + +test("Hard Rule #12 — server.cjs intercept error path uses sanitizeErrorMessage", async () => { + // Spec assertion: error responses must NOT leak raw err.message. + const fs = await import("node:fs"); + const path = await import("node:path"); + const url = await import("node:url"); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const serverPath = path.resolve(here, "../../src/mitm/server.cjs"); + const src = fs.readFileSync(serverPath, "utf-8"); + // sanitizeErrorMessage must wrap the error body. + assert.match( + src, + /sanitizeErrorMessage\(error\s*&&\s*error\.message\)/, + "intercept() error path must route through sanitizeErrorMessage()" + ); + // The historical raw-leak pattern must be gone from the error body literal. + const errorBodyRegion = src.match( + /res\.end\(\s*JSON\.stringify\(\{\s*error[\s\S]*?type:\s*"mitm_error"[\s\S]*?\}\)\s*\)/ + ); + assert.ok(errorBodyRegion, "intercept() must build a JSON error body"); + assert.doesNotMatch( + errorBodyRegion[0], + /message:\s*error\.message[^a-zA-Z_]/, + "intercept() error body must not contain raw error.message" + ); +}); + +test("C1 contract — server.cjs registers a CONNECT handler", async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + const url = await import("node:url"); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const serverPath = path.resolve(here, "../../src/mitm/server.cjs"); + const src = fs.readFileSync(serverPath, "utf-8"); + assert.match( + src, + /server\.on\(\s*"connect"/, + "server.cjs must register a CONNECT handler" + ); + assert.match( + src, + /net\.connect\(/, + "server.cjs must dial upstream via net.connect for bypass/passthrough" + ); + assert.match( + src, + /HTTP\/1\.1\s+200\s+Connection Established/, + "server.cjs CONNECT path must reply with 200 Connection Established" + ); +});