246 lines
8.8 KiB
TypeScript
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]}`
|
|
);
|
|
});
|
|
});
|