File size: 8,744 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
/**
 * 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]}`
    );
  });
});