openclaw/src/infra/http-body.test.ts
2026-02-22 18:37:25 +00:00

152 lines
5.3 KiB
TypeScript

import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
import { describe, expect, it } from "vitest";
import { createMockServerResponse } from "../test-utils/mock-http-response.js";
import {
installRequestBodyLimitGuard,
isRequestBodyLimitError,
readJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./http-body.js";
type MockIncomingMessage = IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => MockIncomingMessage;
__unhandledDestroyError?: unknown;
};
async function waitForMicrotaskTurn(): Promise<void> {
await new Promise<void>((resolve) => queueMicrotask(resolve));
}
function createMockRequest(params: {
chunks?: string[];
headers?: Record<string, string>;
emitEnd?: boolean;
}): MockIncomingMessage {
const req = new EventEmitter() as MockIncomingMessage;
req.destroyed = false;
req.headers = params.headers ?? {};
req.destroy = ((error?: Error) => {
req.destroyed = true;
if (error) {
// Simulate Node's async 'error' emission on destroy(err). If no listener is
// present at that time, EventEmitter throws; capture that as "unhandled".
queueMicrotask(() => {
try {
req.emit("error", error);
} catch (err) {
req.__unhandledDestroyError = err;
}
});
}
return req;
}) as MockIncomingMessage["destroy"];
if (params.chunks) {
void Promise.resolve().then(() => {
for (const chunk of params.chunks ?? []) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
if (params.emitEnd !== false) {
req.emit("end");
}
});
}
return req;
}
describe("http body limits", () => {
it("reads body within max bytes", async () => {
const req = createMockRequest({ chunks: ['{"ok":true}'] });
await expect(readRequestBodyWithLimit(req, { maxBytes: 1024 })).resolves.toBe('{"ok":true}');
});
it("rejects oversized body", async () => {
const req = createMockRequest({ chunks: ["x".repeat(512)] });
await expect(readRequestBodyWithLimit(req, { maxBytes: 64 })).rejects.toMatchObject({
message: "PayloadTooLarge",
});
expect(req.__unhandledDestroyError).toBeUndefined();
});
it("returns json parse error when body is invalid", async () => {
const req = createMockRequest({ chunks: ["{bad json"] });
const result = await readJsonBodyWithLimit(req, { maxBytes: 1024, emptyObjectOnEmpty: false });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe("INVALID_JSON");
}
});
it("returns payload-too-large for json body", async () => {
const req = createMockRequest({ chunks: ["x".repeat(1024)] });
const result = await readJsonBodyWithLimit(req, { maxBytes: 10 });
expect(result).toEqual({ ok: false, code: "PAYLOAD_TOO_LARGE", error: "Payload too large" });
});
it("guard rejects oversized declared content-length", () => {
const req = createMockRequest({
headers: { "content-length": "9999" },
emitEnd: false,
});
const res = createMockServerResponse();
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128 });
expect(guard.isTripped()).toBe(true);
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
expect(res.statusCode).toBe(413);
});
it("guard rejects streamed oversized body", async () => {
const req = createMockRequest({ chunks: ["small", "x".repeat(256)], emitEnd: false });
const res = createMockServerResponse();
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128, responseFormat: "text" });
await waitForMicrotaskTurn();
expect(guard.isTripped()).toBe(true);
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
expect(res.statusCode).toBe(413);
expect(res.body).toBe("Payload too large");
expect(req.__unhandledDestroyError).toBeUndefined();
});
it("timeout surfaces typed error when timeoutMs is clamped", async () => {
const req = createMockRequest({ emitEnd: false });
const promise = readRequestBodyWithLimit(req, { maxBytes: 128, timeoutMs: 0 });
await expect(promise).rejects.toSatisfy((error: unknown) =>
isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT"),
);
expect(req.__unhandledDestroyError).toBeUndefined();
});
it("guard clamps invalid maxBytes to one byte", async () => {
const req = createMockRequest({ chunks: ["ab"], emitEnd: false });
const res = createMockServerResponse();
const guard = installRequestBodyLimitGuard(req, res, {
maxBytes: Number.NaN,
responseFormat: "text",
});
await waitForMicrotaskTurn();
expect(guard.isTripped()).toBe(true);
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
expect(res.statusCode).toBe(413);
expect(req.__unhandledDestroyError).toBeUndefined();
});
it("declared oversized content-length does not emit unhandled error", async () => {
const req = createMockRequest({
headers: { "content-length": "9999" },
emitEnd: false,
});
await expect(readRequestBodyWithLimit(req, { maxBytes: 128 })).rejects.toMatchObject({
message: "PayloadTooLarge",
});
// Wait a tick for any async destroy(err) emission.
await waitForMicrotaskTurn();
expect(req.__unhandledDestroyError).toBeUndefined();
});
});