mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
test(api): integration tests for traffic-inspector routes (F6)
This commit is contained in:
258
tests/integration/traffic-inspector-capture-modes.test.ts
Normal file
258
tests/integration/traffic-inspector-capture-modes.test.ts
Normal file
@@ -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<void>((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);
|
||||
});
|
||||
216
tests/integration/traffic-inspector-error-sanitization.test.ts
Normal file
216
tests/integration/traffic-inspector-error-sanitization.test.ts
Normal file
@@ -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<string> {
|
||||
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");
|
||||
});
|
||||
142
tests/integration/traffic-inspector-hosts.test.ts
Normal file
142
tests/integration/traffic-inspector-hosts.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
150
tests/integration/traffic-inspector-internal-ingest.test.ts
Normal file
150
tests/integration/traffic-inspector-internal-ingest.test.ts
Normal file
@@ -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<string, string> = {
|
||||
"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<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, string> = {
|
||||
"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);
|
||||
});
|
||||
114
tests/integration/traffic-inspector-localonly.test.ts
Normal file
114
tests/integration/traffic-inspector-localonly.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
193
tests/integration/traffic-inspector-requests.test.ts
Normal file
193
tests/integration/traffic-inspector-requests.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
241
tests/integration/traffic-inspector-sessions.test.ts
Normal file
241
tests/integration/traffic-inspector-sessions.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
148
tests/integration/traffic-inspector-ws.test.ts
Normal file
148
tests/integration/traffic-inspector-ws.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user