import type { IncomingMessage, ServerResponse } from "node:http"; import { beforeEach, describe, expect, test, vi } from "vitest"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { HooksConfigResolved } from "./hooks.js"; const { readJsonBodyMock } = vi.hoisted(() => ({ readJsonBodyMock: vi.fn(), })); vi.mock("./hooks.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readJsonBody: readJsonBodyMock, }; }); import { createHooksRequestHandler } from "./server-http.js"; type HooksHandlerDeps = Parameters[0]; function createHooksConfig(): HooksConfigResolved { return { basePath: "/hooks", token: "hook-secret", maxBodyBytes: 1024, mappings: [], agentPolicy: { defaultAgentId: "main", knownAgentIds: new Set(["main"]), allowedAgentIds: undefined, }, sessionPolicy: { allowRequestSessionKey: false, defaultSessionKey: undefined, allowedSessionKeyPrefixes: undefined, }, }; } function createRequest(params?: { authorization?: string; remoteAddress?: string; url?: string; }): IncomingMessage { return { method: "POST", url: params?.url ?? "/hooks/wake", headers: { host: "127.0.0.1:18789", authorization: params?.authorization ?? "Bearer hook-secret", }, socket: { remoteAddress: params?.remoteAddress ?? "127.0.0.1" }, } as IncomingMessage; } function createResponse(): { res: ServerResponse; end: ReturnType; setHeader: ReturnType; } { const setHeader = vi.fn(); const end = vi.fn(); const res = { statusCode: 200, setHeader, end, } as unknown as ServerResponse; return { res, end, setHeader }; } function createHandler(params?: { dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"]; dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"]; bindHost?: string; }) { return createHooksRequestHandler({ getHooksConfig: () => createHooksConfig(), bindHost: params?.bindHost ?? "127.0.0.1", port: 18789, logHooks: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn(), } as unknown as ReturnType, dispatchWakeHook: params?.dispatchWakeHook ?? ((() => { return; }) as HooksHandlerDeps["dispatchWakeHook"]), dispatchAgentHook: params?.dispatchAgentHook ?? ((() => "run-1") as HooksHandlerDeps["dispatchAgentHook"]), }); } describe("createHooksRequestHandler timeout status mapping", () => { beforeEach(() => { readJsonBodyMock.mockClear(); }); test("returns 408 for request body timeout", async () => { readJsonBodyMock.mockResolvedValue({ ok: false, error: "request body timeout" }); const dispatchWakeHook = vi.fn(); const dispatchAgentHook = vi.fn(() => "run-1"); const handler = createHandler({ dispatchWakeHook, dispatchAgentHook }); const req = createRequest(); const { res, end } = createResponse(); const handled = await handler(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(408); expect(end).toHaveBeenCalledWith(JSON.stringify({ ok: false, error: "request body timeout" })); expect(dispatchWakeHook).not.toHaveBeenCalled(); expect(dispatchAgentHook).not.toHaveBeenCalled(); }); test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => { const handler = createHandler(); for (let i = 0; i < 20; i++) { const req = createRequest({ authorization: "Bearer wrong", remoteAddress: "1.2.3.4", }); const { res } = createResponse(); const handled = await handler(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(401); } const mappedReq = createRequest({ authorization: "Bearer wrong", remoteAddress: "::ffff:1.2.3.4", }); const { res: mappedRes, setHeader } = createResponse(); const handled = await handler(mappedReq, mappedRes); expect(handled).toBe(true); expect(mappedRes.statusCode).toBe(429); expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); }); test.each(["0.0.0.0", "::"])( "does not throw when bindHost=%s while parsing non-hook request URL", async (bindHost) => { const handler = createHandler({ bindHost }); const req = createRequest({ url: "/" }); const { res, end } = createResponse(); const handled = await handler(req, res); expect(handled).toBe(false); expect(end).not.toHaveBeenCalled(); }, ); });