fix(gateway): allow microphone access for same-origin in Permissions-Policy header (#68368)

* test(gateway): add full unit coverage for http-common.ts

Adds tests exercising every export in src/gateway/http-common.ts so the module reaches 100% line, branch, function and statement coverage (33 tests). Captures current default security headers (including the existing Permissions-Policy microphone=() deny-list) and exhaustively covers sendJson/sendText/sendMethodNotAllowed/sendUnauthorized/sendRateLimited (with and without Retry-After), sendGatewayAuthFailure (both branches), sendInvalidRequest, readJsonBodyOrError (413/408/400/success), writeDone, setSseHeaders (with and without flushHeaders) and watchClientDisconnect (empty/single/dedup/distinct sockets, abort logic and listener cleanup).

* fix(gateway): allow microphone access for same-origin in Permissions-Policy header

The gateway's default security headers set Permissions-Policy to microphone=(), which denies microphone access for every origin including the page itself. As a result, the control-ui chat mic button (ui/src/ui/chat/speech.ts) cannot start SpeechRecognition: the browser refuses with 'Permissions policy violation: microphone is not allowed in this document' and the button silently resets.

Relax microphone to the same-origin allowlist (self) so the dashboard page can use the Web Speech API while still blocking third-party frames. Camera and geolocation remain fully denied.

Fixes #51085

* test(gateway): add seeded property/fuzz tests for http-common.ts

Adds src/gateway/http-common.fuzz.test.ts with 13 property-style tests (200 iterations each) driven by an in-file deterministic mulberry32 PRNG. Covers every export with invariants rather than fixed examples: baseline security headers across all opts shapes, Strict-Transport-Security iff non-empty string, sendJson/sendText status + body round-trips across random codes and payloads, sendMethodNotAllowed with random Allow values, sendRateLimited Retry-After iff retryAfterMs>0 with ceil-seconds value (including fractional ms), sendGatewayAuthFailure delegation, sendInvalidRequest message echo, readJsonBodyOrError status/body mapping across random error texts, writeDone sentinel, setSseHeaders with/without flushHeaders, and watchClientDisconnect invariants across arbitrary socket/controller/callback combinations (empty, same, distinct, pre-aborted). Deterministic seeds keep failures reproducible without introducing a new dev dependency.
This commit is contained in:
Viz
2026-04-17 23:03:49 -04:00
committed by GitHub
parent a50ec27d3b
commit dee99f27d1
3 changed files with 770 additions and 5 deletions

View File

@@ -0,0 +1,451 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayAuthResult } from "./auth.js";
import {
readJsonBodyOrError,
sendGatewayAuthFailure,
sendInvalidRequest,
sendJson,
sendMethodNotAllowed,
sendRateLimited,
sendText,
sendUnauthorized,
setDefaultSecurityHeaders,
setSseHeaders,
watchClientDisconnect,
writeDone,
} from "./http-common.js";
import { makeMockHttpResponse } from "./test-http-response.js";
/**
* Seeded property-based / fuzz coverage for http-common.
*
* The repo does not pull in fast-check, so this file ships a small,
* deterministic PRNG (mulberry32) + generators. Every property runs
* N iterations; any failure prints the seed-derived inputs so failures
* are reproducible.
*/
const readJsonBodyMock = vi.hoisted(() => vi.fn());
vi.mock("./hooks.js", () => ({
readJsonBody: readJsonBodyMock,
}));
beforeEach(() => {
readJsonBodyMock.mockReset();
});
/** Deterministic 32-bit PRNG. */
function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number {
return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive;
}
function randString(rng: () => number, maxLen = 48): string {
const len = randInt(rng, 0, maxLen);
let out = "";
for (let i = 0; i < len; i += 1) {
// Mix ASCII printables, whitespace, and a few higher codepoints.
const bucket = rng();
if (bucket < 0.7) {
out += String.fromCharCode(randInt(rng, 0x20, 0x7e));
} else if (bucket < 0.85) {
out += " \t\n\r"[randInt(rng, 0, 3)];
} else {
out += String.fromCharCode(randInt(rng, 0xa0, 0x2fff));
}
}
return out;
}
function randBody(rng: () => number): unknown {
const kind = randInt(rng, 0, 5);
if (kind === 0) {
return null;
}
if (kind === 1) {
return randString(rng, 32);
}
if (kind === 2) {
return randInt(rng, -1_000_000, 1_000_000);
}
if (kind === 3) {
return rng() < 0.5;
}
if (kind === 4) {
const n = randInt(rng, 0, 4);
const arr: unknown[] = [];
for (let i = 0; i < n; i += 1) {
arr.push(randInt(rng, 0, 100));
}
return arr;
}
return { a: randString(rng, 12), b: randInt(rng, 0, 1000), c: rng() < 0.5 };
}
const ITERATIONS = 200;
describe("fuzz: setDefaultSecurityHeaders", () => {
it("always emits the three baseline headers regardless of opts", () => {
const rng = makeRng(0xa11ce);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader } = makeMockHttpResponse();
const shape = randInt(rng, 0, 3);
if (shape === 0) {
setDefaultSecurityHeaders(res);
} else if (shape === 1) {
setDefaultSecurityHeaders(res, undefined);
} else if (shape === 2) {
setDefaultSecurityHeaders(res, {});
} else {
setDefaultSecurityHeaders(res, { strictTransportSecurity: randString(rng) });
}
expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff");
expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer");
expect(setHeader).toHaveBeenCalledWith(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=()",
);
}
});
it("sets Strict-Transport-Security iff opts.strictTransportSecurity is a non-empty string", () => {
const rng = makeRng(0xb0b);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader } = makeMockHttpResponse();
const value = randString(rng);
setDefaultSecurityHeaders(res, { strictTransportSecurity: value });
const stsCalls = setHeader.mock.calls.filter(
(call) => call[0] === "Strict-Transport-Security",
);
if (value.length > 0) {
expect(stsCalls).toHaveLength(1);
expect(stsCalls[0]?.[1]).toBe(value);
} else {
expect(stsCalls).toHaveLength(0);
}
}
});
});
describe("fuzz: sendJson", () => {
it("propagates status, sets JSON content type, and serializes the body", () => {
const rng = makeRng(0xdecaf);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader, end } = makeMockHttpResponse();
const status = randInt(rng, 100, 599);
const body = randBody(rng);
sendJson(res, status, body);
expect(res.statusCode).toBe(status);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8");
expect(end).toHaveBeenCalledWith(JSON.stringify(body));
}
});
});
describe("fuzz: sendText", () => {
it("propagates status, sets plain-text content type, and forwards the body", () => {
const rng = makeRng(0xfeed);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader, end } = makeMockHttpResponse();
const status = randInt(rng, 100, 599);
const body = randString(rng, 64);
sendText(res, status, body);
expect(res.statusCode).toBe(status);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
expect(end).toHaveBeenCalledWith(body);
}
});
});
describe("fuzz: sendMethodNotAllowed", () => {
it("always responds 405 with the supplied Allow header (or POST when omitted)", () => {
const rng = makeRng(0x405);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader, end } = makeMockHttpResponse();
const useDefault = rng() < 0.3;
const allow = useDefault ? undefined : randString(rng, 24);
if (allow === undefined) {
sendMethodNotAllowed(res);
expect(setHeader).toHaveBeenCalledWith("Allow", "POST");
} else {
sendMethodNotAllowed(res, allow);
expect(setHeader).toHaveBeenCalledWith("Allow", allow);
}
expect(res.statusCode).toBe(405);
expect(end).toHaveBeenCalledWith("Method Not Allowed");
}
});
});
describe("fuzz: sendUnauthorized", () => {
it("is deterministic: always 401 with the canonical error payload", () => {
const expected = JSON.stringify({
error: { message: "Unauthorized", type: "unauthorized" },
});
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, end } = makeMockHttpResponse();
sendUnauthorized(res);
expect(res.statusCode).toBe(401);
expect(end).toHaveBeenCalledWith(expected);
}
});
});
describe("fuzz: sendRateLimited", () => {
it("sets Retry-After iff retryAfterMs is truthy and > 0, with ceil-seconds value", () => {
const rng = makeRng(0x429);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader } = makeMockHttpResponse();
const pick = randInt(rng, 0, 4);
let retryAfterMs: number | undefined;
if (pick === 0) {
retryAfterMs = undefined;
} else if (pick === 1) {
retryAfterMs = 0;
} else if (pick === 2) {
retryAfterMs = -randInt(rng, 1, 100_000);
} else if (pick === 3) {
retryAfterMs = randInt(rng, 1, 3_600_000);
} else {
// Fractional positive values exercise Math.ceil.
retryAfterMs = rng() * 5000 + 0.001;
}
sendRateLimited(res, retryAfterMs);
expect(res.statusCode).toBe(429);
const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After");
if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
expect(retryCalls).toHaveLength(1);
expect(retryCalls[0]?.[1]).toBe(String(Math.ceil(retryAfterMs / 1000)));
} else {
expect(retryCalls).toHaveLength(0);
}
}
});
});
describe("fuzz: sendGatewayAuthFailure", () => {
it("delegates to rate-limited vs unauthorized based on authResult.rateLimited", () => {
const rng = makeRng(0xba5e);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader, end } = makeMockHttpResponse();
const rateLimited = rng() < 0.5;
const retryAfterMs = rateLimited && rng() < 0.7 ? randInt(rng, 1, 120_000) : undefined;
const authResult = { ok: false, rateLimited, retryAfterMs } as GatewayAuthResult;
sendGatewayAuthFailure(res, authResult);
if (rateLimited) {
expect(res.statusCode).toBe(429);
const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After");
if (typeof retryAfterMs === "number" && retryAfterMs > 0) {
expect(retryCalls).toHaveLength(1);
} else {
expect(retryCalls).toHaveLength(0);
}
} else {
expect(res.statusCode).toBe(401);
expect(end).toHaveBeenCalledWith(
JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }),
);
}
}
});
});
describe("fuzz: sendInvalidRequest", () => {
it("always responds 400 with the supplied message echoed into the payload", () => {
const rng = makeRng(0xbad);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, end } = makeMockHttpResponse();
const message = randString(rng, 64);
sendInvalidRequest(res, message);
expect(res.statusCode).toBe(400);
expect(end).toHaveBeenCalledWith(
JSON.stringify({ error: { message, type: "invalid_request_error" } }),
);
}
});
});
describe("fuzz: readJsonBodyOrError", () => {
const makeRequest = () => ({}) as IncomingMessage;
it("maps readJsonBody results to the documented status/body contract", async () => {
const rng = makeRng(0xc0de);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, end } = makeMockHttpResponse();
const pick = randInt(rng, 0, 3);
let expectedStatus: number | undefined;
let expectedBody: string | undefined;
let expectedValue: unknown;
if (pick === 0) {
const value = randBody(rng);
expectedValue = value;
readJsonBodyMock.mockResolvedValueOnce({ ok: true, value });
} else if (pick === 1) {
expectedStatus = 413;
expectedBody = JSON.stringify({
error: { message: "Payload too large", type: "invalid_request_error" },
});
readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" });
} else if (pick === 2) {
expectedStatus = 408;
expectedBody = JSON.stringify({
error: { message: "Request body timeout", type: "invalid_request_error" },
});
readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" });
} else {
// Arbitrary error text must neither collide with the 413/408 sentinels
// nor accidentally reuse them; pick a prefix that can never match.
const text = `err-${randString(rng, 24)}`;
expectedStatus = 400;
expectedBody = JSON.stringify({
error: { message: text, type: "invalid_request_error" },
});
readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: text });
}
const maxBytes = randInt(rng, 1, 1 << 20);
const result = await readJsonBodyOrError(makeRequest(), res, maxBytes);
if (pick === 0) {
expect(result).toEqual(expectedValue);
} else {
expect(result).toBeUndefined();
expect(res.statusCode).toBe(expectedStatus);
expect(end).toHaveBeenCalledWith(expectedBody);
}
expect(readJsonBodyMock).toHaveBeenLastCalledWith(expect.anything(), maxBytes);
}
});
});
describe("fuzz: writeDone", () => {
it("always writes the DONE sentinel exactly once per call", () => {
for (let i = 0; i < ITERATIONS; i += 1) {
const { res } = makeMockHttpResponse();
const write = vi.spyOn(res, "write");
writeDone(res);
expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledWith("data: [DONE]\n\n");
}
});
});
describe("fuzz: setSseHeaders", () => {
it("sets SSE headers and invokes flushHeaders when present", () => {
const rng = makeRng(0x55e);
for (let i = 0; i < ITERATIONS; i += 1) {
const { res, setHeader } = makeMockHttpResponse();
const hasFlush = rng() < 0.5;
const flushHeaders = vi.fn();
if (hasFlush) {
(res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders;
}
setSseHeaders(res);
expect(res.statusCode).toBe(200);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8");
expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache");
expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive");
if (hasFlush) {
expect(flushHeaders).toHaveBeenCalledTimes(1);
} else {
expect(flushHeaders).not.toHaveBeenCalled();
}
}
});
});
describe("fuzz: watchClientDisconnect", () => {
function buildReqRes(
reqSocket: EventEmitter | null,
resSocket: EventEmitter | null,
): { req: IncomingMessage; res: ServerResponse } {
return {
req: { socket: reqSocket } as unknown as IncomingMessage,
res: { socket: resSocket } as unknown as ServerResponse,
};
}
it("invariants hold for arbitrary socket/controller/callback combinations", () => {
const rng = makeRng(0xc105e);
for (let i = 0; i < ITERATIONS; i += 1) {
const shape = randInt(rng, 0, 3);
const same = rng() < 0.4;
let reqSocket: EventEmitter | null = null;
let resSocket: EventEmitter | null = null;
if (shape === 0) {
// both null
} else if (shape === 1) {
reqSocket = new EventEmitter();
} else if (shape === 2) {
resSocket = new EventEmitter();
} else if (same) {
reqSocket = new EventEmitter();
resSocket = reqSocket;
} else {
reqSocket = new EventEmitter();
resSocket = new EventEmitter();
}
const preAborted = rng() < 0.25;
const hasCallback = rng() < 0.5;
const controller = new AbortController();
if (preAborted) {
controller.abort();
}
const onDisconnect = hasCallback ? vi.fn() : undefined;
const { req, res } = buildReqRes(reqSocket, resSocket);
const cleanup = watchClientDisconnect(req, res, controller, onDisconnect);
expect(typeof cleanup).toBe("function");
const uniqueSockets = new Set<EventEmitter>();
if (reqSocket) {
uniqueSockets.add(reqSocket);
}
if (resSocket) {
uniqueSockets.add(resSocket);
}
// Each unique socket should have exactly one "close" listener registered
// (or zero when there are no sockets at all).
for (const s of uniqueSockets) {
expect(s.listenerCount("close")).toBe(1);
}
// Fire close on every unique socket; invariants: callback fires once per
// close, controller becomes aborted (regardless of whether it started so).
let expectedCallbackCalls = 0;
for (const s of uniqueSockets) {
s.emit("close");
expectedCallbackCalls += 1;
}
if (uniqueSockets.size > 0) {
expect(controller.signal.aborted).toBe(true);
if (onDisconnect) {
expect(onDisconnect).toHaveBeenCalledTimes(expectedCallbackCalls);
}
} else {
expect(controller.signal.aborted).toBe(preAborted);
}
// Cleanup removes all registered listeners.
cleanup();
for (const s of uniqueSockets) {
expect(s.listenerCount("close")).toBe(0);
}
}
});
});

View File

@@ -1,7 +1,33 @@
import { describe, expect, it } from "vitest";
import { setDefaultSecurityHeaders } from "./http-common.js";
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayAuthResult } from "./auth.js";
import {
readJsonBodyOrError,
sendGatewayAuthFailure,
sendInvalidRequest,
sendJson,
sendMethodNotAllowed,
sendRateLimited,
sendText,
sendUnauthorized,
setDefaultSecurityHeaders,
setSseHeaders,
watchClientDisconnect,
writeDone,
} from "./http-common.js";
import { makeMockHttpResponse } from "./test-http-response.js";
const readJsonBodyMock = vi.hoisted(() => vi.fn());
vi.mock("./hooks.js", () => ({
readJsonBody: readJsonBodyMock,
}));
beforeEach(() => {
readJsonBodyMock.mockReset();
});
describe("setDefaultSecurityHeaders", () => {
it("sets X-Content-Type-Options", () => {
const { res, setHeader } = makeMockHttpResponse();
@@ -15,12 +41,12 @@ describe("setDefaultSecurityHeaders", () => {
expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer");
});
it("sets Permissions-Policy", () => {
it("sets Permissions-Policy that allows microphone for same-origin", () => {
const { res, setHeader } = makeMockHttpResponse();
setDefaultSecurityHeaders(res);
expect(setHeader).toHaveBeenCalledWith(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
"camera=(), microphone=(self), geolocation=()",
);
});
@@ -46,4 +72,292 @@ describe("setDefaultSecurityHeaders", () => {
setDefaultSecurityHeaders(res, { strictTransportSecurity: "" });
expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything());
});
it("does not set Strict-Transport-Security when opts is omitted", () => {
const { res, setHeader } = makeMockHttpResponse();
setDefaultSecurityHeaders(res, undefined);
expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything());
});
});
describe("sendJson", () => {
it("sets status, content-type and writes JSON body", () => {
const { res, setHeader, end } = makeMockHttpResponse();
sendJson(res, 201, { ok: true });
expect(res.statusCode).toBe(201);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8");
expect(end).toHaveBeenCalledWith(JSON.stringify({ ok: true }));
});
});
describe("sendText", () => {
it("sets status, content-type and writes plain-text body", () => {
const { res, setHeader, end } = makeMockHttpResponse();
sendText(res, 202, "hello");
expect(res.statusCode).toBe(202);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
expect(end).toHaveBeenCalledWith("hello");
});
});
describe("sendMethodNotAllowed", () => {
it("defaults the Allow header to POST and responds 405", () => {
const { res, setHeader, end } = makeMockHttpResponse();
sendMethodNotAllowed(res);
expect(setHeader).toHaveBeenCalledWith("Allow", "POST");
expect(res.statusCode).toBe(405);
expect(end).toHaveBeenCalledWith("Method Not Allowed");
});
it("honours a custom Allow header value", () => {
const { res, setHeader } = makeMockHttpResponse();
sendMethodNotAllowed(res, "GET, POST");
expect(setHeader).toHaveBeenCalledWith("Allow", "GET, POST");
});
});
describe("sendUnauthorized", () => {
it("responds with 401 and a structured unauthorized payload", () => {
const { res, end } = makeMockHttpResponse();
sendUnauthorized(res);
expect(res.statusCode).toBe(401);
expect(end).toHaveBeenCalledWith(
JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }),
);
});
});
describe("sendRateLimited", () => {
it("responds with 429 and no Retry-After when retryAfterMs is omitted", () => {
const { res, setHeader, end } = makeMockHttpResponse();
sendRateLimited(res);
expect(res.statusCode).toBe(429);
expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything());
expect(end).toHaveBeenCalledWith(
JSON.stringify({
error: {
message: "Too many failed authentication attempts. Please try again later.",
type: "rate_limited",
},
}),
);
});
it("responds with 429 and no Retry-After when retryAfterMs is zero", () => {
const { res, setHeader } = makeMockHttpResponse();
sendRateLimited(res, 0);
expect(res.statusCode).toBe(429);
expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything());
});
it("responds with 429 and no Retry-After when retryAfterMs is negative", () => {
const { res, setHeader } = makeMockHttpResponse();
sendRateLimited(res, -500);
expect(res.statusCode).toBe(429);
expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything());
});
it("sets Retry-After (seconds, ceiled) when retryAfterMs is positive", () => {
const { res, setHeader } = makeMockHttpResponse();
sendRateLimited(res, 1500);
expect(res.statusCode).toBe(429);
expect(setHeader).toHaveBeenCalledWith("Retry-After", "2");
});
});
describe("sendGatewayAuthFailure", () => {
it("delegates to sendRateLimited when the auth result is rate limited", () => {
const { res, setHeader, end } = makeMockHttpResponse();
const authResult = { ok: false, rateLimited: true, retryAfterMs: 3000 } as GatewayAuthResult;
sendGatewayAuthFailure(res, authResult);
expect(res.statusCode).toBe(429);
expect(setHeader).toHaveBeenCalledWith("Retry-After", "3");
expect(end).toHaveBeenCalledTimes(1);
});
it("delegates to sendUnauthorized when the auth result is not rate limited", () => {
const { res, end } = makeMockHttpResponse();
const authResult = { ok: false, rateLimited: false } as GatewayAuthResult;
sendGatewayAuthFailure(res, authResult);
expect(res.statusCode).toBe(401);
expect(end).toHaveBeenCalledWith(
JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }),
);
});
});
describe("sendInvalidRequest", () => {
it("responds with 400 and includes the supplied message", () => {
const { res, end } = makeMockHttpResponse();
sendInvalidRequest(res, "bad input");
expect(res.statusCode).toBe(400);
expect(end).toHaveBeenCalledWith(
JSON.stringify({ error: { message: "bad input", type: "invalid_request_error" } }),
);
});
});
describe("readJsonBodyOrError", () => {
const makeRequest = () => ({}) as IncomingMessage;
it("returns the parsed body on success", async () => {
readJsonBodyMock.mockResolvedValueOnce({ ok: true, value: { hello: "world" } });
const { res } = makeMockHttpResponse();
const result = await readJsonBodyOrError(makeRequest(), res, 1024);
expect(result).toEqual({ hello: "world" });
expect(readJsonBodyMock).toHaveBeenCalledWith(expect.anything(), 1024);
});
it("responds with 413 when the body is too large", async () => {
readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" });
const { res, end } = makeMockHttpResponse();
const result = await readJsonBodyOrError(makeRequest(), res, 1024);
expect(result).toBeUndefined();
expect(res.statusCode).toBe(413);
expect(end).toHaveBeenCalledWith(
JSON.stringify({
error: { message: "Payload too large", type: "invalid_request_error" },
}),
);
});
it("responds with 408 when the request body times out", async () => {
readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" });
const { res, end } = makeMockHttpResponse();
const result = await readJsonBodyOrError(makeRequest(), res, 1024);
expect(result).toBeUndefined();
expect(res.statusCode).toBe(408);
expect(end).toHaveBeenCalledWith(
JSON.stringify({
error: { message: "Request body timeout", type: "invalid_request_error" },
}),
);
});
it("responds with 400 for other parse failures", async () => {
readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "bad json" });
const { res, end } = makeMockHttpResponse();
const result = await readJsonBodyOrError(makeRequest(), res, 1024);
expect(result).toBeUndefined();
expect(res.statusCode).toBe(400);
expect(end).toHaveBeenCalledWith(
JSON.stringify({ error: { message: "bad json", type: "invalid_request_error" } }),
);
});
});
describe("writeDone", () => {
it("writes the SSE termination sentinel to the response stream", () => {
const { res } = makeMockHttpResponse();
const write = vi.spyOn(res, "write");
writeDone(res);
expect(write).toHaveBeenCalledWith("data: [DONE]\n\n");
});
});
describe("setSseHeaders", () => {
it("sets the SSE headers and calls flushHeaders when present", () => {
const { res, setHeader } = makeMockHttpResponse();
const flushHeaders = vi.fn();
(res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders;
setSseHeaders(res);
expect(res.statusCode).toBe(200);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8");
expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache");
expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive");
expect(flushHeaders).toHaveBeenCalledTimes(1);
});
it("skips flushHeaders gracefully when the response does not expose one", () => {
const { res, setHeader } = makeMockHttpResponse();
// Ensure flushHeaders is not defined on the mock response.
expect((res as unknown as { flushHeaders?: () => void }).flushHeaders).toBeUndefined();
expect(() => setSseHeaders(res)).not.toThrow();
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8");
});
});
describe("watchClientDisconnect", () => {
function buildReqRes(
reqSocket: EventEmitter | null,
resSocket: EventEmitter | null,
): { req: IncomingMessage; res: ServerResponse } {
return {
req: { socket: reqSocket } as unknown as IncomingMessage,
res: { socket: resSocket } as unknown as ServerResponse,
};
}
it("returns a no-op cleanup when no sockets are available", () => {
const { req, res } = buildReqRes(null, null);
const controller = new AbortController();
const cleanup = watchClientDisconnect(req, res, controller);
expect(typeof cleanup).toBe("function");
expect(() => cleanup()).not.toThrow();
expect(controller.signal.aborted).toBe(false);
});
it("aborts the controller and calls onDisconnect when a socket closes", () => {
const socket = new EventEmitter();
const { req, res } = buildReqRes(socket, socket);
const controller = new AbortController();
const onDisconnect = vi.fn();
watchClientDisconnect(req, res, controller, onDisconnect);
socket.emit("close");
expect(onDisconnect).toHaveBeenCalledTimes(1);
expect(controller.signal.aborted).toBe(true);
});
it("does not double-abort when the controller is already aborted", () => {
const socket = new EventEmitter();
const { req, res } = buildReqRes(socket, null);
const controller = new AbortController();
controller.abort();
const abortSpy = vi.spyOn(controller, "abort");
const onDisconnect = vi.fn();
watchClientDisconnect(req, res, controller, onDisconnect);
socket.emit("close");
expect(onDisconnect).toHaveBeenCalledTimes(1);
expect(abortSpy).not.toHaveBeenCalled();
});
it("works without an onDisconnect callback", () => {
const socket = new EventEmitter();
const { req, res } = buildReqRes(null, socket);
const controller = new AbortController();
watchClientDisconnect(req, res, controller);
socket.emit("close");
expect(controller.signal.aborted).toBe(true);
});
it("deduplicates identical request and response sockets", () => {
const socket = new EventEmitter();
const onSpy = vi.spyOn(socket, "on");
const { req, res } = buildReqRes(socket, socket);
const controller = new AbortController();
watchClientDisconnect(req, res, controller);
expect(onSpy).toHaveBeenCalledTimes(1);
});
it("registers handlers on distinct request and response sockets", () => {
const reqSocket = new EventEmitter();
const resSocket = new EventEmitter();
const reqOn = vi.spyOn(reqSocket, "on");
const resOn = vi.spyOn(resSocket, "on");
const { req, res } = buildReqRes(reqSocket, resSocket);
const controller = new AbortController();
watchClientDisconnect(req, res, controller);
expect(reqOn).toHaveBeenCalledWith("close", expect.any(Function));
expect(resOn).toHaveBeenCalledWith("close", expect.any(Function));
});
it("cleanup detaches the close listener from each socket", () => {
const socket = new EventEmitter();
const { req, res } = buildReqRes(socket, null);
const controller = new AbortController();
const cleanup = watchClientDisconnect(req, res, controller);
expect(socket.listenerCount("close")).toBe(1);
cleanup();
expect(socket.listenerCount("close")).toBe(0);
});
});

View File

@@ -14,7 +14,7 @@ export function setDefaultSecurityHeaders(
) {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
res.setHeader("Permissions-Policy", "camera=(), microphone=(self), geolocation=()");
const strictTransportSecurity = opts?.strictTransportSecurity;
if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) {
res.setHeader("Strict-Transport-Security", strictTransportSecurity);