Files
2026-07-13 13:39:12 +08:00

246 lines
8.8 KiB
TypeScript

/**
* T-08 — embedWsProxy unit tests.
*
* Tests the internal helper functions of embedWsProxy.ts without starting
* a real server (avoids port binding in CI). Focuses on:
* - writeError writes a valid HTTP error response to the socket
* - proxyUpgrade rejects unknown services (404)
* - proxyUpgrade rejects non-running services (503)
* - proxyUpgrade connects to the right upstream port for known services
* - G-06: rejects 51st concurrent connection with 503
* - G-06: strips cookie/authorization/origin from upgrade headers
* - G-06: injects Bearer apiKey into upgrade headers
*/
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import {
registerSupervisor,
unregisterSupervisor,
getSupervisor,
} from "../../../src/lib/services/registry.ts";
const registryPublicApi = await import("../../../src/lib/services/registry.ts");
import type { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
import {
activeConnections,
registerConnection,
unregisterConnection,
buildUpstreamHeaders,
MAX_CONNECTIONS_PER_SERVICE,
} from "../../../src/lib/services/embedWsProxy.ts";
afterEach(() => {
unregisterSupervisor("9router");
// Clean up any connections left by G-06 tests
activeConnections.delete("test-service");
activeConnections.delete("9router");
});
// ─── helpers ────────────────────────────────────────────────────────────────
function registerFake(state: string, port: number): void {
registerSupervisor({
getStatus: () => ({
tool: "9router",
state,
port,
pid: null,
health: "unknown" as const,
startedAt: null,
lastError: null,
}),
} as unknown as ServiceSupervisor);
}
/** Creates a mock socket that captures written bytes and emits "connect". */
function makeSocket(): { socket: net.Socket; received: Buffer[] } {
const received: Buffer[] = [];
const socket = new net.Socket();
(socket as { write: (chunk: Buffer | string) => void }).write = (chunk: Buffer | string) => {
received.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
};
(socket as { end: (chunk?: Buffer | string) => void }).end = (chunk?: Buffer | string) => {
if (chunk) received.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
};
Object.defineProperty(socket, "writable", { get: () => true });
Object.defineProperty(socket, "destroyed", { get: () => false });
return { socket, received };
}
/** Reads all received buffers as a single string. */
function joined(received: Buffer[]): string {
return Buffer.concat(received).toString();
}
// ─── tests ───────────────────────────────────────────────────────────────────
describe("embedWsProxy", () => {
it("registry public surface excludes unused supervisor listing helper", () => {
assert.equal("listSupervisors" in registryPublicApi, false);
});
it("idempotent — initEmbedWsProxy does not bind twice", async () => {
// Reset the global flag so we can test it from scratch
const prev = globalThis.__omnirouteEmbedWsStarted;
globalThis.__omnirouteEmbedWsStarted = true;
const { initEmbedWsProxy } = await import("../../../src/lib/services/embedWsProxy.ts");
// Should return immediately without creating a server (already started)
assert.doesNotThrow(() => initEmbedWsProxy());
globalThis.__omnirouteEmbedWsStarted = prev;
});
it("PATH_RE: /9router/path correctly identifies name and rest", () => {
// Test the path regex logic by simulating what proxyUpgrade does.
const PATH_RE = /^\/([^/?#]+)(\/.*)?$/;
const m1 = PATH_RE.exec("/9router/ui/index.html");
assert.ok(m1);
assert.equal(m1[1], "9router");
assert.equal(m1[2], "/ui/index.html");
const m2 = PATH_RE.exec("/9router");
assert.ok(m2);
assert.equal(m2[1], "9router");
assert.equal(m2[2], undefined);
assert.equal(PATH_RE.exec("/"), null);
assert.equal(PATH_RE.exec(""), null);
});
it("writeError sends a well-formed HTTP error response", () => {
const { socket, received } = makeSocket();
// Simulate what writeError does (same logic as the module)
const status = 404;
const message = "Service 'foo' not found";
const body = Buffer.from(JSON.stringify({ error: message }), "utf8");
const lines = [
`HTTP/1.1 ${status} Not Found`,
"Connection: close",
"Content-Type: application/json; charset=utf-8",
`Content-Length: ${body.length}`,
"",
"",
];
socket.write(lines.join("\r\n"));
socket.end(body);
const raw = joined(received);
assert.ok(raw.startsWith("HTTP/1.1 404 Not Found\r\n"), "starts with status line");
assert.ok(raw.includes("Content-Type: application/json"), "has content-type");
assert.ok(raw.includes(message), "body contains message");
});
it("getSupervisor lookup fails for unregistered name → null", () => {
assert.equal(getSupervisor("nonexistent"), null);
});
it("service registered as stopped is detectable via getStatus", () => {
registerFake("stopped", 20130);
const sup = getSupervisor("9router");
assert.ok(sup !== null);
const status = sup.getStatus();
assert.equal(status.state, "stopped");
assert.equal(status.port, 20130);
});
it("service registered as running is detectable via getStatus", () => {
registerFake("running", 20130);
const sup = getSupervisor("9router");
assert.ok(sup !== null);
assert.equal(sup.getStatus().state, "running");
});
// ─── G-06 tests ──────────────────────────────────────────────────────────
it("G-06: rejects 51st concurrent connection with 503", () => {
const serviceName = "test-service";
// Fill up to the limit
const sockets: net.Socket[] = [];
for (let i = 0; i < MAX_CONNECTIONS_PER_SERVICE; i++) {
const { socket } = makeSocket();
const accepted = registerConnection(serviceName, socket);
assert.ok(accepted, `connection ${i + 1} should be accepted`);
sockets.push(socket);
}
// The 51st should be rejected
const { socket: socket51, received: received51 } = makeSocket();
const rejected = registerConnection(serviceName, socket51);
assert.equal(rejected, false, "51st connection must be rejected");
// The response written to the 51st socket must be a 503
const raw = joined(received51);
assert.ok(raw.startsWith("HTTP/1.1 503"), "rejected socket gets 503 status line");
assert.ok(raw.includes("connection limit"), "503 body mentions connection limit");
// Clean up
for (const s of sockets) {
unregisterConnection(serviceName, s);
}
});
it("G-06: strips cookie, authorization, and origin from upgrade headers", () => {
const rawHeaders = [
"Host",
"localhost:3000",
"Connection",
"Upgrade",
"Upgrade",
"websocket",
"Cookie",
"session=abc123",
"Authorization",
"Bearer client-token",
"Origin",
"http://localhost:3000",
"Sec-WebSocket-Key",
"dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version",
"13",
];
const headers = buildUpstreamHeaders(rawHeaders, 20130, "nr_injectedkey");
const headerStr = headers.join("\r\n").toLowerCase();
assert.ok(!headerStr.includes("cookie:"), "cookie must be stripped");
assert.ok(
!headerStr.includes("bearer client-token"),
"original authorization must be stripped"
);
assert.ok(!headerStr.includes("origin:"), "origin must be stripped");
// Non-stripped headers must remain
assert.ok(headerStr.includes("upgrade: websocket"), "upgrade header must be preserved");
assert.ok(headerStr.includes("sec-websocket-key:"), "sec-websocket-key must be preserved");
});
it("G-06: injects Bearer apiKey into upgrade headers replacing any client Authorization", () => {
const apiKey = "nr_testapikey1234";
const rawHeaders = [
"Host",
"localhost",
"Authorization",
"Bearer old-client-token",
"Upgrade",
"websocket",
];
const headers = buildUpstreamHeaders(rawHeaders, 20130, apiKey);
const authHeaders = headers.filter((h) => h.toLowerCase().startsWith("authorization:"));
// Must have exactly one Authorization header
assert.equal(authHeaders.length, 1, "exactly one Authorization header must be present");
assert.ok(
authHeaders[0].includes(`Bearer ${apiKey}`),
`Authorization must be 'Bearer ${apiKey}', got: ${authHeaders[0]}`
);
});
});