Spaces:
Runtime error
Runtime error
| /** | |
| * 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"; | |
| 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("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]}` | |
| ); | |
| }); | |
| }); | |