226 lines
6.7 KiB
TypeScript
226 lines
6.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
declaredContentLength,
|
|
decodeJsonBytes,
|
|
isEffectivelyEmpty,
|
|
isJsonMediaType,
|
|
probeForbiddenBody,
|
|
readBoundedBytes,
|
|
} from "../../src/adapters/http/bounded-body-reader.ts";
|
|
|
|
function responseWithBody(
|
|
body: ReadableStream<Uint8Array> | null,
|
|
contentLength?: string,
|
|
): Response {
|
|
return new Response(body, {
|
|
headers:
|
|
contentLength === undefined ? undefined : { "content-length": contentLength },
|
|
});
|
|
}
|
|
|
|
describe("bounded body reader", () => {
|
|
it.each([
|
|
[null, false],
|
|
["", false],
|
|
[" application/json ; charset=utf-8 ", true],
|
|
["APPLICATION/PROBLEM+JSON", true],
|
|
["text/json", false],
|
|
["application/jsonp", false],
|
|
] as const)("classifies JSON media type %j", (header, expected) => {
|
|
expect(isJsonMediaType(header)).toBe(expected);
|
|
});
|
|
|
|
it.each([
|
|
[undefined, null],
|
|
["0", 0],
|
|
["12", 12],
|
|
["-1", null],
|
|
["NaN", null],
|
|
["Infinity", null],
|
|
] as const)("parses declared content length %s", (header, expected) => {
|
|
expect(declaredContentLength(responseWithBody(null, header))).toBe(expected);
|
|
});
|
|
|
|
it("rejects an oversized declared body and tolerates cancellation failure", async () => {
|
|
const cancel = vi.fn(async () => {
|
|
throw new Error("already settled");
|
|
});
|
|
const response = {
|
|
headers: new Headers({ "content-length": "9" }),
|
|
body: { cancel },
|
|
} as unknown as Response;
|
|
|
|
await expect(readBoundedBytes(response, 8)).resolves.toEqual({
|
|
ok: false,
|
|
code: "RESPONSE_TOO_LARGE",
|
|
});
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("returns empty bytes when a successful response has no body", async () => {
|
|
const result = await readBoundedBytes(responseWithBody(null), 8);
|
|
expect(result).toEqual({ ok: true, bytes: new Uint8Array(0) });
|
|
});
|
|
|
|
it("joins chunks without retaining empty chunks", async () => {
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(0));
|
|
controller.enqueue(Uint8Array.of(1, 2));
|
|
controller.enqueue(Uint8Array.of(3));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
await expect(readBoundedBytes(responseWithBody(body), 3)).resolves.toEqual({
|
|
ok: true,
|
|
bytes: Uint8Array.of(1, 2, 3),
|
|
});
|
|
});
|
|
|
|
it("cancels streaming input as soon as the accumulated limit is exceeded", async () => {
|
|
const cancel = vi.fn().mockRejectedValue(new Error("cancel failed"));
|
|
const reader = {
|
|
read: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ done: false, value: Uint8Array.of(1, 2) })
|
|
.mockResolvedValueOnce({ done: false, value: Uint8Array.of(3, 4) }),
|
|
cancel,
|
|
releaseLock: vi.fn(),
|
|
};
|
|
const response = {
|
|
headers: new Headers(),
|
|
body: { getReader: () => reader },
|
|
} as unknown as Response;
|
|
|
|
await expect(readBoundedBytes(response, 3)).resolves.toEqual({
|
|
ok: false,
|
|
code: "RESPONSE_TOO_LARGE",
|
|
});
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("maps reader failure and cancellation failure to a stream failure", async () => {
|
|
const reader = {
|
|
read: vi.fn().mockRejectedValue(new Error("stream failed")),
|
|
cancel: vi.fn().mockRejectedValue(new Error("cancel failed")),
|
|
releaseLock: vi.fn(() => {
|
|
throw new Error("already released");
|
|
}),
|
|
};
|
|
const response = {
|
|
headers: new Headers(),
|
|
body: { getReader: () => reader },
|
|
} as unknown as Response;
|
|
|
|
await expect(readBoundedBytes(response, 3)).resolves.toEqual({
|
|
ok: false,
|
|
code: "RESPONSE_STREAM_FAILURE",
|
|
});
|
|
});
|
|
|
|
it("detects a forbidden body from declared length without reading it", async () => {
|
|
const cancel = vi.fn();
|
|
const response = {
|
|
headers: new Headers({ "content-length": "1" }),
|
|
body: { cancel },
|
|
} as unknown as Response;
|
|
|
|
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
|
ok: true,
|
|
present: true,
|
|
});
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("accepts an absent, completed, or zero-byte forbidden body", async () => {
|
|
await expect(probeForbiddenBody(responseWithBody(null))).resolves.toEqual({
|
|
ok: true,
|
|
present: false,
|
|
});
|
|
|
|
for (const next of [
|
|
{ done: true, value: undefined },
|
|
{ done: false, value: new Uint8Array(0) },
|
|
]) {
|
|
const reader = {
|
|
read: vi.fn().mockResolvedValue(next),
|
|
cancel: vi.fn(),
|
|
releaseLock: vi.fn(),
|
|
};
|
|
const response = {
|
|
headers: new Headers(),
|
|
body: { getReader: () => reader },
|
|
} as unknown as Response;
|
|
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
|
ok: true,
|
|
present: false,
|
|
});
|
|
expect(reader.cancel).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("probes only one present byte and cancels the remaining body", async () => {
|
|
const reader = {
|
|
read: vi.fn().mockResolvedValue({ done: false, value: Uint8Array.of(1) }),
|
|
cancel: vi.fn().mockRejectedValue(new Error("cancel failed")),
|
|
releaseLock: vi.fn(),
|
|
};
|
|
const response = {
|
|
headers: new Headers(),
|
|
body: { getReader: () => reader },
|
|
} as unknown as Response;
|
|
|
|
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
|
ok: true,
|
|
present: true,
|
|
});
|
|
expect(reader.read).toHaveBeenCalledOnce();
|
|
expect(reader.cancel).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("maps a forbidden-body probe error even when cleanup also fails", async () => {
|
|
const reader = {
|
|
read: vi.fn().mockRejectedValue(new Error("probe failed")),
|
|
cancel: vi.fn().mockRejectedValue(new Error("cancel failed")),
|
|
releaseLock: vi.fn(() => {
|
|
throw new Error("released");
|
|
}),
|
|
};
|
|
const response = {
|
|
headers: new Headers(),
|
|
body: { getReader: () => reader },
|
|
} as unknown as Response;
|
|
|
|
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
|
ok: false,
|
|
code: "RESPONSE_STREAM_FAILURE",
|
|
});
|
|
});
|
|
|
|
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
|
|
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
|
ok: true,
|
|
value: { ok: true },
|
|
});
|
|
expect(decodeJsonBytes(Uint8Array.of(0xc3, 0x28))).toEqual({
|
|
ok: false,
|
|
code: "UTF8_INVALID",
|
|
});
|
|
expect(decodeJsonBytes(new TextEncoder().encode("{"))).toEqual({
|
|
ok: false,
|
|
code: "JSON_INVALID",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[new Uint8Array(0), true],
|
|
[Uint8Array.of(0x20, 0x09, 0x0a, 0x0d), true],
|
|
[Uint8Array.of(0x20, 0x00), false],
|
|
])("classifies effective emptiness %#", (bytes, expected) => {
|
|
expect(isEffectivelyEmpty(bytes)).toBe(expected);
|
|
});
|
|
});
|