mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
merge(F6): Traffic Inspector REST + WS routes into Group A parent
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* POST /api/tools/traffic-inspector/capture-modes/http-proxy
|
||||
*
|
||||
* Start or stop the HTTP_PROXY listener (default port 8080).
|
||||
* `EADDRINUSE` is surfaced as 409 with a structured error body.
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorCaptureModeActionSchema } from "@/shared/schemas/inspector";
|
||||
import { startHttpProxyServer } from "@/mitm/inspector/httpProxyServer";
|
||||
import { getHttpProxyHandle, setHttpProxyHandle } from "@/lib/inspector/captureState";
|
||||
|
||||
const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080;
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = InspectorCaptureModeActionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { action } = parsed.data;
|
||||
|
||||
if (action === "stop") {
|
||||
const handle = getHttpProxyHandle();
|
||||
if (!handle) {
|
||||
return Response.json({ ok: true, running: false, port: null });
|
||||
}
|
||||
try {
|
||||
await handle.stop();
|
||||
setHttpProxyHandle(null);
|
||||
return Response.json({ ok: true, running: false, port: null });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to stop HTTP proxy")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// action === "start"
|
||||
const existing = getHttpProxyHandle();
|
||||
if (existing) {
|
||||
return Response.json({ ok: true, running: true, port: existing.port });
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await startHttpProxyServer(DEFAULT_PORT);
|
||||
setHttpProxyHandle(handle);
|
||||
return Response.json({ ok: true, running: true, port: handle.port }, { status: 201 });
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr?.code === "EADDRINUSE") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Port ${DEFAULT_PORT} is already in use`,
|
||||
type: "conflict",
|
||||
code: "EADDRINUSE",
|
||||
port: DEFAULT_PORT,
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(500, msg || "Failed to start HTTP proxy")),
|
||||
{ status: 500, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/tools/traffic-inspector/capture-modes/route.ts
Normal file
53
src/app/api/tools/traffic-inspector/capture-modes/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/capture-modes
|
||||
*
|
||||
* Returns the current status of all 4 capture modes:
|
||||
* 1. agentBridge — always active when the MITM server is running
|
||||
* 2. customHosts — count from DB
|
||||
* 3. httpProxy — running flag + port
|
||||
* 4. systemProxy — applied flag + guardUntil
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { listCustomHosts } from "@/lib/db/inspectorCustomHosts";
|
||||
import {
|
||||
getHttpProxyHandle,
|
||||
getSystemProxyState,
|
||||
isTlsInterceptEnabled,
|
||||
} from "@/lib/inspector/captureState";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const customHosts = listCustomHosts();
|
||||
const httpProxy = getHttpProxyHandle();
|
||||
const systemProxy = getSystemProxyState();
|
||||
|
||||
return Response.json({
|
||||
agentBridge: true,
|
||||
customHosts: {
|
||||
count: customHosts.length,
|
||||
enabledCount: customHosts.filter((h) => h.enabled).length,
|
||||
},
|
||||
httpProxy: {
|
||||
running: httpProxy !== null,
|
||||
port: httpProxy?.port ?? null,
|
||||
},
|
||||
systemProxy: {
|
||||
applied: systemProxy.applied,
|
||||
guardUntil: systemProxy.guardUntil,
|
||||
port: systemProxy.port,
|
||||
},
|
||||
tlsIntercept: {
|
||||
enabled: isTlsInterceptEnabled(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(500, msg || "Failed to get capture mode status")),
|
||||
{ status: 500, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* POST /api/tools/traffic-inspector/capture-modes/system-proxy
|
||||
*
|
||||
* Apply or revert the OS-level system proxy.
|
||||
*
|
||||
* `apply` — sets the system proxy to 127.0.0.1:<port> and saves the
|
||||
* prior state so it can be restored. Starts a guard timer that
|
||||
* auto-reverts after `guardMinutes` (default 30).
|
||||
*
|
||||
* `revert` — restores the previously saved proxy state.
|
||||
*
|
||||
* Hard Rule #13: all shell invocations happen in `systemProxyConfig.ts` using
|
||||
* `execFile` with array args — no interpolation here.
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorSystemProxyActionSchema } from "@/shared/schemas/inspector";
|
||||
import { apply, revert } from "@/mitm/inspector/systemProxyConfig";
|
||||
import {
|
||||
getSystemProxyState,
|
||||
setSystemProxyApplied,
|
||||
clearSystemProxy,
|
||||
} from "@/lib/inspector/captureState";
|
||||
|
||||
const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080;
|
||||
const DEFAULT_GUARD_MINUTES = Number(
|
||||
process.env.INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES ?? "30"
|
||||
) || 30;
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = InspectorSystemProxyActionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { action, port, guardMinutes } = parsed.data;
|
||||
const resolvedPort = port ?? DEFAULT_PORT;
|
||||
const resolvedGuard = guardMinutes ?? DEFAULT_GUARD_MINUTES;
|
||||
|
||||
if (action === "revert") {
|
||||
const state = getSystemProxyState();
|
||||
const previousState = state.previousState;
|
||||
|
||||
try {
|
||||
if (previousState) {
|
||||
await revert(previousState);
|
||||
}
|
||||
clearSystemProxy();
|
||||
return Response.json({ ok: true, applied: false });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(500, msg || "Failed to revert system proxy")),
|
||||
{ status: 500, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// action === "apply"
|
||||
try {
|
||||
const result = await apply(resolvedPort);
|
||||
setSystemProxyApplied(resolvedPort, result.previousState, resolvedGuard);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
applied: true,
|
||||
port: resolvedPort,
|
||||
platform: result.platform,
|
||||
guardUntil: getSystemProxyState().guardUntil,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(500, msg || "Failed to apply system proxy")),
|
||||
{ status: 500, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/tools/traffic-inspector/capture-modes/tls-intercept
|
||||
*
|
||||
* Toggle TLS body decryption in the MITM proxy. When enabled, the MITM
|
||||
* server decrypts HTTPS bodies and the Traffic Inspector can show full
|
||||
* request/response content. When disabled, CONNECT tunnels are passed through
|
||||
* and only metadata is captured.
|
||||
*
|
||||
* State is held in the `captureState` module (process-lifetime).
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorTlsInterceptToggleSchema } from "@/shared/schemas/inspector";
|
||||
import { isTlsInterceptEnabled, setTlsIntercept } from "@/lib/inspector/captureState";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = InspectorTlsInterceptToggleSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
setTlsIntercept(parsed.data.enabled);
|
||||
return Response.json({ ok: true, tlsIntercept: { enabled: isTlsInterceptEnabled() } });
|
||||
}
|
||||
62
src/app/api/tools/traffic-inspector/export.har/route.ts
Normal file
62
src/app/api/tools/traffic-inspector/export.har/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/export.har
|
||||
*
|
||||
* Exports the entire (optionally filtered) traffic buffer as a HAR v1.2 file.
|
||||
* The Content-Disposition header triggers a browser download.
|
||||
*
|
||||
* Secrets are always masked in the export — see `toHar` implementation.
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorListQuerySchema } from "@/shared/schemas/inspector";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
import { toHar } from "@/lib/inspector/harExport";
|
||||
import type { ListFilters } from "@/mitm/inspector/types";
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const rawQuery: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
rawQuery[key] = value;
|
||||
});
|
||||
|
||||
const parsed = InspectorListQuerySchema.safeParse(rawQuery);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const filters: ListFilters = {
|
||||
profile: parsed.data.profile,
|
||||
host: parsed.data.host,
|
||||
agent: parsed.data.agent as ListFilters["agent"],
|
||||
status: parsed.data.status,
|
||||
source: parsed.data.source,
|
||||
sessionId: parsed.data.sessionId,
|
||||
};
|
||||
|
||||
try {
|
||||
const requests = globalTrafficBuffer.list(filters);
|
||||
const har = toHar(requests);
|
||||
const json = JSON.stringify(har, null, 2);
|
||||
|
||||
return new Response(json, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-disposition": 'attachment; filename="traffic.har"',
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
77
src/app/api/tools/traffic-inspector/hosts/[host]/route.ts
Normal file
77
src/app/api/tools/traffic-inspector/hosts/[host]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* DELETE /api/tools/traffic-inspector/hosts/[host] — remove a custom host
|
||||
* PATCH /api/tools/traffic-inspector/hosts/[host] — toggle enabled flag
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { z } from "zod";
|
||||
import { removeCustomHost, toggleCustomHost, listCustomHosts } from "@/lib/db/inspectorCustomHosts";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ host: string }>;
|
||||
}
|
||||
|
||||
const PatchBodySchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { host } = await params;
|
||||
const decodedHost = decodeURIComponent(host);
|
||||
|
||||
try {
|
||||
removeCustomHost(decodedHost);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to remove host")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params): Promise<Response> {
|
||||
const { host } = await params;
|
||||
const decodedHost = decodeURIComponent(host);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = PatchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
toggleCustomHost(decodedHost, parsed.data.enabled);
|
||||
// Return updated record
|
||||
const hosts = listCustomHosts();
|
||||
const updated = hosts.find((h) => h.host === decodedHost);
|
||||
if (!updated) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Host not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return Response.json(updated);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to toggle host")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
61
src/app/api/tools/traffic-inspector/hosts/route.ts
Normal file
61
src/app/api/tools/traffic-inspector/hosts/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/hosts — list custom host capture entries
|
||||
* POST /api/tools/traffic-inspector/hosts — add a host (DB record)
|
||||
*
|
||||
* The DB record enables the MITM proxy to SNI-certify the host on demand.
|
||||
* DNS /etc/hosts edits are out of scope for this route — clients that need
|
||||
* OS-level redirect must use the Custom Hosts setup guide (requires sudo).
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorCustomHostSchema } from "@/shared/schemas/inspector";
|
||||
import { listCustomHosts, addCustomHost } from "@/lib/db/inspectorCustomHosts";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const hosts = listCustomHosts();
|
||||
return Response.json({ hosts });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list hosts")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = InspectorCustomHostSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { host, kind, label } = parsed.data;
|
||||
|
||||
try {
|
||||
addCustomHost(host, kind, label ?? undefined);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to add host")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, host }, { status: 201 });
|
||||
}
|
||||
127
src/app/api/tools/traffic-inspector/internal/ingest/route.ts
Normal file
127
src/app/api/tools/traffic-inspector/internal/ingest/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* POST /api/tools/traffic-inspector/internal/ingest
|
||||
*
|
||||
* Internal endpoint consumed by `server.cjs` (D4 fallback) to push
|
||||
* intercepted request data into the traffic buffer when the request does
|
||||
* NOT pass through a TypeScript handler that already calls
|
||||
* `agentBridgeHook.ts`.
|
||||
*
|
||||
* Security model (double LOCAL_ONLY):
|
||||
* 1. `isLocalOnlyPath("/api/tools/traffic-inspector/")` blocks all non-
|
||||
* loopback callers unconditionally — this is handled by the authz pipeline.
|
||||
* 2. The shared secret `INSPECTOR_INTERNAL_INGEST_TOKEN` (set in .env or
|
||||
* auto-generated at process boot) must match the `Authorization: Bearer`
|
||||
* header. This prevents any other loopback process from stuffing the buffer.
|
||||
*
|
||||
* Body: partial `InterceptedRequest` — only `id`, `timestamp`, `method`,
|
||||
* `host`, `path` are required; all other fields default.
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard + token gate below.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { InterceptedRequestSchema } from "@/mitm/inspector/types";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
|
||||
// ── Token management ────────────────────────────────────────────────────────
|
||||
|
||||
let _cachedToken: string | null = null;
|
||||
|
||||
function getIngestToken(): string {
|
||||
if (_cachedToken) return _cachedToken;
|
||||
const env = process.env.INSPECTOR_INTERNAL_INGEST_TOKEN;
|
||||
if (env && env.length >= 16) {
|
||||
_cachedToken = env;
|
||||
} else {
|
||||
// Auto-generate on first call; persists for the lifetime of the process.
|
||||
_cachedToken = randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return _cachedToken;
|
||||
}
|
||||
|
||||
function tokenMatches(received: string): boolean {
|
||||
const expected = getIngestToken();
|
||||
if (!received || !expected) return false;
|
||||
try {
|
||||
const a = createHash("sha256").update(expected).digest();
|
||||
const b = createHash("sha256").update(received).digest();
|
||||
return timingSafeEqual(a, b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Partial schema (only required fields; rest optional) ───────────────────
|
||||
|
||||
const IngestBodySchema = InterceptedRequestSchema.partial().required({
|
||||
id: true,
|
||||
timestamp: true,
|
||||
method: true,
|
||||
host: true,
|
||||
path: true,
|
||||
source: true,
|
||||
requestHeaders: true,
|
||||
requestSize: true,
|
||||
responseHeaders: true,
|
||||
responseSize: true,
|
||||
status: true,
|
||||
});
|
||||
|
||||
// ── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
// Token gate (second layer after LOCAL_ONLY IP check).
|
||||
const authHeader = request.headers.get("authorization") ?? "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
||||
if (!tokenMatches(token)) {
|
||||
return new Response(JSON.stringify(buildErrorBody(403, "Invalid or missing ingest token")), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = IngestBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fill in any missing optional fields with sensible defaults.
|
||||
const req = {
|
||||
requestBody: null,
|
||||
responseBody: null,
|
||||
...parsed.data,
|
||||
};
|
||||
globalTrafficBuffer.push(req);
|
||||
return Response.json({ ok: true, id: req.id }, { status: 200 });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Ingest failed")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the auto-generated token for use by `server.cjs` bootstrap.
|
||||
* Called once at process start via dynamic import.
|
||||
*/
|
||||
export function getIngestTokenForBootstrap(): string {
|
||||
return getIngestToken();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PUT /api/tools/traffic-inspector/requests/[id]/annotation
|
||||
*
|
||||
* Attaches or replaces a free-text annotation on a buffered entry.
|
||||
* Mutations are broadcast to all WS subscribers via `buffer.update`.
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorAnnotationPutSchema } from "@/shared/schemas/inspector";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = InspectorAnnotationPutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")
|
||||
),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const entry = globalTrafficBuffer.get(id);
|
||||
if (!entry) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = { ...entry, annotation: parsed.data.annotation };
|
||||
globalTrafficBuffer.update(id, updated);
|
||||
return Response.json(updated);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update annotation")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* POST /api/tools/traffic-inspector/requests/[id]/replay
|
||||
*
|
||||
* Re-issues the captured request through the local OmniRoute instance and
|
||||
* returns the response body. The replay will itself appear in the traffic
|
||||
* buffer (captured by agentBridgeHook or httpProxyServer depending on path).
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const OMNIROUTE_BASE = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128";
|
||||
|
||||
export async function POST(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const entry = globalTrafficBuffer.get(id);
|
||||
if (!entry) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${OMNIROUTE_BASE}${entry.path}`;
|
||||
|
||||
const replayHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"x-omniroute-source": "inspector-replay",
|
||||
};
|
||||
// Forward original Authorization if present (masked in buffer — skip if masked)
|
||||
const origAuth = entry.requestHeaders["authorization"] ?? entry.requestHeaders["Authorization"];
|
||||
if (origAuth && !origAuth.includes("***")) {
|
||||
replayHeaders["authorization"] = origAuth;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(url, {
|
||||
method: entry.method,
|
||||
headers: replayHeaders,
|
||||
body: entry.requestBody ?? undefined,
|
||||
});
|
||||
|
||||
const body = await upstream.text();
|
||||
return new Response(body, {
|
||||
status: upstream.status,
|
||||
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(502, msg || "Replay failed")), {
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/app/api/tools/traffic-inspector/requests/[id]/route.ts
Normal file
24
src/app/api/tools/traffic-inspector/requests/[id]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/requests/[id] — fetch a single intercepted request
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const entry = globalTrafficBuffer.get(id);
|
||||
if (!entry) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return Response.json(entry);
|
||||
}
|
||||
44
src/app/api/tools/traffic-inspector/requests/route.ts
Normal file
44
src/app/api/tools/traffic-inspector/requests/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/requests — list buffer with optional filters
|
||||
* DELETE /api/tools/traffic-inspector/requests — clear the entire buffer
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard (no extra check needed here).
|
||||
*/
|
||||
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorListQuerySchema } from "@/shared/schemas/inspector";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
import type { ListFilters } from "@/mitm/inspector/types";
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const rawQuery: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
rawQuery[key] = value;
|
||||
});
|
||||
|
||||
const parsed = InspectorListQuerySchema.safeParse(rawQuery);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const filters: ListFilters = {
|
||||
profile: parsed.data.profile,
|
||||
host: parsed.data.host,
|
||||
agent: parsed.data.agent as ListFilters["agent"],
|
||||
status: parsed.data.status,
|
||||
source: parsed.data.source,
|
||||
sessionId: parsed.data.sessionId,
|
||||
};
|
||||
|
||||
const requests = globalTrafficBuffer.list(filters);
|
||||
return Response.json({ requests, total: requests.length });
|
||||
}
|
||||
|
||||
export async function DELETE(): Promise<Response> {
|
||||
globalTrafficBuffer.clear();
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/sessions/[id]/export.har
|
||||
*
|
||||
* Export all requests of a specific session as HAR v1.2.
|
||||
* Secrets are always masked — see `toHar`.
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { getSession, getSessionRequests } from "@/lib/db/inspectorSessions";
|
||||
import { toHar } from "@/lib/inspector/harExport";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = getSessionRequests(id);
|
||||
const requests: InterceptedRequest[] = rows
|
||||
.map((r) => {
|
||||
try {
|
||||
return JSON.parse(r.payload) as InterceptedRequest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((r): r is InterceptedRequest => r !== null);
|
||||
|
||||
const har = toHar(requests);
|
||||
const sessionName = (session.name ?? `session-${id}`).replace(/[^a-z0-9_-]/gi, "_");
|
||||
const filename = `${sessionName}.har`;
|
||||
|
||||
return new Response(JSON.stringify(har, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-disposition": `attachment; filename="${filename}"`,
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
123
src/app/api/tools/traffic-inspector/sessions/[id]/route.ts
Normal file
123
src/app/api/tools/traffic-inspector/sessions/[id]/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/sessions/[id] — session detail + requests
|
||||
* PATCH /api/tools/traffic-inspector/sessions/[id] — stop or rename
|
||||
* DELETE /api/tools/traffic-inspector/sessions/[id] — delete + cascade requests
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorSessionPatchSchema } from "@/shared/schemas/inspector";
|
||||
import {
|
||||
getSession,
|
||||
getSessionRequests,
|
||||
stopSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
} from "@/lib/db/inspectorSessions";
|
||||
|
||||
interface Params {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const requests = getSessionRequests(id).map((r) => {
|
||||
try {
|
||||
return JSON.parse(r.payload) as unknown;
|
||||
} catch {
|
||||
return r.payload;
|
||||
}
|
||||
});
|
||||
return Response.json({ session, requests });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to get session")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = InspectorSessionPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.data.action === "stop") {
|
||||
stopSession(id);
|
||||
} else if (parsed.data.action === "rename") {
|
||||
if (!parsed.data.name) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, "name is required for rename action")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
renameSession(id, parsed.data.name);
|
||||
}
|
||||
return Response.json(getSession(id));
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update session")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params): Promise<Response> {
|
||||
const { id } = await params;
|
||||
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
deleteSession(id);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to delete session")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
52
src/app/api/tools/traffic-inspector/sessions/route.ts
Normal file
52
src/app/api/tools/traffic-inspector/sessions/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* GET /api/tools/traffic-inspector/sessions — list all sessions
|
||||
* POST /api/tools/traffic-inspector/sessions — start a new recording session
|
||||
*
|
||||
* LOCAL_ONLY enforced by routeGuard.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { InspectorSessionStartSchema } from "@/shared/schemas/inspector";
|
||||
import { listSessions, createSession } from "@/lib/db/inspectorSessions";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const sessions = listSessions();
|
||||
return Response.json({ sessions });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list sessions")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
// Empty body is valid — name is optional
|
||||
body = {};
|
||||
}
|
||||
|
||||
const parsed = InspectorSessionStartSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = createSession({ name: parsed.data.name });
|
||||
return Response.json(session, { status: 201 });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err);
|
||||
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to create session")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
142
src/app/api/tools/traffic-inspector/ws/route.ts
Normal file
142
src/app/api/tools/traffic-inspector/ws/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* WebSocket endpoint for the Traffic Inspector live stream.
|
||||
*
|
||||
* Clients connect here to receive real-time `WsEvent` frames
|
||||
* (snapshot, new, update, clear) from the `globalTrafficBuffer`.
|
||||
*
|
||||
* LOCAL_ONLY enforcement happens unconditionally in the authz pipeline via
|
||||
* `isLocalOnlyPath("/api/tools/traffic-inspector/")` — this route does not
|
||||
* need to repeat that check. The WS upgrade uses the raw socket injected by
|
||||
* Next.js / the standalone server.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Client connects with `Upgrade: websocket`.
|
||||
* 2. Server immediately emits `{type:"snapshot", data:[...]}`.
|
||||
* 3. Subsequent mutations produce `{type:"new"|"update"|"clear", data?}`.
|
||||
* 4. Server sends ping frames every 30s; client may pong (ignored here).
|
||||
* 5. Closing the connection removes the subscriber.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
const PING_INTERVAL_MS = 30_000;
|
||||
|
||||
function acceptKey(clientKey: string): string {
|
||||
return createHash("sha1")
|
||||
.update(clientKey + WS_GUID)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function encodeWsFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
|
||||
const length = payload.length;
|
||||
let header: Buffer;
|
||||
if (length < 126) {
|
||||
header = Buffer.allocUnsafe(2);
|
||||
header[1] = length;
|
||||
} else if (length <= 0xffff) {
|
||||
header = Buffer.allocUnsafe(4);
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(length, 2);
|
||||
} else {
|
||||
header = Buffer.allocUnsafe(10);
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(length), 2);
|
||||
}
|
||||
header[0] = 0x80 | (opcode & 0x0f);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function sendText(socket: import("node:net").Socket, data: unknown): void {
|
||||
try {
|
||||
const json = JSON.stringify(data);
|
||||
const payload = Buffer.from(json, "utf8");
|
||||
socket.write(encodeWsFrame(0x01, payload));
|
||||
} catch {
|
||||
// socket may be destroyed; ignore
|
||||
}
|
||||
}
|
||||
|
||||
function sendClose(socket: import("node:net").Socket): void {
|
||||
try {
|
||||
socket.write(encodeWsFrame(0x08));
|
||||
socket.end();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const upgrade = request.headers.get("upgrade");
|
||||
if (!upgrade || upgrade.toLowerCase() !== "websocket") {
|
||||
return new Response(JSON.stringify(buildErrorBody(426, "Upgrade Required")), {
|
||||
status: 426,
|
||||
headers: { "content-type": "application/json", Upgrade: "websocket" },
|
||||
});
|
||||
}
|
||||
|
||||
const clientKey = request.headers.get("sec-websocket-key");
|
||||
if (!clientKey) {
|
||||
return new Response(JSON.stringify(buildErrorBody(400, "Missing Sec-WebSocket-Key")), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error — Next.js standalone server exposes the raw socket via
|
||||
// `request.socket` but the Request type does not declare it.
|
||||
const socket = (request as unknown as { socket?: import("node:net").Socket }).socket;
|
||||
if (!socket) {
|
||||
return new Response(JSON.stringify(buildErrorBody(500, "WebSocket upgrade unavailable")), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const acceptHeader = acceptKey(clientKey);
|
||||
socket.write(
|
||||
[
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${acceptHeader}`,
|
||||
"\r\n",
|
||||
].join("\r\n")
|
||||
);
|
||||
|
||||
const unsubscribe = globalTrafficBuffer.subscribe((ev) => {
|
||||
sendText(socket, ev);
|
||||
});
|
||||
|
||||
const pingTimer = setInterval(() => {
|
||||
try {
|
||||
socket.write(encodeWsFrame(0x09)); // ping
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
|
||||
function cleanup(): void {
|
||||
clearInterval(pingTimer);
|
||||
unsubscribe();
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
socket.once("close", cleanup);
|
||||
socket.once("error", cleanup);
|
||||
|
||||
// Never resolve — the socket is the response channel.
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.once("close", resolve);
|
||||
socket.once("error", resolve);
|
||||
});
|
||||
|
||||
cleanup();
|
||||
return new Response(null, { status: 101 });
|
||||
}
|
||||
90
src/lib/inspector/captureState.ts
Normal file
90
src/lib/inspector/captureState.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Runtime state for Traffic Inspector capture modes.
|
||||
*
|
||||
* Held in module-level variables (process-singleton). Survives across route
|
||||
* handler calls for the lifetime of the process.
|
||||
*
|
||||
* Exported mutation functions are the single write path so all route handlers
|
||||
* stay stateless.
|
||||
*/
|
||||
|
||||
import type { HttpProxyServerHandle } from "@/mitm/inspector/httpProxyServer";
|
||||
import type { PreviousState } from "@/mitm/inspector/systemProxyConfig";
|
||||
|
||||
// ── HTTP Proxy ──────────────────────────────────────────────────────────────
|
||||
|
||||
let httpProxyHandle: HttpProxyServerHandle | null = null;
|
||||
|
||||
export function getHttpProxyHandle(): HttpProxyServerHandle | null {
|
||||
return httpProxyHandle;
|
||||
}
|
||||
|
||||
export function setHttpProxyHandle(handle: HttpProxyServerHandle | null): void {
|
||||
httpProxyHandle = handle;
|
||||
}
|
||||
|
||||
// ── System Proxy ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SystemProxyState {
|
||||
applied: boolean;
|
||||
port: number | null;
|
||||
guardUntil: string | null; // ISO 8601
|
||||
previousState: PreviousState | null;
|
||||
}
|
||||
|
||||
let systemProxyState: SystemProxyState = {
|
||||
applied: false,
|
||||
port: null,
|
||||
guardUntil: null,
|
||||
previousState: null,
|
||||
};
|
||||
|
||||
let guardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function getSystemProxyState(): Readonly<SystemProxyState> {
|
||||
return { ...systemProxyState };
|
||||
}
|
||||
|
||||
export function setSystemProxyApplied(
|
||||
port: number,
|
||||
previousState: PreviousState,
|
||||
guardMinutes: number
|
||||
): void {
|
||||
if (guardTimer) clearTimeout(guardTimer);
|
||||
|
||||
const guardUntil = new Date(Date.now() + guardMinutes * 60_000).toISOString();
|
||||
systemProxyState = { applied: true, port, guardUntil, previousState };
|
||||
|
||||
guardTimer = setTimeout(
|
||||
() => {
|
||||
// Auto-revert after guard period — fire-and-forget.
|
||||
// Import lazily to avoid circular deps at module load.
|
||||
import("@/mitm/inspector/systemProxyConfig").then(({ revert }) => {
|
||||
const ps = systemProxyState.previousState;
|
||||
systemProxyState = { applied: false, port: null, guardUntil: null, previousState: null };
|
||||
if (ps) revert(ps).catch(() => {/* best-effort */});
|
||||
}).catch(() => {/* best-effort */});
|
||||
},
|
||||
guardMinutes * 60_000
|
||||
);
|
||||
}
|
||||
|
||||
export function clearSystemProxy(): void {
|
||||
if (guardTimer) {
|
||||
clearTimeout(guardTimer);
|
||||
guardTimer = null;
|
||||
}
|
||||
systemProxyState = { applied: false, port: null, guardUntil: null, previousState: null };
|
||||
}
|
||||
|
||||
// ── TLS Intercept ───────────────────────────────────────────────────────────
|
||||
|
||||
let tlsInterceptEnabled = process.env.INSPECTOR_TLS_INTERCEPT === "true";
|
||||
|
||||
export function isTlsInterceptEnabled(): boolean {
|
||||
return tlsInterceptEnabled;
|
||||
}
|
||||
|
||||
export function setTlsIntercept(enabled: boolean): void {
|
||||
tlsInterceptEnabled = enabled;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
|
||||
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
|
||||
"/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17)
|
||||
"/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17)
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/cli-tools/runtime/",
|
||||
"/api/services/", // T-10: can run npm install + spawn node processes
|
||||
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
|
||||
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
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