@mingxy/cerebro 1.20.4 → 1.20.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -3
- package/src/client.test.ts +373 -0
- package/src/config.test.ts +405 -0
- package/src/hooks-tier1.test.ts +220 -0
- package/src/hooks-tier2.test.ts +275 -0
- package/src/hooks-tier3.test.ts +461 -0
- package/src/hooks.ts +48 -12
- package/src/index.test.ts +190 -0
- package/src/index.ts +12 -2
- package/src/keywords.test.ts +283 -0
- package/src/logger.test.ts +640 -0
- package/src/privacy.test.ts +128 -0
- package/src/tags.test.ts +86 -0
- package/src/tools.test.ts +508 -0
- package/src/tools.ts +34 -0
- package/src/updater.test.ts +380 -0
- package/src/web-server.test.ts +740 -0
- package/src/web-server.ts +8 -2
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
|
|
4
|
+
// ── Hoisted mock functions ───────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
mockExistsSync,
|
|
8
|
+
mockStat,
|
|
9
|
+
mockReadFile,
|
|
10
|
+
mockLogInfo,
|
|
11
|
+
mockLogWarn,
|
|
12
|
+
mockLogError,
|
|
13
|
+
} = vi.hoisted(() => ({
|
|
14
|
+
mockExistsSync: vi.fn(),
|
|
15
|
+
mockStat: vi.fn(),
|
|
16
|
+
mockReadFile: vi.fn(),
|
|
17
|
+
mockLogInfo: vi.fn(),
|
|
18
|
+
mockLogWarn: vi.fn(),
|
|
19
|
+
mockLogError: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// ── Mocks ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
let capturedHandler: (req: IncomingMessage, res: ServerResponse) => void;
|
|
25
|
+
|
|
26
|
+
const mockServerInstance = {
|
|
27
|
+
on: vi.fn(),
|
|
28
|
+
listen: vi.fn(),
|
|
29
|
+
close: vi.fn(),
|
|
30
|
+
unref: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
vi.mock("node:http", () => ({
|
|
34
|
+
createServer: vi.fn().mockImplementation((handler) => {
|
|
35
|
+
capturedHandler = handler;
|
|
36
|
+
return mockServerInstance;
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock("node:fs", () => ({
|
|
41
|
+
existsSync: mockExistsSync,
|
|
42
|
+
stat: mockStat,
|
|
43
|
+
readFile: mockReadFile,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock("./logger.js", () => ({
|
|
47
|
+
logInfo: mockLogInfo,
|
|
48
|
+
logWarn: mockLogWarn,
|
|
49
|
+
logError: mockLogError,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// ── Imports (post-mock) ──────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
import * as http from "node:http";
|
|
55
|
+
import { startWebServer, stopWebServer } from "./web-server.js";
|
|
56
|
+
import type { WebServerHandle } from "./web-server.js";
|
|
57
|
+
|
|
58
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const mockedCreateServer = vi.mocked(http.createServer);
|
|
61
|
+
|
|
62
|
+
function makeReq(url: string, method = "GET"): IncomingMessage {
|
|
63
|
+
return { url, method } as IncomingMessage;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeRes(): ServerResponse & {
|
|
67
|
+
_status: number;
|
|
68
|
+
_headers: Record<string, string>;
|
|
69
|
+
_body: string;
|
|
70
|
+
_ended: boolean;
|
|
71
|
+
} {
|
|
72
|
+
return {
|
|
73
|
+
_status: 0,
|
|
74
|
+
_headers: {},
|
|
75
|
+
_body: "",
|
|
76
|
+
_ended: false,
|
|
77
|
+
writeHead(status: number, headers?: Record<string, string>) {
|
|
78
|
+
this._status = status;
|
|
79
|
+
if (headers) Object.assign(this._headers, headers);
|
|
80
|
+
return this;
|
|
81
|
+
},
|
|
82
|
+
end(data?: unknown) {
|
|
83
|
+
if (data !== undefined) this._body = typeof data === "string" ? data : String(data);
|
|
84
|
+
this._ended = true;
|
|
85
|
+
return this;
|
|
86
|
+
},
|
|
87
|
+
} as any;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetMockServer() {
|
|
91
|
+
mockServerInstance.on.mockReturnThis();
|
|
92
|
+
mockServerInstance.listen.mockImplementation((_p: number, _h: string, cb: () => void) => cb());
|
|
93
|
+
mockServerInstance.close.mockImplementation((cb: () => void) => cb());
|
|
94
|
+
mockServerInstance.unref.mockReturnThis();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultFsMocks() {
|
|
98
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
99
|
+
const s = String(p);
|
|
100
|
+
return s.includes("web") || s.includes("index.html");
|
|
101
|
+
});
|
|
102
|
+
mockStat.mockImplementation((_p: unknown, cb: Function) => {
|
|
103
|
+
cb(null, { isFile: () => true, size: 100 });
|
|
104
|
+
});
|
|
105
|
+
mockReadFile.mockImplementation((_p: unknown, cb: Function) => {
|
|
106
|
+
cb(null, Buffer.from("file content"));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe("web-server", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
vi.clearAllMocks();
|
|
115
|
+
vi.useFakeTimers();
|
|
116
|
+
resetMockServer();
|
|
117
|
+
defaultFsMocks();
|
|
118
|
+
vi.stubGlobal("fetch", vi.fn());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
vi.useRealTimers();
|
|
123
|
+
vi.unstubAllGlobals();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── Server startup ────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe("server startup", () => {
|
|
129
|
+
it("returns null when web directory does not exist", async () => {
|
|
130
|
+
mockExistsSync.mockReturnValue(false);
|
|
131
|
+
|
|
132
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
133
|
+
|
|
134
|
+
expect(handle).toBeNull();
|
|
135
|
+
expect(mockLogWarn).toHaveBeenCalledWith(
|
|
136
|
+
"web-server: web directory not found, skipping",
|
|
137
|
+
expect.objectContaining({ webDir: expect.any(String) }),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns null when index.html does not exist", async () => {
|
|
142
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
143
|
+
const s = String(p);
|
|
144
|
+
return s.includes("web") && !s.includes("index.html");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
148
|
+
|
|
149
|
+
expect(handle).toBeNull();
|
|
150
|
+
expect(mockLogWarn).toHaveBeenCalledWith(
|
|
151
|
+
"web-server: index.html not found, skipping",
|
|
152
|
+
expect.objectContaining({ webDir: expect.any(String) }),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("creates server and returns handle on success", async () => {
|
|
157
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
158
|
+
|
|
159
|
+
expect(handle).not.toBeNull();
|
|
160
|
+
expect(mockedCreateServer).toHaveBeenCalled();
|
|
161
|
+
expect(mockLogInfo).toHaveBeenCalledWith(
|
|
162
|
+
"web-server: server started",
|
|
163
|
+
expect.objectContaining({ port: 5212 }),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await stopWebServer(handle!);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("uses config.port when provided", async () => {
|
|
170
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080", port: 9999 });
|
|
171
|
+
|
|
172
|
+
const addr = handle!.address();
|
|
173
|
+
expect(addr).toEqual({ port: 9999, family: "IPv4", address: "127.0.0.1" });
|
|
174
|
+
|
|
175
|
+
await stopWebServer(handle!);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── HTTP handler ──────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe("HTTP handler", () => {
|
|
182
|
+
async function getHandle(): Promise<WebServerHandle> {
|
|
183
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
184
|
+
expect(handle).not.toBeNull();
|
|
185
|
+
return handle!;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it("returns health check JSON at /health", async () => {
|
|
189
|
+
const handle = await getHandle();
|
|
190
|
+
const req = makeReq("/health");
|
|
191
|
+
const res = makeRes();
|
|
192
|
+
|
|
193
|
+
capturedHandler(req, res);
|
|
194
|
+
|
|
195
|
+
expect(res._status).toBe(200);
|
|
196
|
+
expect(res._headers["Content-Type"]).toBe("application/json");
|
|
197
|
+
expect(res._headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
198
|
+
const body = JSON.parse(res._body);
|
|
199
|
+
expect(body).toEqual({ status: "ok", service: "cerebro", port: 5212 });
|
|
200
|
+
|
|
201
|
+
await stopWebServer(handle);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns health check at /health/ (trailing slash)", async () => {
|
|
205
|
+
const handle = await getHandle();
|
|
206
|
+
const req = makeReq("/health/");
|
|
207
|
+
const res = makeRes();
|
|
208
|
+
|
|
209
|
+
capturedHandler(req, res);
|
|
210
|
+
|
|
211
|
+
expect(res._status).toBe(200);
|
|
212
|
+
expect(JSON.parse(res._body).service).toBe("cerebro");
|
|
213
|
+
|
|
214
|
+
await stopWebServer(handle);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns 405 for POST method", async () => {
|
|
218
|
+
const handle = await getHandle();
|
|
219
|
+
const req = makeReq("/some/path", "POST");
|
|
220
|
+
const res = makeRes();
|
|
221
|
+
|
|
222
|
+
capturedHandler(req, res);
|
|
223
|
+
|
|
224
|
+
expect(res._status).toBe(405);
|
|
225
|
+
expect(res._headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
226
|
+
expect(res._body).toBe("Method Not Allowed");
|
|
227
|
+
|
|
228
|
+
await stopWebServer(handle);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("returns 405 for DELETE method", async () => {
|
|
232
|
+
const handle = await getHandle();
|
|
233
|
+
const req = makeReq("/some/path", "DELETE");
|
|
234
|
+
const res = makeRes();
|
|
235
|
+
|
|
236
|
+
capturedHandler(req, res);
|
|
237
|
+
|
|
238
|
+
expect(res._status).toBe(405);
|
|
239
|
+
expect(res._body).toBe("Method Not Allowed");
|
|
240
|
+
|
|
241
|
+
await stopWebServer(handle);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("returns 405 for PUT method", async () => {
|
|
245
|
+
const handle = await getHandle();
|
|
246
|
+
const req = makeReq("/some/path", "PUT");
|
|
247
|
+
const res = makeRes();
|
|
248
|
+
|
|
249
|
+
capturedHandler(req, res);
|
|
250
|
+
|
|
251
|
+
expect(res._status).toBe(405);
|
|
252
|
+
|
|
253
|
+
await stopWebServer(handle);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("allows HEAD method", async () => {
|
|
257
|
+
const handle = await getHandle();
|
|
258
|
+
const req = makeReq("/style.css", "HEAD");
|
|
259
|
+
const res = makeRes();
|
|
260
|
+
|
|
261
|
+
capturedHandler(req, res);
|
|
262
|
+
|
|
263
|
+
expect(res._status).not.toBe(405);
|
|
264
|
+
|
|
265
|
+
await stopWebServer(handle);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("allows GET method", async () => {
|
|
269
|
+
const handle = await getHandle();
|
|
270
|
+
const req = makeReq("/app.js", "GET");
|
|
271
|
+
const res = makeRes();
|
|
272
|
+
|
|
273
|
+
capturedHandler(req, res);
|
|
274
|
+
|
|
275
|
+
expect(res._status).not.toBe(405);
|
|
276
|
+
|
|
277
|
+
await stopWebServer(handle);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── Static file serving ───────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("static file serving", () => {
|
|
284
|
+
async function getHandle(): Promise<WebServerHandle> {
|
|
285
|
+
return (await startWebServer({ apiUrl: "http://localhost:8080" }))!;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it("serves JS with correct content type", async () => {
|
|
289
|
+
const handle = await getHandle();
|
|
290
|
+
const req = makeReq("/app.js");
|
|
291
|
+
const res = makeRes();
|
|
292
|
+
|
|
293
|
+
capturedHandler(req, res);
|
|
294
|
+
await vi.waitFor(() => res._ended);
|
|
295
|
+
|
|
296
|
+
expect(res._status).toBe(200);
|
|
297
|
+
expect(res._headers["Content-Type"]).toContain("application/javascript");
|
|
298
|
+
|
|
299
|
+
await stopWebServer(handle);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("serves CSS with correct content type", async () => {
|
|
303
|
+
const handle = await getHandle();
|
|
304
|
+
const req = makeReq("/style.css");
|
|
305
|
+
const res = makeRes();
|
|
306
|
+
|
|
307
|
+
capturedHandler(req, res);
|
|
308
|
+
await vi.waitFor(() => res._ended);
|
|
309
|
+
|
|
310
|
+
expect(res._status).toBe(200);
|
|
311
|
+
expect(res._headers["Content-Type"]).toContain("text/css");
|
|
312
|
+
|
|
313
|
+
await stopWebServer(handle);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("serves SVG with correct content type", async () => {
|
|
317
|
+
const handle = await getHandle();
|
|
318
|
+
const req = makeReq("/icon.svg");
|
|
319
|
+
const res = makeRes();
|
|
320
|
+
|
|
321
|
+
capturedHandler(req, res);
|
|
322
|
+
await vi.waitFor(() => res._ended);
|
|
323
|
+
|
|
324
|
+
expect(res._status).toBe(200);
|
|
325
|
+
expect(res._headers["Content-Type"]).toBe("image/svg+xml");
|
|
326
|
+
|
|
327
|
+
await stopWebServer(handle);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("serves JSON with correct content type", async () => {
|
|
331
|
+
const handle = await getHandle();
|
|
332
|
+
const req = makeReq("/data.json");
|
|
333
|
+
const res = makeRes();
|
|
334
|
+
|
|
335
|
+
capturedHandler(req, res);
|
|
336
|
+
await vi.waitFor(() => res._ended);
|
|
337
|
+
|
|
338
|
+
expect(res._status).toBe(200);
|
|
339
|
+
expect(res._headers["Content-Type"]).toContain("application/json");
|
|
340
|
+
|
|
341
|
+
await stopWebServer(handle);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("falls back to application/octet-stream for unknown extensions", async () => {
|
|
345
|
+
const handle = await getHandle();
|
|
346
|
+
const req = makeReq("/data.xyz");
|
|
347
|
+
const res = makeRes();
|
|
348
|
+
|
|
349
|
+
capturedHandler(req, res);
|
|
350
|
+
await vi.waitFor(() => res._ended);
|
|
351
|
+
|
|
352
|
+
expect(res._status).toBe(200);
|
|
353
|
+
expect(res._headers["Content-Type"]).toBe("application/octet-stream");
|
|
354
|
+
|
|
355
|
+
await stopWebServer(handle);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("falls back to index.html for unknown paths (SPA)", async () => {
|
|
359
|
+
const handle = await getHandle();
|
|
360
|
+
const req = makeReq("/unknown-route");
|
|
361
|
+
const res = makeRes();
|
|
362
|
+
|
|
363
|
+
let statCallCount = 0;
|
|
364
|
+
mockStat.mockImplementation((_p: unknown, cb: Function) => {
|
|
365
|
+
statCallCount++;
|
|
366
|
+
if (statCallCount === 1) {
|
|
367
|
+
cb(new Error("ENOENT"), undefined);
|
|
368
|
+
} else {
|
|
369
|
+
cb(null, { isFile: () => true, size: 500 });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
capturedHandler(req, res);
|
|
374
|
+
await vi.waitFor(() => res._ended);
|
|
375
|
+
|
|
376
|
+
expect(res._status).toBe(200);
|
|
377
|
+
|
|
378
|
+
await stopWebServer(handle);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("returns 404 when index.html is missing for SPA fallback", async () => {
|
|
382
|
+
const handle = await getHandle();
|
|
383
|
+
const req = makeReq("/unknown-route");
|
|
384
|
+
const res = makeRes();
|
|
385
|
+
|
|
386
|
+
mockStat.mockImplementation((_p: unknown, cb: Function) => {
|
|
387
|
+
cb(new Error("ENOENT"), undefined);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
capturedHandler(req, res);
|
|
391
|
+
await vi.waitFor(() => res._ended);
|
|
392
|
+
|
|
393
|
+
expect(res._status).toBe(404);
|
|
394
|
+
expect(res._body).toBe("Not Found");
|
|
395
|
+
|
|
396
|
+
await stopWebServer(handle);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── Cache headers ─────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
describe("cache headers", () => {
|
|
403
|
+
async function getHandle(): Promise<WebServerHandle> {
|
|
404
|
+
return (await startWebServer({ apiUrl: "http://localhost:8080" }))!;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
it("sets no-cache for HTML files", async () => {
|
|
408
|
+
const handle = await getHandle();
|
|
409
|
+
const req = makeReq("/index.html");
|
|
410
|
+
const res = makeRes();
|
|
411
|
+
|
|
412
|
+
capturedHandler(req, res);
|
|
413
|
+
await vi.waitFor(() => res._ended);
|
|
414
|
+
|
|
415
|
+
expect(res._headers["Cache-Control"]).toBe("no-cache, no-store, must-revalidate");
|
|
416
|
+
|
|
417
|
+
await stopWebServer(handle);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("sets long cache for JS files", async () => {
|
|
421
|
+
const handle = await getHandle();
|
|
422
|
+
const req = makeReq("/app.js");
|
|
423
|
+
const res = makeRes();
|
|
424
|
+
|
|
425
|
+
capturedHandler(req, res);
|
|
426
|
+
await vi.waitFor(() => res._ended);
|
|
427
|
+
|
|
428
|
+
expect(res._headers["Cache-Control"]).toBe("public, max-age=86400");
|
|
429
|
+
|
|
430
|
+
await stopWebServer(handle);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("sets long cache for CSS files", async () => {
|
|
434
|
+
const handle = await getHandle();
|
|
435
|
+
const req = makeReq("/style.css");
|
|
436
|
+
const res = makeRes();
|
|
437
|
+
|
|
438
|
+
capturedHandler(req, res);
|
|
439
|
+
await vi.waitFor(() => res._ended);
|
|
440
|
+
|
|
441
|
+
expect(res._headers["Cache-Control"]).toBe("public, max-age=86400");
|
|
442
|
+
|
|
443
|
+
await stopWebServer(handle);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ── COMMON_HEADERS (X-Content-Type-Options) ───────────────────────
|
|
448
|
+
|
|
449
|
+
describe("COMMON_HEADERS", () => {
|
|
450
|
+
async function getHandle(): Promise<WebServerHandle> {
|
|
451
|
+
return (await startWebServer({ apiUrl: "http://localhost:8080" }))!;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
it("includes X-Content-Type-Options: nosniff on health endpoint", async () => {
|
|
455
|
+
const handle = await getHandle();
|
|
456
|
+
const res = makeRes();
|
|
457
|
+
|
|
458
|
+
capturedHandler(makeReq("/health"), res);
|
|
459
|
+
|
|
460
|
+
expect(res._headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
461
|
+
|
|
462
|
+
await stopWebServer(handle);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("includes X-Content-Type-Options: nosniff on 405", async () => {
|
|
466
|
+
const handle = await getHandle();
|
|
467
|
+
const res = makeRes();
|
|
468
|
+
|
|
469
|
+
capturedHandler(makeReq("/x", "POST"), res);
|
|
470
|
+
|
|
471
|
+
expect(res._headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
472
|
+
|
|
473
|
+
await stopWebServer(handle);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("includes X-Content-Type-Options: nosniff on 404", async () => {
|
|
477
|
+
const handle = await getHandle();
|
|
478
|
+
const res = makeRes();
|
|
479
|
+
|
|
480
|
+
mockStat.mockImplementation((_p: unknown, cb: Function) => {
|
|
481
|
+
cb(new Error("ENOENT"), undefined);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
capturedHandler(makeReq("/missing-route"), res);
|
|
485
|
+
await vi.waitFor(() => res._ended);
|
|
486
|
+
|
|
487
|
+
expect(res._headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
488
|
+
|
|
489
|
+
await stopWebServer(handle);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ── XSS prevention in HTML ────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
describe("XSS prevention", () => {
|
|
496
|
+
async function getHandle(apiUrl = "http://localhost:8080"): Promise<WebServerHandle> {
|
|
497
|
+
return (await startWebServer({ apiUrl }))!;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
it("replaces __OMEM_API_URL__ with JSON.stringify for safety", async () => {
|
|
501
|
+
const handle = await getHandle();
|
|
502
|
+
const req = makeReq("/index.html");
|
|
503
|
+
const res = makeRes();
|
|
504
|
+
|
|
505
|
+
mockReadFile.mockImplementation((_p: unknown, cb: Function) => {
|
|
506
|
+
cb(null, Buffer.from(
|
|
507
|
+
'<html><script>window.__OMEM_API_URL__ = "__OMEM_API_URL__";</script></html>',
|
|
508
|
+
));
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
capturedHandler(req, res);
|
|
512
|
+
await vi.waitFor(() => res._ended);
|
|
513
|
+
|
|
514
|
+
expect(res._status).toBe(200);
|
|
515
|
+
// Placeholder replaced with JSON.stringify value
|
|
516
|
+
expect(res._body).toContain('"http://localhost:8080"');
|
|
517
|
+
// Raw placeholder should be gone
|
|
518
|
+
expect(res._body).not.toMatch(/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/);
|
|
519
|
+
|
|
520
|
+
await stopWebServer(handle);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("escapes quotes in malicious apiUrl via JSON.stringify", async () => {
|
|
524
|
+
const maliciousUrl = 'http://evil.com"; alert(1) //';
|
|
525
|
+
const handle = await getHandle(maliciousUrl);
|
|
526
|
+
const req = makeReq("/index.html");
|
|
527
|
+
const res = makeRes();
|
|
528
|
+
|
|
529
|
+
mockReadFile.mockImplementation((_p: unknown, cb: Function) => {
|
|
530
|
+
cb(null, Buffer.from(
|
|
531
|
+
'<script>window.__OMEM_API_URL__ = "__OMEM_API_URL__";</script>',
|
|
532
|
+
));
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
capturedHandler(req, res);
|
|
536
|
+
await vi.waitFor(() => res._ended);
|
|
537
|
+
|
|
538
|
+
// JSON.stringify wraps the value in quotes and escapes internal quotes
|
|
539
|
+
expect(res._body).not.toMatch(/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/);
|
|
540
|
+
|
|
541
|
+
await stopWebServer(handle);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("does not modify non-HTML files", async () => {
|
|
545
|
+
const handle = await getHandle();
|
|
546
|
+
const req = makeReq("/data.json");
|
|
547
|
+
const res = makeRes();
|
|
548
|
+
|
|
549
|
+
mockReadFile.mockImplementation((_p: unknown, cb: Function) => {
|
|
550
|
+
cb(null, Buffer.from('{"url": "__OMEM_API_URL__"}'));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
capturedHandler(req, res);
|
|
554
|
+
await vi.waitFor(() => res._ended);
|
|
555
|
+
|
|
556
|
+
// Non-HTML should not be modified
|
|
557
|
+
expect(res._body).toBe('{"url": "__OMEM_API_URL__"}');
|
|
558
|
+
|
|
559
|
+
await stopWebServer(handle);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ── File read error ───────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
describe("file read error", () => {
|
|
566
|
+
async function getHandle(): Promise<WebServerHandle> {
|
|
567
|
+
return (await startWebServer({ apiUrl: "http://localhost:8080" }))!;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
it("returns 500 when file read fails", async () => {
|
|
571
|
+
const handle = await getHandle();
|
|
572
|
+
const req = makeReq("/broken.css");
|
|
573
|
+
const res = makeRes();
|
|
574
|
+
|
|
575
|
+
mockReadFile.mockImplementation((_p: unknown, cb: Function) => {
|
|
576
|
+
cb(new Error("read error"), undefined);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
capturedHandler(req, res);
|
|
580
|
+
await vi.waitFor(() => res._ended);
|
|
581
|
+
|
|
582
|
+
expect(res._status).toBe(500);
|
|
583
|
+
expect(res._body).toBe("Internal Server Error");
|
|
584
|
+
expect(res._headers["X-Content-Type-Options"]).toBe("nosniff");
|
|
585
|
+
|
|
586
|
+
await stopWebServer(handle);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ── Port configuration ────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
describe("port configuration", () => {
|
|
593
|
+
it("uses config.port when provided", async () => {
|
|
594
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080", port: 9999 });
|
|
595
|
+
|
|
596
|
+
const req = makeReq("/health");
|
|
597
|
+
const res = makeRes();
|
|
598
|
+
capturedHandler(req, res);
|
|
599
|
+
|
|
600
|
+
const body = JSON.parse(res._body);
|
|
601
|
+
expect(body.port).toBe(9999);
|
|
602
|
+
|
|
603
|
+
await stopWebServer(handle!);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("uses default port 5212 when no port specified", async () => {
|
|
607
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
608
|
+
|
|
609
|
+
const req = makeReq("/health");
|
|
610
|
+
const res = makeRes();
|
|
611
|
+
capturedHandler(req, res);
|
|
612
|
+
|
|
613
|
+
const body = JSON.parse(res._body);
|
|
614
|
+
expect(body.port).toBe(5212);
|
|
615
|
+
|
|
616
|
+
await stopWebServer(handle!);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// ── WebServerHandle ───────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
describe("WebServerHandle", () => {
|
|
623
|
+
it("address() returns correct info", async () => {
|
|
624
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080", port: 9999 });
|
|
625
|
+
|
|
626
|
+
const addr = handle!.address();
|
|
627
|
+
expect(addr).toEqual({ port: 9999, family: "IPv4", address: "127.0.0.1" });
|
|
628
|
+
|
|
629
|
+
await stopWebServer(handle!);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("isOwner() returns true when server was started by us", async () => {
|
|
633
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
634
|
+
|
|
635
|
+
expect(handle!.isOwner()).toBe(true);
|
|
636
|
+
|
|
637
|
+
await stopWebServer(handle!);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("stop() closes the server", async () => {
|
|
641
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
642
|
+
|
|
643
|
+
await stopWebServer(handle!);
|
|
644
|
+
|
|
645
|
+
expect(mockServerInstance.close).toHaveBeenCalled();
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// ── Takeover mechanism ────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
describe("takeover mechanism", () => {
|
|
652
|
+
it("starts takeover watch when existing server is healthy", async () => {
|
|
653
|
+
const mockedFetch = vi.mocked(fetch);
|
|
654
|
+
mockedFetch.mockResolvedValue({ ok: true, text: () => Promise.resolve("cerebro") } as any);
|
|
655
|
+
|
|
656
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
657
|
+
|
|
658
|
+
expect(mockLogInfo).toHaveBeenCalledWith(
|
|
659
|
+
"web-server: reusing existing server",
|
|
660
|
+
expect.objectContaining({ port: 5212 }),
|
|
661
|
+
);
|
|
662
|
+
// We didn't create the server, so isOwner should be false
|
|
663
|
+
expect(handle!.isOwner()).toBe(false);
|
|
664
|
+
|
|
665
|
+
await stopWebServer(handle!);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("starts takeover when port is busy (EADDRINUSE)", async () => {
|
|
669
|
+
const mockedFetch = vi.mocked(fetch);
|
|
670
|
+
mockedFetch.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
671
|
+
|
|
672
|
+
mockServerInstance.on.mockImplementation((event: string, handler: Function) => {
|
|
673
|
+
if (event === "error") {
|
|
674
|
+
handler(Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" }));
|
|
675
|
+
}
|
|
676
|
+
return mockServerInstance;
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const handle = await startWebServer({ apiUrl: "http://localhost:8080" });
|
|
680
|
+
|
|
681
|
+
expect(mockLogInfo).toHaveBeenCalledWith(
|
|
682
|
+
"web-server: port busy, starting takeover watch",
|
|
683
|
+
expect.objectContaining({ port: 5212 }),
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
await stopWebServer(handle!);
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// ── Path traversal defense ────────────────────────────────────────
|
|
691
|
+
// NOTE: new URL() normalizes ../ segments, so ../../../etc/passwd
|
|
692
|
+
// becomes /etc/passwd which resolves to webDir/etc/passwd — safely
|
|
693
|
+
// inside the web directory. resolveSafe is defense-in-depth.
|
|
694
|
+
|
|
695
|
+
describe("path traversal defense", () => {
|
|
696
|
+
async function getHandle(): Promise<WebServerHandle> {
|
|
697
|
+
return (await startWebServer({ apiUrl: "http://localhost:8080" }))!;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
it("URL normalization prevents ../ traversal (served as safe path)", async () => {
|
|
701
|
+
const handle = await getHandle();
|
|
702
|
+
const req = makeReq("/../../../etc/passwd");
|
|
703
|
+
const res = makeRes();
|
|
704
|
+
|
|
705
|
+
capturedHandler(req, res);
|
|
706
|
+
await vi.waitFor(() => res._ended);
|
|
707
|
+
|
|
708
|
+
// URL normalizes ../ → result is within webDir, served as 200
|
|
709
|
+
expect(res._status).toBe(200);
|
|
710
|
+
|
|
711
|
+
await stopWebServer(handle);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("allows normal file paths", async () => {
|
|
715
|
+
const handle = await getHandle();
|
|
716
|
+
const req = makeReq("/assets/app.js");
|
|
717
|
+
const res = makeRes();
|
|
718
|
+
|
|
719
|
+
capturedHandler(req, res);
|
|
720
|
+
await vi.waitFor(() => res._ended);
|
|
721
|
+
|
|
722
|
+
expect(res._status).toBe(200);
|
|
723
|
+
|
|
724
|
+
await stopWebServer(handle);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("allows root path / (served as index.html)", async () => {
|
|
728
|
+
const handle = await getHandle();
|
|
729
|
+
const req = makeReq("/");
|
|
730
|
+
const res = makeRes();
|
|
731
|
+
|
|
732
|
+
capturedHandler(req, res);
|
|
733
|
+
await vi.waitFor(() => res._ended);
|
|
734
|
+
|
|
735
|
+
expect(res._status).toBe(200);
|
|
736
|
+
|
|
737
|
+
await stopWebServer(handle);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
});
|