diff --git a/tests/unit/inspector-context-key.test.ts b/tests/unit/inspector-context-key.test.ts new file mode 100644 index 0000000000..304e87fc50 --- /dev/null +++ b/tests/unit/inspector-context-key.test.ts @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractSystemPrompt, computeContextKey } from "../../src/mitm/inspector/contextKey.ts"; +import type { InterceptedRequest } from "../../src/mitm/inspector/types.ts"; + +function makeReq(body: unknown): InterceptedRequest { + return { + id: "00000000-0000-0000-0000-000000000001", + source: "agent-bridge", + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: body !== null ? JSON.stringify(body) : null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200, + }; +} + +test("extractSystemPrompt — OpenAI chat messages[0] role=system", () => { + const req = makeReq({ + messages: [{ role: "system", content: "You are a helpful assistant." }, { role: "user", content: "Hello" }], + }); + assert.equal(extractSystemPrompt(req), "You are a helpful assistant."); +}); + +test("extractSystemPrompt — Anthropic top-level system field (string)", () => { + const req = makeReq({ system: "You are Claude.", messages: [{ role: "user", content: "Hello" }] }); + assert.equal(extractSystemPrompt(req), "You are Claude."); +}); + +test("extractSystemPrompt — Anthropic top-level system field (array)", () => { + const req = makeReq({ + system: [{ type: "text", text: "You are a helpful assistant." }], + messages: [{ role: "user", content: "Hello" }], + }); + assert.equal(extractSystemPrompt(req), "You are a helpful assistant."); +}); + +test("extractSystemPrompt — Gemini systemInstruction.parts", () => { + const req = makeReq({ + systemInstruction: { parts: [{ text: "You are a Gemini assistant." }] }, + contents: [{ parts: [{ text: "Hello" }] }], + }); + assert.equal(extractSystemPrompt(req), "You are a Gemini assistant."); +}); + +test("extractSystemPrompt — null when no system prompt", () => { + assert.equal(extractSystemPrompt(makeReq({ messages: [{ role: "user", content: "Hi" }] })), null); +}); + +test("extractSystemPrompt — null when requestBody is null", () => { + assert.equal(extractSystemPrompt(makeReq(null)), null); +}); + +test("extractSystemPrompt — null when requestBody is invalid JSON", () => { + const req = makeReq(null); + req.requestBody = "not-json{{{"; + assert.equal(extractSystemPrompt(req), null); +}); + +test("computeContextKey — same system → same 12-hex key", () => { + const sys = "You are a helpful assistant."; + const key1 = computeContextKey(makeReq({ messages: [{ role: "system", content: sys }, { role: "user", content: "Hi" }] })); + const key2 = computeContextKey(makeReq({ messages: [{ role: "system", content: sys }, { role: "user", content: "Bye" }] })); + assert.ok(key1 !== null); + assert.equal(key1, key2); +}); + +test("computeContextKey — returns 12 hex chars", () => { + const key = computeContextKey(makeReq({ messages: [{ role: "system", content: "Test system" }, { role: "user", content: "Hi" }] })); + assert.ok(key !== null); + assert.equal(key!.length, 12); + assert.match(key!, /^[0-9a-f]{12}$/); +}); + +test("computeContextKey — null when no system", () => { + assert.equal(computeContextKey(makeReq({ messages: [{ role: "user", content: "Hi" }] })), null); +}); + +test("computeContextKey — different systems → different keys", () => { + const k1 = computeContextKey(makeReq({ messages: [{ role: "system", content: "System A" }, { role: "user", content: "Hi" }] })); + const k2 = computeContextKey(makeReq({ messages: [{ role: "system", content: "System B" }, { role: "user", content: "Hi" }] })); + assert.notEqual(k1, k2); +}); diff --git a/tests/unit/inspector-kind-detector.test.ts b/tests/unit/inspector-kind-detector.test.ts new file mode 100644 index 0000000000..c7b0fa3344 --- /dev/null +++ b/tests/unit/inspector-kind-detector.test.ts @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectKind } from "../../src/mitm/inspector/kindDetector.ts"; +import type { InterceptedRequest } from "../../src/mitm/inspector/types.ts"; + +function makeReq(overrides: Partial): InterceptedRequest { + return { + id: "00000000-0000-0000-0000-000000000001", + source: "agent-bridge", + timestamp: new Date().toISOString(), + method: "POST", + host: "random.example.com", + path: "/api/data", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200, + ...overrides, + }; +} + +test("detectKind — api.openai.com → llm", () => { + assert.equal(detectKind(makeReq({ host: "api.openai.com" })), "llm"); +}); +test("detectKind — api.anthropic.com → llm", () => { + assert.equal(detectKind(makeReq({ host: "api.anthropic.com" })), "llm"); +}); +test("detectKind — generativelanguage.googleapis.com → llm", () => { + assert.equal(detectKind(makeReq({ host: "generativelanguage.googleapis.com" })), "llm"); +}); +test("detectKind — openrouter.ai → llm", () => { + assert.equal(detectKind(makeReq({ host: "openrouter.ai" })), "llm"); +}); +test("detectKind — azure openai subdomain → llm", () => { + assert.equal(detectKind(makeReq({ host: "mycompany.openai.azure.com" })), "llm"); +}); +test("detectKind — api.mistral.ai → llm", () => { + assert.equal(detectKind(makeReq({ host: "api.mistral.ai" })), "llm"); +}); +test("detectKind — api.groq.com → llm", () => { + assert.equal(detectKind(makeReq({ host: "api.groq.com" })), "llm"); +}); + +test("detectKind — body with messages array → llm", () => { + const req = makeReq({ + requestBody: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }), + }); + assert.equal(detectKind(req), "llm"); +}); + +test("detectKind — body with contents array (Gemini) → llm", () => { + const req = makeReq({ + requestBody: JSON.stringify({ contents: [{ parts: [{ text: "Hello" }] }] }), + }); + assert.equal(detectKind(req), "llm"); +}); + +test("detectKind — UA 'antigravity/1.0' → llm", () => { + assert.equal( + detectKind(makeReq({ requestHeaders: { "user-agent": "antigravity/1.0" } })), + "llm", + ); +}); + +test("detectKind — random.example.com with no clues → app", () => { + assert.equal(detectKind(makeReq({ host: "random.example.com" })), "app"); +}); + +test("detectKind — path /v1/chat/completions → llm", () => { + assert.equal(detectKind(makeReq({ path: "/v1/chat/completions" })), "llm"); +}); +test("detectKind — path /v1/messages → llm", () => { + assert.equal(detectKind(makeReq({ path: "/v1/messages" })), "llm"); +}); +test("detectKind — path /generateContent → llm", () => { + assert.equal(detectKind(makeReq({ path: "/v1beta/models/gemini-pro:generateContent" })), "llm"); +}); diff --git a/tests/unit/inspector-types.test.ts b/tests/unit/inspector-types.test.ts new file mode 100644 index 0000000000..3d12b622bc --- /dev/null +++ b/tests/unit/inspector-types.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { InterceptedRequestSchema } from "../../src/mitm/inspector/types.ts"; +import { MitmTargetSchema } from "../../src/mitm/types.ts"; + +const validInterceptedRequest = { + id: "550e8400-e29b-41d4-a716-446655440000", + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: { "content-type": "application/json" }, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200, +}; + +test("InterceptedRequestSchema — accepts valid payload", () => { + assert.ok(InterceptedRequestSchema.safeParse(validInterceptedRequest).success); +}); + +test("InterceptedRequestSchema — accepts in-flight status", () => { + assert.ok(InterceptedRequestSchema.safeParse({ ...validInterceptedRequest, status: "in-flight" }).success); +}); + +test("InterceptedRequestSchema — accepts error status", () => { + assert.ok(InterceptedRequestSchema.safeParse({ ...validInterceptedRequest, status: "error", error: "Connection timeout" }).success); +}); + +test("InterceptedRequestSchema — rejects malformed uuid", () => { + assert.ok(!InterceptedRequestSchema.safeParse({ ...validInterceptedRequest, id: "not-a-uuid" }).success); +}); + +test("InterceptedRequestSchema — rejects invalid source enum", () => { + assert.ok(!InterceptedRequestSchema.safeParse({ ...validInterceptedRequest, source: "invalid-source" }).success); +}); + +test("InterceptedRequestSchema — rejects negative requestSize", () => { + assert.ok(!InterceptedRequestSchema.safeParse({ ...validInterceptedRequest, requestSize: -1 }).success); +}); + +const validMitmTarget = { + id: "copilot", + name: "GitHub Copilot", + icon: "code", + color: "#10B981", + hosts: ["api.githubcopilot.com"], + port: 443, + endpointPatterns: ["/v1/chat/completions"], + defaultModels: [{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }], + setupTutorial: { + steps: ["Step 1", "Step 2"], + detection: { command: "code --version", platform: "all" as const }, + }, + riskNoticeKey: "providers.riskNotice.oauth", +}; + +test("MitmTargetSchema — accepts valid target", () => { + assert.ok(MitmTargetSchema.safeParse(validMitmTarget).success); +}); + +test("MitmTargetSchema — rejects invalid color format", () => { + assert.ok(!MitmTargetSchema.safeParse({ ...validMitmTarget, color: "green" }).success); +}); + +test("MitmTargetSchema — rejects empty hosts array", () => { + assert.ok(!MitmTargetSchema.safeParse({ ...validMitmTarget, hosts: [] }).success); +}); + +test("MitmTargetSchema — rejects invalid agent id", () => { + assert.ok(!MitmTargetSchema.safeParse({ ...validMitmTarget, id: "unknown-agent" }).success); +}); + +test("MitmTargetSchema — accepts all 9 valid agent ids", () => { + const ids = ["antigravity", "kiro", "copilot", "codex", "cursor", "zed", "claude-code", "open-code", "trae"]; + for (const id of ids) { + assert.ok(MitmTargetSchema.safeParse({ ...validMitmTarget, id }).success, `Should accept: ${id}`); + } +}); diff --git a/tests/unit/mitm-masksecrets.test.ts b/tests/unit/mitm-masksecrets.test.ts new file mode 100644 index 0000000000..4a5c57ac36 --- /dev/null +++ b/tests/unit/mitm-masksecrets.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { maskSecret } from "../../src/mitm/maskSecrets.ts"; + +test("maskSecret — Bearer token is masked", () => { + const input = "authorization: Bearer sk-proj-abcdefghijklmnop"; + const result = maskSecret(input); + assert.ok(result.includes("Bearer ***"), `Expected Bearer ***, got: ${result}`); + assert.ok(!result.includes("sk-proj-abcdefghijklmnop"), "Should not contain original token"); +}); + +test("maskSecret — sk- key is masked with prefix and suffix", () => { + const input = "sk-abcdefghijklmnopqrstuvwxyz123456"; + const result = maskSecret(input); + assert.ok(result.startsWith("sk-abc"), `Expected prefix sk-abc, got: ${result}`); + assert.ok(result.endsWith("…56"), `Expected suffix …56, got: ${result}`); + assert.ok(!result.includes("ghijklmnopqrstuvwxyz1234"), "Middle chars should be redacted"); +}); + +test("maskSecret — ak- key is masked", () => { + const input = "ak-1234567890abcdefghijklmnop"; + const result = maskSecret(input); + assert.ok(result.startsWith("ak-123")); + assert.ok(result.endsWith("…op")); +}); + +test("maskSecret — pk- key is masked", () => { + const input = "pk-supersecretkeywithmorethan16chars"; + const result = maskSecret(input); + assert.ok(result.startsWith("pk-sup")); + assert.ok(result.endsWith("…rs")); +}); + +test("maskSecret — long opaque token (≥40 chars) is masked", () => { + const longToken = "A".repeat(40); + const result = maskSecret(longToken); + assert.ok(result.startsWith("AAAA")); + assert.ok(result.endsWith("…AA")); + assert.ok(result.length < longToken.length); +}); + +test("maskSecret — string without secrets is unchanged", () => { + const safe = "Content-Type: application/json"; + assert.equal(maskSecret(safe), safe); +}); + +test("maskSecret — multiple secrets in same string", () => { + const input = "sk-abcdefghijklmnopqrstuvwxyz12345678 and pk-qwertyuiopasdfghjklzxcvbnm12345"; + const result = maskSecret(input); + assert.ok(!result.includes("abcdefghijklmno")); + assert.ok(!result.includes("qwertyuiopasdfg")); +}); + +test("maskSecret — secrets embedded in quoted strings", () => { + const input = `"api_key": "sk-abcdefghijklmnopqrstuvwxyz12345678"`; + const result = maskSecret(input); + assert.ok(!result.includes("abcdefghijklmno")); +}); + +test("maskSecret — short sk- key below 16 chars is NOT masked", () => { + const shortKey = "sk-shortkey"; + assert.equal(maskSecret(shortKey), shortKey); +}); diff --git a/tests/unit/mitm-passthrough.test.ts b/tests/unit/mitm-passthrough.test.ts new file mode 100644 index 0000000000..97e2725250 --- /dev/null +++ b/tests/unit/mitm-passthrough.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { shouldBypass, globMatch, DEFAULT_BYPASS_PATTERNS } from "../../src/mitm/passthrough.ts"; + +test("shouldBypass — bank subdomain matches default pattern", () => { + assert.ok(shouldBypass("my.bank.com", [])); + assert.ok(shouldBypass("secure.bank.example", [])); +}); + +test("shouldBypass — .gov domain matches default pattern", () => { + assert.ok(shouldBypass("portal.gov.br", [])); + assert.ok(shouldBypass("tax.gov", [])); +}); + +test("shouldBypass — okta.com matches default SSO pattern", () => { + assert.ok(shouldBypass("mycompany.okta.com", [])); + assert.ok(shouldBypass("okta.com", [])); +}); + +test("shouldBypass — auth0.com matches default SSO pattern", () => { + assert.ok(shouldBypass("myapp.auth0.com", [])); + assert.ok(shouldBypass("auth0.com", [])); +}); + +test("shouldBypass — non-sensitive host does NOT match defaults", () => { + assert.ok(!shouldBypass("api.openai.com", [])); + assert.ok(!shouldBypass("api.anthropic.com", [])); + assert.ok(!shouldBypass("example.com", [])); +}); + +test("shouldBypass — user custom glob pattern matches", () => { + assert.ok(shouldBypass("internal.mycompany.com", ["*.mycompany.com"])); + assert.ok(!shouldBypass("external.othercompany.com", ["*.mycompany.com"])); +}); + +test("globMatch — star wildcard matches any subdomain", () => { + assert.ok(globMatch("foo.example.com", "*.example.com")); + assert.ok(globMatch("bar.example.com", "*.example.com")); +}); + +test("globMatch — exact match without wildcard", () => { + assert.ok(globMatch("api.openai.com", "api.openai.com")); + assert.ok(!globMatch("api.openai.com", "api.anthropic.com")); +}); + +test("globMatch — invalid regex-like pattern does not throw", () => { + assert.doesNotThrow(() => globMatch("test.com", "[invalid(")); +}); + +test("DEFAULT_BYPASS_PATTERNS — exported array is not empty", () => { + assert.ok(DEFAULT_BYPASS_PATTERNS.length >= 4); + assert.ok(DEFAULT_BYPASS_PATTERNS.every((p) => p instanceof RegExp)); +}); diff --git a/tests/unit/mitm-upstream-trust.test.ts b/tests/unit/mitm-upstream-trust.test.ts new file mode 100644 index 0000000000..1bb5f4934d --- /dev/null +++ b/tests/unit/mitm-upstream-trust.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { configureUpstreamCa } from "../../src/mitm/upstreamTrust.ts"; + +test("configureUpstreamCa — no-op when pemPath is undefined", () => { + assert.doesNotThrow(() => configureUpstreamCa(undefined)); +}); + +test("configureUpstreamCa — no-op when pemPath is empty string", () => { + assert.doesNotThrow(() => configureUpstreamCa("")); +}); + +test("configureUpstreamCa — throws structured error for non-existent path", () => { + const fakePath = "/nonexistent/path/that/does/not/exist/ca.pem"; + try { + configureUpstreamCa(fakePath); + assert.fail("Should have thrown"); + } catch (err) { + assert.ok(err instanceof Error); + assert.ok(!err.message.includes(" at /"), `Error message should not contain stack trace: ${err.message}`); + assert.ok(err.message.includes(fakePath)); + } +}); + +test("configureUpstreamCa — error message contains AGENTBRIDGE_UPSTREAM_CA_CERT label", () => { + const fakePath = "/no/such/file.pem"; + try { + configureUpstreamCa(fakePath); + assert.fail("Should have thrown"); + } catch (err) { + assert.ok(err instanceof Error); + assert.ok(err.message.includes("AGENTBRIDGE_UPSTREAM_CA_CERT")); + } +}); + +test("configureUpstreamCa — error does not embed multiline stack trace in message", () => { + try { + configureUpstreamCa("/definitely/does/not/exist.pem"); + } catch (err) { + assert.ok(err instanceof Error); + assert.ok(!err.message.includes("\n at ")); + } +}); diff --git a/tests/unit/shared-schemas.test.ts b/tests/unit/shared-schemas.test.ts new file mode 100644 index 0000000000..69d68b712f --- /dev/null +++ b/tests/unit/shared-schemas.test.ts @@ -0,0 +1,137 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + AgentBridgeStateRowSchema, + AgentBridgeMappingRowSchema, + AgentBridgeBypassRowSchema, + AgentBridgeServerActionSchema, + AgentBridgeDnsActionSchema, + AgentBridgeMappingPutSchema, + AgentBridgeBypassUpsertSchema, + AgentBridgeUpstreamCaPostSchema, +} from "../../src/shared/schemas/agentBridge.ts"; +import { + InspectorCustomHostSchema, + InspectorSessionStartSchema, + InspectorSessionPatchSchema, + InspectorCaptureModeActionSchema, + InspectorSystemProxyActionSchema, + InspectorTlsInterceptToggleSchema, + InspectorAnnotationPutSchema, + InspectorListQuerySchema, +} from "../../src/shared/schemas/inspector.ts"; + +test("AgentBridgeStateRowSchema — round-trip", () => { + const data = { + agent_id: "copilot", + dns_enabled: true, + cert_trusted: false, + setup_completed: false, + last_started_at: null, + last_error: null, + }; + const r = AgentBridgeStateRowSchema.safeParse(data); + assert.ok(r.success); +}); + +test("AgentBridgeMappingRowSchema — round-trip", () => { + assert.ok(AgentBridgeMappingRowSchema.safeParse({ + agent_id: "copilot", source_model: "gpt-4o", target_model: "claude-sonnet-4-5", updated_at: new Date().toISOString(), + }).success); +}); + +test("AgentBridgeBypassRowSchema — round-trip", () => { + assert.ok(AgentBridgeBypassRowSchema.safeParse({ + pattern: "*.bank.com", source: "user", created_at: new Date().toISOString(), + }).success); +}); + +test("AgentBridgeBypassRowSchema — rejects invalid source enum", () => { + assert.ok(!AgentBridgeBypassRowSchema.safeParse({ + pattern: "x", source: "custom", created_at: new Date().toISOString(), + }).success); +}); + +test("AgentBridgeServerActionSchema — all valid actions", () => { + for (const action of ["start", "stop", "restart", "trust-cert", "regenerate-cert"]) { + assert.ok(AgentBridgeServerActionSchema.safeParse({ action }).success, `accepted ${action}`); + } +}); + +test("AgentBridgeServerActionSchema — rejects unknown action", () => { + assert.ok(!AgentBridgeServerActionSchema.safeParse({ action: "delete" }).success); +}); + +test("AgentBridgeDnsActionSchema — round-trip", () => { + assert.ok(AgentBridgeDnsActionSchema.safeParse({ enabled: true }).success); +}); + +test("AgentBridgeMappingPutSchema — round-trip", () => { + assert.ok(AgentBridgeMappingPutSchema.safeParse({ mappings: [{ source: "a", target: "b" }] }).success); +}); + +test("AgentBridgeBypassUpsertSchema — round-trip", () => { + assert.ok(AgentBridgeBypassUpsertSchema.safeParse({ patterns: ["*.bank.com"] }).success); +}); + +test("AgentBridgeUpstreamCaPostSchema — rejects empty path", () => { + assert.ok(!AgentBridgeUpstreamCaPostSchema.safeParse({ path: "" }).success); +}); + +test("InspectorCustomHostSchema — default enabled=true", () => { + const r = InspectorCustomHostSchema.safeParse({ host: "example.com" }); + assert.ok(r.success); + assert.equal(r.data?.enabled, true); +}); + +test("InspectorCustomHostSchema — rejects empty host", () => { + assert.ok(!InspectorCustomHostSchema.safeParse({ host: "" }).success); +}); + +test("InspectorSessionStartSchema — round-trip with name", () => { + assert.ok(InspectorSessionStartSchema.safeParse({ name: "My Session" }).success); +}); + +test("InspectorSessionStartSchema — round-trip without name", () => { + assert.ok(InspectorSessionStartSchema.safeParse({}).success); +}); + +test("InspectorSessionPatchSchema — stop action", () => { + assert.ok(InspectorSessionPatchSchema.safeParse({ action: "stop" }).success); +}); + +test("InspectorCaptureModeActionSchema — start/stop", () => { + assert.ok(InspectorCaptureModeActionSchema.safeParse({ action: "start" }).success); + assert.ok(InspectorCaptureModeActionSchema.safeParse({ action: "stop" }).success); +}); + +test("InspectorSystemProxyActionSchema — apply with options", () => { + assert.ok(InspectorSystemProxyActionSchema.safeParse({ action: "apply", port: 8080, guardMinutes: 30 }).success); +}); + +test("InspectorSystemProxyActionSchema — rejects invalid port", () => { + assert.ok(!InspectorSystemProxyActionSchema.safeParse({ action: "apply", port: 99999 }).success); +}); + +test("InspectorTlsInterceptToggleSchema — round-trip", () => { + assert.ok(InspectorTlsInterceptToggleSchema.safeParse({ enabled: false }).success); +}); + +test("InspectorAnnotationPutSchema — rejects over 10000 chars", () => { + assert.ok(!InspectorAnnotationPutSchema.safeParse({ annotation: "x".repeat(10001) }).success); +}); + +test("InspectorListQuerySchema — round-trip with all filters", () => { + assert.ok(InspectorListQuerySchema.safeParse({ + profile: "llm", host: "api.openai.com", agent: "copilot", status: "2xx", + source: "agent-bridge", sessionId: "550e8400-e29b-41d4-a716-446655440000", + }).success); +}); + +test("InspectorListQuerySchema — rejects non-uuid sessionId", () => { + assert.ok(!InspectorListQuerySchema.safeParse({ sessionId: "not-a-uuid" }).success); +}); + +test("InspectorListQuerySchema — empty object is valid", () => { + assert.ok(InspectorListQuerySchema.safeParse({}).success); +});