152 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|