diff --git a/tests/integration/traffic-inspector-capture-modes.test.ts b/tests/integration/traffic-inspector-capture-modes.test.ts new file mode 100644 index 0000000000..bf9b46c7a2 --- /dev/null +++ b/tests/integration/traffic-inspector-capture-modes.test.ts @@ -0,0 +1,258 @@ +/** + * Integration tests: Traffic Inspector capture-modes endpoints + * + * Tests: + * - GET /capture-modes — status overview + * - POST /capture-modes/http-proxy — start/stop (ephemeral port to avoid 8080 conflict) + * - POST /capture-modes/system-proxy — apply/revert (mocked OS commands) + * - POST /capture-modes/tls-intercept — toggle + */ + +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"; +import net from "node:net"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-capture-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.INSPECTOR_HTTP_PROXY_PORT = "0"; // ephemeral port + +const captureModesRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/route.ts" +); +const httpProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts" +); +const systemProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts" +); +const tlsInterceptRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts" +); +const { setHttpProxyHandle, getHttpProxyHandle, clearSystemProxy } = await import( + "../../src/lib/inspector/captureState.ts" +); +const { __setExec } = await import( + "../../src/mitm/inspector/systemProxyConfig.ts" +); + +test.beforeEach(() => { + // Ensure no running proxy handle leaks between tests + const handle = getHttpProxyHandle(); + if (handle) { + handle.stop().catch(() => {/* ignore */}); + setHttpProxyHandle(null); + } + clearSystemProxy(); +}); + +test.after(() => { + // Clean up any running proxy + const handle = getHttpProxyHandle(); + if (handle) { + handle.stop().catch(() => {/* ignore */}); + setHttpProxyHandle(null); + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── GET /capture-modes ────────────────────────────────────────────────────── + +test("GET /capture-modes: returns status of all modes", async () => { + const res = await captureModesRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as { + agentBridge: boolean; + httpProxy: { running: boolean; port: number | null }; + systemProxy: { applied: boolean }; + tlsIntercept: { enabled: boolean }; + }; + assert.equal(body.agentBridge, true); + assert.equal(body.httpProxy.running, false); + assert.equal(body.systemProxy.applied, false); + assert.ok("enabled" in body.tlsIntercept); +}); + +// ── POST /capture-modes/http-proxy ───────────────────────────────────────── + +test("http-proxy: start binds an ephemeral port", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "start" }), + } + ); + const res = await httpProxyRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { ok: boolean; running: boolean; port: number }; + assert.equal(body.ok, true); + assert.equal(body.running, true); + assert.ok(body.port > 0, "should have a bound port"); + + // Clean up + const handle = getHttpProxyHandle(); + if (handle) { + await handle.stop(); + setHttpProxyHandle(null); + } +}); + +test("http-proxy: stop when not running returns ok", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + } + ); + const res = await httpProxyRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; running: boolean }; + assert.equal(body.ok, true); + assert.equal(body.running, false); +}); + +test("http-proxy: start then stop lifecycle", async () => { + const startReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "start" }), + } + ); + const startRes = await httpProxyRoute.POST(startReq); + assert.equal(startRes.status, 201); + + const stopReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + } + ); + const stopRes = await httpProxyRoute.POST(stopReq); + assert.equal(stopRes.status, 200); + const body = await stopRes.json() as { running: boolean }; + assert.equal(body.running, false); +}); + +test("http-proxy: EADDRINUSE returns 409 with structured error", async () => { + // Import startHttpProxyServer directly so we can test the low-level error path + // without depending on the module-cached DEFAULT_PORT. + const { startHttpProxyServer } = await import( + "../../src/mitm/inspector/httpProxyServer.ts" + ); + + // Occupy a random port + const blocker = net.createServer(); + await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve)); + const blockedPort = (blocker.address() as net.AddressInfo).port; + + try { + // startHttpProxyServer should reject with code === EADDRINUSE + let caught: NodeJS.ErrnoException | null = null; + try { + await startHttpProxyServer(blockedPort); + } catch (err) { + caught = err as NodeJS.ErrnoException; + } + assert.ok(caught !== null, "should have thrown"); + assert.equal(caught?.code, "EADDRINUSE"); + } finally { + blocker.close(); + } +}); + +// ── POST /capture-modes/system-proxy ─────────────────────────────────────── + +test("system-proxy: apply with mocked OS commands", async () => { + const restore = __setExec(async (_file, _args) => ({ stdout: "Enabled: No\nServer: \nPort: 0", stderr: "" })); + try { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "apply", port: 8080, guardMinutes: 1 }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; applied: boolean }; + assert.equal(body.ok, true); + assert.equal(body.applied, true); + } finally { + restore(); + clearSystemProxy(); + } +}); + +test("system-proxy: revert without prior apply is a no-op", async () => { + const restore = __setExec(async (_file, _args) => ({ stdout: "", stderr: "" })); + try { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "revert" }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { applied: boolean }; + assert.equal(body.applied, false); + } finally { + restore(); + } +}); + +test("system-proxy: rejects invalid action", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "invalid" }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 400); +}); + +// ── POST /capture-modes/tls-intercept ────────────────────────────────────── + +test("tls-intercept: toggle on/off", async () => { + const enableReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/tls-intercept", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + } + ); + const enableRes = await tlsInterceptRoute.POST(enableReq); + assert.equal(enableRes.status, 200); + const enableBody = await enableRes.json() as { tlsIntercept: { enabled: boolean } }; + assert.equal(enableBody.tlsIntercept.enabled, true); + + const disableReq = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/tls-intercept", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + } + ); + const disableRes = await tlsInterceptRoute.POST(disableReq); + assert.equal(disableRes.status, 200); + const disableBody = await disableRes.json() as { tlsIntercept: { enabled: boolean } }; + assert.equal(disableBody.tlsIntercept.enabled, false); +}); diff --git a/tests/integration/traffic-inspector-error-sanitization.test.ts b/tests/integration/traffic-inspector-error-sanitization.test.ts new file mode 100644 index 0000000000..fd076bd3ca --- /dev/null +++ b/tests/integration/traffic-inspector-error-sanitization.test.ts @@ -0,0 +1,216 @@ +/** + * Integration tests: Traffic Inspector error sanitization + * + * Verifies that all error responses do NOT include stack traces or raw + * file paths (Hard Rule #12). + */ + +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"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-errsanitize-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { globalTrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); + +const requestsRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/route.ts" +); +const requestDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/route.ts" +); +const annotationRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts" +); +const hostsRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/route.ts" +); +const hostDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/[host]/route.ts" +); +const sessionsRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/route.ts" +); +const sessionDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/[id]/route.ts" +); +const ingestRoute = await import( + "../../src/app/api/tools/traffic-inspector/internal/ingest/route.ts" +); +const httpProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts" +); +const systemProxyRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts" +); +const tlsInterceptRoute = await import( + "../../src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts" +); + +function noStackTrace(msg: string, label: string): void { + assert.ok( + !msg.includes("at /"), + `${label}: error message must not contain stack trace (found "at /")` + ); + assert.ok( + !msg.includes(".ts:"), + `${label}: error message must not include TS file paths` + ); +} + +async function getErrorMessage(res: Response): Promise { + const body = await res.json() as { error: { message: string } }; + return body.error?.message ?? ""; +} + +test.beforeEach(() => { + globalTrafficBuffer.clear(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("requests: invalid profile param does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?profile=BAD" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "GET /requests"); +}); + +test("requests/[id]: unknown id does not leak stack", async () => { + const res = await requestDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: randomUUID() }) } + ); + assert.equal(res.status, 404); + noStackTrace(await getErrorMessage(res), "GET /requests/[id]"); +}); + +test("annotation: invalid body does not leak stack", async () => { + const entry = { + id: randomUUID(), + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + }; + globalTrafficBuffer.push(entry); + + const req = new Request("http://localhost/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotation: 12345 }), // wrong type + }); + const res = await annotationRoute.PUT(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "PUT annotation"); +}); + +test("hosts: invalid body does not leak stack", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "bad json!}", + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /hosts"); +}); + +test("hosts/[host] PATCH: invalid body does not leak stack", async () => { + const res = await hostDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: "bad json!", + }), + { params: Promise.resolve({ host: "foo.com" }) } + ); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "PATCH /hosts/[host]"); +}); + +test("sessions: 404 does not leak stack", async () => { + const res = await sessionDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: randomUUID() }) } + ); + assert.equal(res.status, 404); + noStackTrace(await getErrorMessage(res), "GET /sessions/[id]"); +}); + +test("ingest: 403 does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/internal/ingest", + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer wrong-token", + }, + body: JSON.stringify({}), + } + ); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); + noStackTrace(await getErrorMessage(res), "POST /internal/ingest (403)"); +}); + +test("http-proxy: invalid action does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/http-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "invalid" }), + } + ); + const res = await httpProxyRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /capture-modes/http-proxy"); +}); + +test("system-proxy: invalid body does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/system-proxy", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "bad-action" }), + } + ); + const res = await systemProxyRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /capture-modes/system-proxy"); +}); + +test("tls-intercept: missing enabled field does not leak stack", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/capture-modes/tls-intercept", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: "not-a-boolean" }), + } + ); + const res = await tlsInterceptRoute.POST(req); + assert.equal(res.status, 400); + noStackTrace(await getErrorMessage(res), "POST /capture-modes/tls-intercept"); +}); diff --git a/tests/integration/traffic-inspector-hosts.test.ts b/tests/integration/traffic-inspector-hosts.test.ts new file mode 100644 index 0000000000..234eb8be61 --- /dev/null +++ b/tests/integration/traffic-inspector-hosts.test.ts @@ -0,0 +1,142 @@ +/** + * Integration tests: Traffic Inspector custom hosts CRUD + * + * Tests GET /hosts, POST /hosts, DELETE /hosts/[host], PATCH /hosts/[host]. + * DB is isolated per test via a temp DATA_DIR. + */ + +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-ti-hosts-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Boot DB so migrations run +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); + +const hostsRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/route.ts" +); +const hostDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/hosts/[host]/route.ts" +); + +test.beforeEach(async () => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // Re-init DB with fresh migrations + await import("../../src/lib/db/core.ts").then((m) => m.getDbInstance()); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /hosts: returns empty list initially", async () => { + const res = await hostsRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as { hosts: unknown[] }; + assert.deepEqual(body.hosts, []); +}); + +test("POST /hosts: adds a host", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "api.openai.com", kind: "llm" }), + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { ok: boolean; host: string }; + assert.equal(body.ok, true); + assert.equal(body.host, "api.openai.com"); + + // Verify it appears in list + const listRes = await hostsRoute.GET(); + const list = await listRes.json() as { hosts: Array<{ host: string }> }; + assert.ok(list.hosts.some((h) => h.host === "api.openai.com")); +}); + +test("POST /hosts: rejects empty host string", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "" }), + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 400); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("POST /hosts: rejects invalid JSON", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await hostsRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("DELETE /hosts/[host]: removes existing host", async () => { + // Add host first + const addReq = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "remove-me.example.com", kind: "custom" }), + }); + await hostsRoute.POST(addReq); + + // Now delete it + const delRes = await hostDetailRoute.DELETE( + new Request("http://localhost/"), + { params: Promise.resolve({ host: "remove-me.example.com" }) } + ); + assert.equal(delRes.status, 204); + + // Verify gone + const listRes = await hostsRoute.GET(); + const list = await listRes.json() as { hosts: Array<{ host: string }> }; + assert.ok(!list.hosts.some((h) => h.host === "remove-me.example.com")); +}); + +test("PATCH /hosts/[host]: toggles enabled flag", async () => { + // Add host + const addReq = new Request("http://localhost/api/tools/traffic-inspector/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host: "toggle-me.example.com", kind: "app", enabled: true }), + }); + await hostsRoute.POST(addReq); + + // Disable it + const patchRes = await hostDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }), + { params: Promise.resolve({ host: "toggle-me.example.com" }) } + ); + assert.equal(patchRes.status, 200); + const body = await patchRes.json() as { enabled: boolean }; + assert.equal(body.enabled, false); +}); + +test("PATCH /hosts/[host]: returns 404 for non-existent host", async () => { + const res = await hostDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + { params: Promise.resolve({ host: "nonexistent.example.com" }) } + ); + assert.equal(res.status, 404); +}); diff --git a/tests/integration/traffic-inspector-internal-ingest.test.ts b/tests/integration/traffic-inspector-internal-ingest.test.ts new file mode 100644 index 0000000000..3cf2ef1048 --- /dev/null +++ b/tests/integration/traffic-inspector-internal-ingest.test.ts @@ -0,0 +1,150 @@ +/** + * Integration tests: Traffic Inspector internal ingest endpoint + * + * Tests: + * - POST without token → 403 + * - POST with wrong token → 403 + * - POST with valid token + valid body → 200 + buffer push + * - POST with valid token + invalid body → 400 (no stack trace) + */ + +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"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-ingest-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Set a known token BEFORE importing the route so the module picks it up +const VALID_TOKEN = "test-ingest-token-abc123xyz789-longer-than-16"; +process.env.INSPECTOR_INTERNAL_INGEST_TOKEN = VALID_TOKEN; + +const { globalTrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); +const ingestRoute = await import( + "../../src/app/api/tools/traffic-inspector/internal/ingest/route.ts" +); + +function makeIngestRequest(token: string | null, body: unknown): Request { + const headers: Record = { + "content-type": "application/json", + }; + if (token !== null) { + headers["authorization"] = `Bearer ${token}`; + } + return new Request( + "http://localhost/api/tools/traffic-inspector/internal/ingest", + { + method: "POST", + headers, + body: JSON.stringify(body), + } + ); +} + +function minimalEntry(overrides: Record = {}): Record { + return { + id: randomUUID(), + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + source: "agent-bridge", + requestHeaders: {}, + requestSize: 0, + responseHeaders: {}, + responseSize: 0, + status: 200, + ...overrides, + }; +} + +test.beforeEach(() => { + globalTrafficBuffer.clear(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("ingest: POST without Authorization header → 403", async () => { + const req = makeIngestRequest(null, minimalEntry()); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("ingest: POST with wrong token → 403", async () => { + const req = makeIngestRequest("wrong-token", minimalEntry()); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); +}); + +test("ingest: POST with empty string token → 403", async () => { + const req = makeIngestRequest("", minimalEntry()); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 403); +}); + +test("ingest: POST with valid token + valid body → 200 + buffer push", async () => { + const id = randomUUID(); + const req = makeIngestRequest(VALID_TOKEN, minimalEntry({ id })); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; id: string }; + assert.equal(body.ok, true); + assert.equal(body.id, id); + + // Verify the entry was added to the buffer + const entry = globalTrafficBuffer.get(id); + assert.ok(entry, "entry should be in the buffer"); + assert.equal(entry?.host, "api.openai.com"); +}); + +test("ingest: valid token + missing required field → 400", async () => { + const req = makeIngestRequest(VALID_TOKEN, { + // missing 'host', 'path', 'source', etc. + id: randomUUID(), + timestamp: new Date().toISOString(), + method: "GET", + }); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 400); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("ingest: valid token + invalid JSON → 400", async () => { + const headers: Record = { + "content-type": "application/json", + "authorization": `Bearer ${VALID_TOKEN}`, + }; + const req = new Request( + "http://localhost/api/tools/traffic-inspector/internal/ingest", + { + method: "POST", + headers, + body: "not valid json", + } + ); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("ingest: getIngestTokenForBootstrap returns a non-empty token", () => { + const token = ingestRoute.getIngestTokenForBootstrap(); + assert.ok(typeof token === "string" && token.length >= 16, "token should be ≥16 chars"); +}); + +test("ingest: multiple pushes accumulate in buffer", async () => { + const ids = [randomUUID(), randomUUID(), randomUUID()]; + for (const id of ids) { + const req = makeIngestRequest(VALID_TOKEN, minimalEntry({ id })); + const res = await ingestRoute.POST(req); + assert.equal(res.status, 200); + } + assert.equal(globalTrafficBuffer.size(), 3); +}); diff --git a/tests/integration/traffic-inspector-localonly.test.ts b/tests/integration/traffic-inspector-localonly.test.ts new file mode 100644 index 0000000000..0fc352b51d --- /dev/null +++ b/tests/integration/traffic-inspector-localonly.test.ts @@ -0,0 +1,114 @@ +/** + * Integration tests: Traffic Inspector LOCAL_ONLY enforcement + * + * Verifies that `isLocalOnlyPath` returns true for all traffic-inspector prefixes + * and that a simulated non-loopback request to the management policy returns 403. + */ + +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-ti-local-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { isLocalOnlyPath, isLoopbackHost } = await import( + "../../src/server/authz/routeGuard.ts" +); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── isLocalOnlyPath assertions ────────────────────────────────────────────── + +test("isLocalOnlyPath: traffic-inspector prefix is LOCAL_ONLY", () => { + assert.equal( + isLocalOnlyPath("/api/tools/traffic-inspector/"), + true, + "root prefix should be LOCAL_ONLY" + ); +}); + +test("isLocalOnlyPath: ws sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/ws"), true); +}); + +test("isLocalOnlyPath: requests sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/requests"), true); +}); + +test("isLocalOnlyPath: capture-modes sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/capture-modes/http-proxy"), true); +}); + +test("isLocalOnlyPath: sessions sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true); +}); + +test("isLocalOnlyPath: internal/ingest is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/internal/ingest"), true); +}); + +test("isLocalOnlyPath: export.har is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/export.har"), true); +}); + +test("isLocalOnlyPath: hosts sub-path is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/hosts"), true); +}); + +// ── isLoopbackHost assertions ─────────────────────────────────────────────── + +test("isLoopbackHost: localhost returns true", () => { + assert.equal(isLoopbackHost("localhost"), true); +}); + +test("isLoopbackHost: 127.0.0.1 returns true", () => { + assert.equal(isLoopbackHost("127.0.0.1"), true); +}); + +test("isLoopbackHost: example.com returns false", () => { + assert.equal(isLoopbackHost("example.com"), false); +}); + +test("isLoopbackHost: external IP returns false", () => { + assert.equal(isLoopbackHost("192.168.1.100"), false); +}); + +test("isLoopbackHost: ::1 IPv6 returns true", () => { + assert.equal(isLoopbackHost("[::1]"), true); +}); + +// ── Management policy simulation ──────────────────────────────────────────── + +test("management policy: non-loopback request to LOCAL_ONLY path would be blocked", () => { + // Simulate the guard check that happens in management.ts + const path2 = "/api/tools/traffic-inspector/requests"; + const hostHeader = "example.com"; // non-loopback + + const isLocalOnly = isLocalOnlyPath(path2); + const isLoopback = isLoopbackHost(hostHeader); + + // The policy blocks when: isLocalOnly && !isLoopback + assert.equal(isLocalOnly, true, "path should be LOCAL_ONLY"); + assert.equal(isLoopback, false, "example.com should not be loopback"); + // Therefore this request would be blocked (403 LOCAL_ONLY) + const wouldBeBlocked = isLocalOnly && !isLoopback; + assert.equal(wouldBeBlocked, true, "non-loopback request to LOCAL_ONLY path should be blocked"); +}); + +test("management policy: loopback request to LOCAL_ONLY path passes IP check", () => { + const path2 = "/api/tools/traffic-inspector/ws"; + const hostHeader = "localhost"; + + const isLocalOnly = isLocalOnlyPath(path2); + const isLoopback = isLoopbackHost(hostHeader); + + assert.equal(isLocalOnly, true); + assert.equal(isLoopback, true); + const passesIpCheck = !(isLocalOnly && !isLoopback); + assert.equal(passesIpCheck, true, "loopback to LOCAL_ONLY path passes IP gate"); +}); diff --git a/tests/integration/traffic-inspector-requests.test.ts b/tests/integration/traffic-inspector-requests.test.ts new file mode 100644 index 0000000000..9f894c441f --- /dev/null +++ b/tests/integration/traffic-inspector-requests.test.ts @@ -0,0 +1,193 @@ +/** + * Integration tests: Traffic Inspector requests endpoints + * + * Tests GET /requests (with filters), DELETE /requests, GET /requests/[id], + * and PUT /requests/[id]/annotation. + */ + +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"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-reqs-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { globalTrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); +const requestsRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/route.ts" +); +const requestDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/route.ts" +); +const annotationRoute = await import( + "../../src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts" +); + +function makeEntry(overrides: Partial<{ + id: string; + host: string; + detectedKind: "llm" | "app" | "unknown"; + status: number | "in-flight" | "error"; + source: "agent-bridge" | "custom-host" | "http-proxy" | "system-proxy"; +}> = {}) { + return { + id: randomUUID(), + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + detectedKind: "llm" as const, + ...overrides, + }; +} + +test.beforeEach(() => { + globalTrafficBuffer.clear(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /requests: returns empty list when buffer is empty", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/requests"); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: unknown[]; total: number }; + assert.deepEqual(body.requests, []); + assert.equal(body.total, 0); +}); + +test("GET /requests: returns all entries without filter", async () => { + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "a.com" })); + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "b.com" })); + + const req = new Request("http://localhost/api/tools/traffic-inspector/requests"); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: unknown[]; total: number }; + assert.equal(body.total, 2); +}); + +test("GET /requests: filters by profile=llm", async () => { + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), detectedKind: "llm" })); + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), detectedKind: "app" })); + + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?profile=llm" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: unknown[]; total: number }; + assert.equal(body.total, 1); +}); + +test("GET /requests: filters by host", async () => { + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "target.com" })); + globalTrafficBuffer.push(makeEntry({ id: randomUUID(), host: "other.com" })); + + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?host=target.com" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 200); + const body = await res.json() as { requests: Array<{ host: string }>; total: number }; + assert.equal(body.total, 1); + assert.equal(body.requests[0]?.host, "target.com"); +}); + +test("GET /requests: rejects invalid profile param with 400", async () => { + const req = new Request( + "http://localhost/api/tools/traffic-inspector/requests?profile=invalid" + ); + const res = await requestsRoute.GET(req); + assert.equal(res.status, 400); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("DELETE /requests: clears the buffer", async () => { + globalTrafficBuffer.push(makeEntry()); + + const res = await requestsRoute.DELETE(); + assert.equal(res.status, 204); + assert.equal(globalTrafficBuffer.size(), 0); +}); + +test("GET /requests/[id]: returns entry by id", async () => { + const entry = makeEntry(); + globalTrafficBuffer.push(entry); + + const req = new Request(`http://localhost/api/tools/traffic-inspector/requests/${entry.id}`); + const res = await requestDetailRoute.GET(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 200); + const body = await res.json() as { id: string }; + assert.equal(body.id, entry.id); +}); + +test("GET /requests/[id]: returns 404 for unknown id", async () => { + const req = new Request( + `http://localhost/api/tools/traffic-inspector/requests/${randomUUID()}` + ); + const res = await requestDetailRoute.GET(req, { + params: Promise.resolve({ id: randomUUID() }), + }); + assert.equal(res.status, 404); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("PUT /requests/[id]/annotation: attaches annotation", async () => { + const entry = makeEntry(); + globalTrafficBuffer.push(entry); + + const req = new Request( + `http://localhost/api/tools/traffic-inspector/requests/${entry.id}/annotation`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotation: "my note" }), + } + ); + const res = await annotationRoute.PUT(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 200); + const body = await res.json() as { annotation: string }; + assert.equal(body.annotation, "my note"); + + // Confirm buffer was updated + const updated = globalTrafficBuffer.get(entry.id); + assert.equal(updated?.annotation, "my note"); +}); + +test("PUT /requests/[id]/annotation: rejects annotation > 10000 chars", async () => { + const entry = makeEntry(); + globalTrafficBuffer.push(entry); + + const req = new Request( + `http://localhost/api/tools/traffic-inspector/requests/${entry.id}/annotation`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotation: "x".repeat(10_001) }), + } + ); + const res = await annotationRoute.PUT(req, { + params: Promise.resolve({ id: entry.id }), + }); + assert.equal(res.status, 400); +}); diff --git a/tests/integration/traffic-inspector-sessions.test.ts b/tests/integration/traffic-inspector-sessions.test.ts new file mode 100644 index 0000000000..3687b170b8 --- /dev/null +++ b/tests/integration/traffic-inspector-sessions.test.ts @@ -0,0 +1,241 @@ +/** + * Integration tests: Traffic Inspector sessions CRUD + * + * Tests the full lifecycle: POST start → PATCH stop → GET snapshot → DELETE cascade. + */ + +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"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-sessions-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { resetDbInstance, getDbInstance } = await import("../../src/lib/db/core.ts"); + +async function resetStorage() { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // Re-initialize db + getDbInstance(); +} + +const sessionsRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/route.ts" +); +const sessionDetailRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/[id]/route.ts" +); +const sessionHarRoute = await import( + "../../src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts" +); +const { appendSessionRequest } = await import("../../src/lib/db/inspectorSessions.ts"); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("POST /sessions: creates a session", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Test Session" }), + }); + const res = await sessionsRoute.POST(req); + assert.equal(res.status, 201); + const body = await res.json() as { id: string; started_at: string }; + assert.ok(body.id, "should have an id"); + assert.ok(body.started_at, "should have started_at"); +}); + +test("POST /sessions: name is optional", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const res = await sessionsRoute.POST(req); + assert.equal(res.status, 201); +}); + +test("GET /sessions: lists all sessions", async () => { + // Create two sessions + await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "s1" }), + }) + ); + await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "s2" }), + }) + ); + + const res = await sessionsRoute.GET(); + assert.equal(res.status, 200); + const body = await res.json() as { sessions: unknown[] }; + assert.equal(body.sessions.length, 2); +}); + +test("PATCH /sessions/[id]: stop adds ended_at", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + const session = await createRes.json() as { id: string }; + + const patchReq = new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + }); + const patchRes = await sessionDetailRoute.PATCH(patchReq, { + params: Promise.resolve({ id: session.id }), + }); + assert.equal(patchRes.status, 200); + const body = await patchRes.json() as { ended_at: string | null }; + assert.ok(body.ended_at !== null, "ended_at should be set after stop"); +}); + +test("PATCH /sessions/[id]: rename updates name", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "old-name" }), + }) + ); + const session = await createRes.json() as { id: string }; + + const patchRes = await sessionDetailRoute.PATCH( + new Request("http://localhost/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "rename", name: "new-name" }), + }), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(patchRes.status, 200); + const body = await patchRes.json() as { name: string }; + assert.equal(body.name, "new-name"); +}); + +test("GET /sessions/[id]: returns session with requests", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "with-reqs" }), + }) + ); + const session = await createRes.json() as { id: string }; + + // Append a fake request + const payload = JSON.stringify({ + id: randomUUID(), + source: "agent-bridge", + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + status: 200, + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + timestamp: new Date().toISOString(), + }); + appendSessionRequest(session.id, payload); + + const getRes = await sessionDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(getRes.status, 200); + const body = await getRes.json() as { session: { id: string }; requests: unknown[] }; + assert.equal(body.session.id, session.id); + assert.equal(body.requests.length, 1); +}); + +test("DELETE /sessions/[id]: cascades requests", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + const session = await createRes.json() as { id: string }; + + appendSessionRequest(session.id, JSON.stringify({ note: "test" })); + + const delRes = await sessionDetailRoute.DELETE( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(delRes.status, 204); + + // Session should be gone + const getRes = await sessionDetailRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(getRes.status, 404); +}); + +test("GET /sessions/[id]/export.har: returns HAR file", async () => { + const createRes = await sessionsRoute.POST( + new Request("http://localhost/api/tools/traffic-inspector/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "har-test" }), + }) + ); + const session = await createRes.json() as { id: string }; + + const reqPayload = { + id: randomUUID(), + source: "agent-bridge", + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + status: 200, + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + timestamp: new Date().toISOString(), + }; + appendSessionRequest(session.id, JSON.stringify(reqPayload)); + + const harRes = await sessionHarRoute.GET( + new Request("http://localhost/"), + { params: Promise.resolve({ id: session.id }) } + ); + assert.equal(harRes.status, 200); + assert.ok( + harRes.headers.get("content-disposition")?.includes(".har"), + "should have .har filename" + ); + const har = await harRes.json() as { log: { entries: unknown[] } }; + assert.ok(har.log, "should be a HAR object"); + assert.equal(har.log.entries.length, 1); +}); diff --git a/tests/integration/traffic-inspector-ws.test.ts b/tests/integration/traffic-inspector-ws.test.ts new file mode 100644 index 0000000000..8bd12cad5c --- /dev/null +++ b/tests/integration/traffic-inspector-ws.test.ts @@ -0,0 +1,148 @@ +/** + * Integration tests: Traffic Inspector WebSocket endpoint + * + * Tests WS upgrade, initial snapshot delivery, and live buffer events. + * We do not spin up a full HTTP server — we test the buffer subscribe + * mechanism directly since the WS handler is a thin wrapper around it. + */ + +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"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ti-ws-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.INSPECTOR_BUFFER_SIZE = "100"; + +const { TrafficBuffer } = await import("../../src/mitm/inspector/buffer.ts"); +const wsRoute = await import( + "../../src/app/api/tools/traffic-inspector/ws/route.ts" +); + +function makeRequest(upgrade = "websocket", clientKey = "dGhlIHNhbXBsZSBub25jZQ=="): Request { + return new Request("http://localhost/api/tools/traffic-inspector/ws", { + headers: { + upgrade, + "sec-websocket-key": clientKey, + connection: "Upgrade", + }, + }); +} + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("ws/route: rejects non-WebSocket GET with 426", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/ws"); + const res = await wsRoute.GET(req); + assert.equal(res.status, 426); + const body = await res.json() as { error: { message: string } }; + assert.ok(body.error.message.includes("Upgrade"), "should mention upgrade"); +}); + +test("ws/route: rejects missing Sec-WebSocket-Key with 400", async () => { + const req = new Request("http://localhost/api/tools/traffic-inspector/ws", { + headers: { upgrade: "websocket", connection: "Upgrade" }, + }); + const res = await wsRoute.GET(req); + assert.equal(res.status, 400); +}); + +test("ws/route: rejects when no raw socket available with 500", async () => { + const req = makeRequest(); + // No `.socket` property injected — Next.js standalone would attach it + const res = await wsRoute.GET(req); + assert.equal(res.status, 500); + const body = await res.json() as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /"), "must not leak stack trace"); +}); + +test("TrafficBuffer: subscribe receives snapshot immediately", () => { + const buf = new TrafficBuffer(10, 1024 * 1024); + const entry = { + id: randomUUID(), + source: "agent-bridge" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "api.openai.com", + path: "/v1/chat/completions", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + }; + buf.push(entry); + + const events: unknown[] = []; + const unsub = buf.subscribe((ev) => events.push(ev)); + + assert.equal(events.length, 1, "should receive snapshot immediately"); + const snapshot = events[0] as { type: string; data: unknown[] }; + assert.equal(snapshot.type, "snapshot"); + assert.ok(Array.isArray(snapshot.data)); + assert.equal(snapshot.data.length, 1); + + unsub(); +}); + +test("TrafficBuffer: push broadcasts new event to subscribers", () => { + const buf = new TrafficBuffer(10, 1024 * 1024); + const events: unknown[] = []; + const unsub = buf.subscribe((ev) => events.push(ev)); + + // snapshot is at index 0 + buf.push({ + id: randomUUID(), + source: "http-proxy" as const, + timestamp: new Date().toISOString(), + method: "GET", + host: "example.com", + path: "/", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200 as const, + }); + + assert.equal(events.length, 2, "should have snapshot + new event"); + const newEv = events[1] as { type: string; data: { host: string } }; + assert.equal(newEv.type, "new"); + assert.equal(newEv.data.host, "example.com"); + + unsub(); +}); + +test("TrafficBuffer: unsubscribe stops receiving events", () => { + const buf = new TrafficBuffer(10, 1024 * 1024); + const events: unknown[] = []; + const unsub = buf.subscribe((ev) => events.push(ev)); + unsub(); + + buf.push({ + id: randomUUID(), + source: "system-proxy" as const, + timestamp: new Date().toISOString(), + method: "POST", + host: "test.com", + path: "/api", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 204 as const, + }); + + assert.equal(events.length, 1, "should only have the initial snapshot"); +});