mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
24d3dfcf00
* fix(pages): harden cookie parsing * fix(pages): preserve cookie object compatibility * fix(shims): distinguish edge cookie parser * refactor(cookies): dedupe header parsing
745 lines
24 KiB
TypeScript
745 lines
24 KiB
TypeScript
/**
|
|
* Tests for Pages Router `api.bodyParser` config support.
|
|
*
|
|
* Covers F-PROD-4: vinext previously ignored
|
|
* `export const config = { api: { bodyParser: ... } }` and silently parsed
|
|
* the body with a hard-coded 1 MB limit, breaking webhook handlers (Stripe,
|
|
* GitHub, Slack) that need the raw stream for HMAC verification.
|
|
*
|
|
* Exercises both code paths:
|
|
* - Node/dev: packages/vinext/src/server/api-handler.ts
|
|
* - Workers: packages/vinext/src/server/pages-api-route.ts +
|
|
* packages/vinext/src/server/pages-node-compat.ts
|
|
*
|
|
* Next.js reference:
|
|
* packages/next/src/server/api-utils/node/api-resolver.ts (lines ~350-385)
|
|
* honours `config.api?.bodyParser !== false` and
|
|
* `config.api?.bodyParser?.sizeLimit`.
|
|
*/
|
|
import { PassThrough, Transform } from "node:stream";
|
|
import { Buffer } from "node:buffer";
|
|
import http from "node:http";
|
|
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import { handleApiRoute } from "../packages/vinext/src/server/api-handler.js";
|
|
import {
|
|
handlePagesApiRoute,
|
|
type PagesApiRouteMatch,
|
|
} from "../packages/vinext/src/server/pages-api-route.js";
|
|
import {
|
|
parseSizeLimit,
|
|
resolveBodyParserConfig,
|
|
} from "../packages/vinext/src/server/pages-body-parser-config.js";
|
|
import type {
|
|
PagesReqResRequest,
|
|
PagesReqResResponse,
|
|
} from "../packages/vinext/src/server/pages-node-compat.js";
|
|
import type { ModuleImporter } from "../packages/vinext/src/server/instrumentation.js";
|
|
import type { Route } from "../packages/vinext/src/routing/pages-router.js";
|
|
|
|
vi.mock("../packages/vinext/src/server/instrumentation.js", () => ({
|
|
reportRequestError: vi.fn(() => Promise.resolve()),
|
|
importModule: (runner: { import(id: string): Promise<unknown> }, id: string) =>
|
|
runner.import(id) as Promise<Record<string, any>>,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ── parseSizeLimit ───────────────────────────────────────────────────────
|
|
|
|
describe("parseSizeLimit", () => {
|
|
it("parses unit-suffixed strings (case-insensitive)", () => {
|
|
expect(parseSizeLimit("1b")).toBe(1);
|
|
expect(parseSizeLimit("100kb")).toBe(100 * 1024);
|
|
expect(parseSizeLimit("4mb")).toBe(4 * 1024 * 1024);
|
|
expect(parseSizeLimit("1gb")).toBe(1024 * 1024 * 1024);
|
|
expect(parseSizeLimit("4MB")).toBe(4 * 1024 * 1024);
|
|
expect(parseSizeLimit(" 4mb ")).toBe(4 * 1024 * 1024);
|
|
});
|
|
|
|
it("parses decimal numbers", () => {
|
|
expect(parseSizeLimit("1.5mb")).toBe(Math.floor(1.5 * 1024 * 1024));
|
|
});
|
|
|
|
it("treats a bare number as bytes", () => {
|
|
expect(parseSizeLimit("1024")).toBe(1024);
|
|
expect(parseSizeLimit(1024)).toBe(1024);
|
|
});
|
|
|
|
it("returns undefined for unparseable input", () => {
|
|
expect(parseSizeLimit("")).toBeUndefined();
|
|
expect(parseSizeLimit("nope")).toBeUndefined();
|
|
expect(parseSizeLimit("4xb")).toBeUndefined();
|
|
expect(parseSizeLimit(undefined)).toBeUndefined();
|
|
expect(parseSizeLimit(-1)).toBeUndefined();
|
|
expect(parseSizeLimit(Number.NaN)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── resolveBodyParserConfig ──────────────────────────────────────────────
|
|
|
|
describe("resolveBodyParserConfig", () => {
|
|
const DEFAULT = 1024 * 1024;
|
|
|
|
it("defaults to enabled with 1 MB when config is missing", () => {
|
|
expect(resolveBodyParserConfig(undefined)).toEqual({
|
|
enabled: true,
|
|
sizeLimit: DEFAULT,
|
|
});
|
|
expect(resolveBodyParserConfig({})).toEqual({
|
|
enabled: true,
|
|
sizeLimit: DEFAULT,
|
|
});
|
|
expect(resolveBodyParserConfig({ api: {} })).toEqual({
|
|
enabled: true,
|
|
sizeLimit: DEFAULT,
|
|
});
|
|
});
|
|
|
|
it("treats `bodyParser: false` as disabled", () => {
|
|
expect(resolveBodyParserConfig({ api: { bodyParser: false } })).toEqual({
|
|
enabled: false,
|
|
});
|
|
});
|
|
|
|
it("treats `bodyParser: true` as default-enabled", () => {
|
|
expect(resolveBodyParserConfig({ api: { bodyParser: true } })).toEqual({
|
|
enabled: true,
|
|
sizeLimit: DEFAULT,
|
|
});
|
|
});
|
|
|
|
it("honours `sizeLimit` from the object form", () => {
|
|
expect(resolveBodyParserConfig({ api: { bodyParser: { sizeLimit: "4mb" } } })).toEqual({
|
|
enabled: true,
|
|
sizeLimit: 4 * 1024 * 1024,
|
|
});
|
|
expect(resolveBodyParserConfig({ api: { bodyParser: { sizeLimit: "100kb" } } })).toEqual({
|
|
enabled: true,
|
|
sizeLimit: 100 * 1024,
|
|
});
|
|
expect(resolveBodyParserConfig({ api: { bodyParser: { sizeLimit: 2048 } } })).toEqual({
|
|
enabled: true,
|
|
sizeLimit: 2048,
|
|
});
|
|
});
|
|
|
|
it("falls back to default when sizeLimit is unparseable", () => {
|
|
expect(
|
|
resolveBodyParserConfig({
|
|
api: { bodyParser: { sizeLimit: "bogus" as unknown as string } },
|
|
}),
|
|
).toEqual({ enabled: true, sizeLimit: DEFAULT });
|
|
});
|
|
});
|
|
|
|
// ── Helpers shared by the integration tests below ────────────────────────
|
|
|
|
function mockReq(
|
|
method: string,
|
|
url: string,
|
|
body?: string | Buffer,
|
|
headers: Record<string, string> = {},
|
|
): http.IncomingMessage {
|
|
const stream = new PassThrough();
|
|
const req = Object.assign(stream, {
|
|
method,
|
|
url,
|
|
headers: { ...headers },
|
|
httpVersion: "1.1",
|
|
httpVersionMajor: 1,
|
|
httpVersionMinor: 1,
|
|
complete: false,
|
|
connection: null,
|
|
socket: null,
|
|
aborted: false,
|
|
rawHeaders: [] as string[],
|
|
trailers: {} as Record<string, string | undefined>,
|
|
rawTrailers: [] as string[],
|
|
statusCode: undefined,
|
|
statusMessage: undefined,
|
|
}) as unknown as http.IncomingMessage;
|
|
|
|
if (body !== undefined && body !== null) {
|
|
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf-8");
|
|
queueMicrotask(() => {
|
|
stream.push(buf);
|
|
stream.push(null);
|
|
});
|
|
} else {
|
|
queueMicrotask(() => stream.push(null));
|
|
}
|
|
|
|
return req;
|
|
}
|
|
|
|
function mockReqChunked(
|
|
method: string,
|
|
url: string,
|
|
chunks: Buffer[],
|
|
headers: Record<string, string> = {},
|
|
): http.IncomingMessage {
|
|
const stream = new PassThrough();
|
|
const req = Object.assign(stream, {
|
|
method,
|
|
url,
|
|
headers: { ...headers },
|
|
httpVersion: "1.1",
|
|
httpVersionMajor: 1,
|
|
httpVersionMinor: 1,
|
|
complete: false,
|
|
connection: null,
|
|
socket: null,
|
|
aborted: false,
|
|
rawHeaders: [] as string[],
|
|
trailers: {} as Record<string, string | undefined>,
|
|
rawTrailers: [] as string[],
|
|
statusCode: undefined,
|
|
statusMessage: undefined,
|
|
}) as unknown as http.IncomingMessage;
|
|
|
|
queueMicrotask(() => {
|
|
for (const c of chunks) {
|
|
if (!stream.destroyed) stream.push(c);
|
|
}
|
|
if (!stream.destroyed) stream.push(null);
|
|
});
|
|
|
|
return req;
|
|
}
|
|
|
|
function mockRes(): http.ServerResponse & {
|
|
_body: string | Buffer;
|
|
_headers: Record<string, string | string[]>;
|
|
_statusCode: number;
|
|
_ended: boolean;
|
|
} {
|
|
const headers: Record<string, string | string[]> = {};
|
|
const res = {
|
|
statusCode: 200,
|
|
_body: "" as string | Buffer,
|
|
_headers: headers,
|
|
_statusCode: 200,
|
|
_ended: false,
|
|
setHeader(name: string, value: string | string[]) {
|
|
headers[name.toLowerCase()] = value;
|
|
},
|
|
getHeader(name: string) {
|
|
return headers[name.toLowerCase()];
|
|
},
|
|
writeHead(status: number, hdrs?: Record<string, string>) {
|
|
res.statusCode = status;
|
|
res._statusCode = status;
|
|
if (hdrs) {
|
|
for (const [k, v] of Object.entries(hdrs)) {
|
|
headers[k.toLowerCase()] = v;
|
|
}
|
|
}
|
|
},
|
|
write(data: string | Buffer | Uint8Array) {
|
|
const chunk =
|
|
typeof data === "string"
|
|
? Buffer.from(data)
|
|
: Buffer.isBuffer(data)
|
|
? data
|
|
: Buffer.from(data);
|
|
res._body = Buffer.isBuffer(res._body)
|
|
? Buffer.concat([res._body, chunk])
|
|
: res._body
|
|
? Buffer.concat([Buffer.from(res._body), chunk])
|
|
: chunk;
|
|
return true;
|
|
},
|
|
end(data?: string | Buffer) {
|
|
if (data !== undefined) {
|
|
res._body = data;
|
|
}
|
|
res._ended = true;
|
|
res._statusCode = res.statusCode;
|
|
},
|
|
} as unknown as http.ServerResponse & {
|
|
_body: string | Buffer;
|
|
_headers: Record<string, string | string[]>;
|
|
_statusCode: number;
|
|
_ended: boolean;
|
|
};
|
|
return res;
|
|
}
|
|
|
|
function route(pattern: string, filePath = "/fake/api/handler.ts"): Route {
|
|
return {
|
|
pattern,
|
|
patternParts: pattern.split("/").filter(Boolean),
|
|
filePath,
|
|
isDynamic: false,
|
|
params: [],
|
|
};
|
|
}
|
|
|
|
function mockServer(moduleExport: Record<string, unknown>): ModuleImporter {
|
|
return { import: vi.fn().mockResolvedValue(moduleExport) };
|
|
}
|
|
|
|
// ── Node/dev path: api-handler.ts ────────────────────────────────────────
|
|
|
|
describe("handleApiRoute body parser config (Node/dev path)", () => {
|
|
it("bodyParser: false leaves req intact so handler can read the raw stream", async () => {
|
|
let receivedBodyField: unknown = "sentinel";
|
|
let receivedRaw = "";
|
|
const handler = vi.fn(async (req: any) => {
|
|
receivedBodyField = req.body;
|
|
// Read directly from the underlying stream (the IncomingMessage).
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
receivedRaw = Buffer.concat(chunks).toString("utf-8");
|
|
});
|
|
const server = mockServer({
|
|
default: handler,
|
|
config: { api: { bodyParser: false } },
|
|
});
|
|
|
|
const payload = JSON.stringify({ webhook: "stripe", evt: "payment.succeeded" });
|
|
const req = mockReq("POST", "/api/webhook", payload, {
|
|
"content-type": "application/json",
|
|
"stripe-signature": "v1=abc",
|
|
});
|
|
const res = mockRes();
|
|
|
|
await handleApiRoute(server, req, res, "/api/webhook", [route("/api/webhook")]);
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
// With bodyParser: false, req.body is undefined; raw bytes are intact.
|
|
expect(receivedBodyField).toBeUndefined();
|
|
expect(receivedRaw).toBe(payload);
|
|
});
|
|
|
|
it("bodyParser: { sizeLimit: '4mb' } accepts a 2 MB body that the default would reject", async () => {
|
|
let capturedSize = 0;
|
|
const handler = vi.fn((req: any) => {
|
|
capturedSize = typeof req.body === "string" ? req.body.length : 0;
|
|
});
|
|
const server = mockServer({
|
|
default: handler,
|
|
config: { api: { bodyParser: { sizeLimit: "4mb" } } },
|
|
});
|
|
|
|
// 2 MB of plain text — would exceed the hard-coded 1 MB default.
|
|
const twoMB = 2 * 1024 * 1024;
|
|
const chunks = [Buffer.alloc(twoMB, 0x41)];
|
|
const req = mockReqChunked("POST", "/api/upload", chunks, {
|
|
"content-type": "text/plain",
|
|
});
|
|
const res = mockRes();
|
|
|
|
await handleApiRoute(server, req, res, "/api/upload", [route("/api/upload")]);
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
expect(res._statusCode).not.toBe(413);
|
|
expect(capturedSize).toBe(twoMB);
|
|
});
|
|
|
|
it("bodyParser: { sizeLimit: '4mb' } rejects a 5 MB body with 413", async () => {
|
|
const handler = vi.fn();
|
|
const server = mockServer({
|
|
default: handler,
|
|
config: { api: { bodyParser: { sizeLimit: "4mb" } } },
|
|
});
|
|
|
|
// Push 5 chunks of 1 MB each — 5 MB total.
|
|
const chunks = Array.from({ length: 5 }, () => Buffer.alloc(1024 * 1024, 0x41));
|
|
const req = mockReqChunked("POST", "/api/upload", chunks, {
|
|
"content-type": "text/plain",
|
|
});
|
|
const res = mockRes();
|
|
|
|
await handleApiRoute(server, req, res, "/api/upload", [route("/api/upload")]);
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(res._statusCode).toBe(413);
|
|
expect(res._body).toBe("Request body too large");
|
|
});
|
|
|
|
it("bodyParser: { sizeLimit: '100kb' } rejects a 200 KB body with 413", async () => {
|
|
const handler = vi.fn();
|
|
const server = mockServer({
|
|
default: handler,
|
|
config: { api: { bodyParser: { sizeLimit: "100kb" } } },
|
|
});
|
|
|
|
const chunks = [Buffer.alloc(200 * 1024, 0x41)];
|
|
const req = mockReqChunked("POST", "/api/upload", chunks, {
|
|
"content-type": "text/plain",
|
|
});
|
|
const res = mockRes();
|
|
|
|
await handleApiRoute(server, req, res, "/api/upload", [route("/api/upload")]);
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(res._statusCode).toBe(413);
|
|
});
|
|
|
|
it("no config preserves the 1 MB default", async () => {
|
|
const handler = vi.fn();
|
|
const server = mockServer({ default: handler });
|
|
|
|
const chunks = Array.from({ length: 5 }, () => Buffer.alloc(256 * 1024, 0x41));
|
|
const req = mockReqChunked("POST", "/api/upload", chunks, {
|
|
"content-type": "text/plain",
|
|
});
|
|
const res = mockRes();
|
|
|
|
await handleApiRoute(server, req, res, "/api/upload", [route("/api/upload")]);
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(res._statusCode).toBe(413);
|
|
});
|
|
|
|
it("bodyParser: true behaves like the default 1 MB limit", async () => {
|
|
const handler = vi.fn();
|
|
const server = mockServer({
|
|
default: handler,
|
|
config: { api: { bodyParser: true } },
|
|
});
|
|
|
|
const chunks = Array.from({ length: 5 }, () => Buffer.alloc(256 * 1024, 0x41));
|
|
const req = mockReqChunked("POST", "/api/upload", chunks, {
|
|
"content-type": "text/plain",
|
|
});
|
|
const res = mockRes();
|
|
|
|
await handleApiRoute(server, req, res, "/api/upload", [route("/api/upload")]);
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(res._statusCode).toBe(413);
|
|
});
|
|
});
|
|
|
|
// ── Workers/prod path: pages-api-route.ts ────────────────────────────────
|
|
|
|
type PagesApiRouteModule = PagesApiRouteMatch["route"]["module"];
|
|
|
|
function createMatch(
|
|
handler: PagesApiRouteModule["default"],
|
|
moduleConfig?: PagesApiRouteModule["config"],
|
|
): PagesApiRouteMatch {
|
|
return {
|
|
params: {},
|
|
route: {
|
|
pattern: "/api/webhook",
|
|
module: {
|
|
config: moduleConfig,
|
|
default: handler,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("handlePagesApiRoute body parser config (Workers/prod path)", () => {
|
|
// Next.js delegates Pages API cookies to its compiled `cookie` parser.
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/api-utils/get-cookie-parser.ts
|
|
it("preserves duplicate ordering and the public object API", async () => {
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch((req: PagesReqResRequest, res: PagesReqResResponse) => {
|
|
res.json({
|
|
cookies: req.cookies,
|
|
hasOwnPropertyWorks: req.cookies.hasOwnProperty("session"),
|
|
hasDefaultToString: req.cookies.toString === Object.prototype.toString,
|
|
toStringResult: Object.prototype.toString.call(req.cookies),
|
|
ownsProto: Object.hasOwn(req.cookies, "__proto__"),
|
|
ownsConstructor: Object.hasOwn(req.cookies, "constructor"),
|
|
ownsToString: Object.hasOwn(req.cookies, "toString"),
|
|
});
|
|
}),
|
|
request: new Request("https://example.com/api/account", {
|
|
headers: {
|
|
cookie:
|
|
'session=trusted; session=attacker; =empty-name; __proto__=prototype-cookie; constructor=constructor-cookie; toString=string-cookie; encoded=hello%20world; malformed=%E0%A4%A; quoted="quoted value"',
|
|
},
|
|
}),
|
|
url: "/api/account",
|
|
});
|
|
|
|
expect(await response.json()).toEqual({
|
|
cookies: {
|
|
session: "trusted",
|
|
"": "empty-name",
|
|
encoded: "hello world",
|
|
malformed: "%E0%A4%A",
|
|
quoted: "quoted value",
|
|
},
|
|
hasOwnPropertyWorks: true,
|
|
hasDefaultToString: true,
|
|
toStringResult: "[object Object]",
|
|
ownsProto: false,
|
|
ownsConstructor: false,
|
|
ownsToString: false,
|
|
});
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/proxy-request-with-middleware/test/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/proxy-request-with-middleware/test/index.test.ts
|
|
//
|
|
// The upstream fixture pipes the API route's `req` through the deprecated
|
|
// `request` package and then pipes the proxied response into `res`. That
|
|
// proves a Pages API route still receives an IncomingMessage/ServerResponse-
|
|
// shaped stream surface when middleware is present. At this lower boundary
|
|
// we keep the same observable contract with a local Transform: `req.pipe`
|
|
// must preserve method, headers, and body, and `res` must be writable.
|
|
for (const { method, body } of [
|
|
{ method: "GET", body: undefined },
|
|
{ method: "POST", body: JSON.stringify({ key: "value" }) },
|
|
]) {
|
|
it(`bodyParser: false supports req.pipe(...).pipe(res) for ${method}`, async () => {
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/json",
|
|
"x-custom-header": "some value",
|
|
};
|
|
if (body) {
|
|
headers["content-length"] = String(body.length);
|
|
}
|
|
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
(req: PagesReqResRequest, res: PagesReqResResponse) => {
|
|
const chunks: Buffer[] = [];
|
|
const upstream = new Transform({
|
|
transform(chunk: Buffer, _encoding, callback) {
|
|
chunks.push(Buffer.from(chunk));
|
|
callback();
|
|
},
|
|
flush(callback) {
|
|
this.push(
|
|
JSON.stringify({
|
|
method: req.method,
|
|
headers: req.headers,
|
|
rawBody: Buffer.concat(chunks).toString("utf8"),
|
|
}),
|
|
);
|
|
callback();
|
|
},
|
|
});
|
|
|
|
return req.pipe(upstream).pipe(res);
|
|
},
|
|
{ api: { bodyParser: false } },
|
|
),
|
|
request: new Request("https://example.com/api", {
|
|
method,
|
|
headers,
|
|
body,
|
|
}),
|
|
url: "/api",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as {
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
rawBody: string;
|
|
};
|
|
|
|
expect(data.method).toBe(method);
|
|
if (body) {
|
|
expect(data.headers["content-length"]).toBe(String(body.length));
|
|
expect(data.rawBody).toBe(body);
|
|
} else {
|
|
expect(data.rawBody).toBe("");
|
|
}
|
|
expect(data.headers).toEqual(expect.objectContaining(headers));
|
|
});
|
|
}
|
|
|
|
// Ported from Next.js: test/e2e/middleware-fetches-with-body/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-fetches-with-body/index.test.ts
|
|
//
|
|
// The Next.js `body_parser_false` handler iterates `req` directly with
|
|
// `for await (const chunk of req)` — matching Node's IncomingMessage
|
|
// contract. Our Workers `req` is a synthetic object, so we must surface
|
|
// an async-iterator on `req` so this idiom works. Issue #1479.
|
|
it("bodyParser: false: handler can iterate `req` directly with `for await` (16KiB payload)", async () => {
|
|
const bodySize = 16 * 1024;
|
|
const body = "HIJK1L2M3N4O5P6Q7R8S9T0UaVbWcXdY".repeat(bodySize / 32);
|
|
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
async (req: any, res: any) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
const buf = Buffer.concat(chunks);
|
|
res.json({ rawBody: buf.toString("utf8"), body: req.body });
|
|
},
|
|
{ api: { bodyParser: false } },
|
|
),
|
|
request: new Request("https://example.com/api/body_parser_false", {
|
|
method: "POST",
|
|
headers: { "content-type": "text/plain" },
|
|
body,
|
|
}),
|
|
url: "/api/body_parser_false",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const data = (await response.json()) as { rawBody: string; body?: unknown };
|
|
expect(data.body).toBeUndefined();
|
|
expect(data.rawBody.length).toBe(bodySize);
|
|
expect(data.rawBody).toBe(body);
|
|
});
|
|
|
|
it("bodyParser: false leaves req.body undefined; handler reads raw bytes by iterating req", async () => {
|
|
let receivedBody: unknown = "sentinel";
|
|
let receivedRaw = "";
|
|
|
|
const payload = JSON.stringify({ webhook: "stripe", evt: "payment.succeeded" });
|
|
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
async (req: any, res: any) => {
|
|
receivedBody = req.body;
|
|
// Next.js parity: with bodyParser: false, handler iterates `req`
|
|
// directly (matching Node IncomingMessage). Cf. test/e2e/middleware-
|
|
// fetches-with-body fixture `body_parser_false.js`.
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
receivedRaw = Buffer.concat(chunks).toString("utf8");
|
|
res.json({ ok: true });
|
|
},
|
|
{ api: { bodyParser: false } },
|
|
),
|
|
request: new Request("https://example.com/api/webhook", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"stripe-signature": "v1=abc",
|
|
},
|
|
body: payload,
|
|
}),
|
|
url: "/api/webhook",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(receivedBody).toBeUndefined();
|
|
expect(receivedRaw).toBe(payload);
|
|
});
|
|
|
|
it("bodyParser: { sizeLimit: '4mb' } accepts a 2 MB body that the default would reject", async () => {
|
|
const payload = "A".repeat(2 * 1024 * 1024);
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
(req: any, res: any) => {
|
|
const size = typeof req.body === "string" ? req.body.length : 0;
|
|
res.json({ size });
|
|
},
|
|
{ api: { bodyParser: { sizeLimit: "4mb" } } },
|
|
),
|
|
request: new Request("https://example.com/api/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"content-length": String(payload.length),
|
|
},
|
|
body: payload,
|
|
}),
|
|
url: "/api/upload",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({ size: 2 * 1024 * 1024 });
|
|
});
|
|
|
|
it("bodyParser: { sizeLimit: '4mb' } rejects a 5 MB body with 413", async () => {
|
|
const payload = "A".repeat(5 * 1024 * 1024);
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
(_req: any, res: any) => {
|
|
res.json({ ok: true });
|
|
},
|
|
{ api: { bodyParser: { sizeLimit: "4mb" } } },
|
|
),
|
|
request: new Request("https://example.com/api/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"content-length": String(payload.length),
|
|
},
|
|
body: payload,
|
|
}),
|
|
url: "/api/upload",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
});
|
|
|
|
it("bodyParser: { sizeLimit: '100kb' } rejects a 200 KB body with 413", async () => {
|
|
const payload = "A".repeat(200 * 1024);
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
(_req: any, res: any) => {
|
|
res.json({ ok: true });
|
|
},
|
|
{ api: { bodyParser: { sizeLimit: "100kb" } } },
|
|
),
|
|
request: new Request("https://example.com/api/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"content-length": String(payload.length),
|
|
},
|
|
body: payload,
|
|
}),
|
|
url: "/api/upload",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
});
|
|
|
|
it("no config preserves the 1 MB default", async () => {
|
|
const payload = "A".repeat(2 * 1024 * 1024);
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch((_req: any, res: any) => {
|
|
res.json({ ok: true });
|
|
}),
|
|
request: new Request("https://example.com/api/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"content-length": String(payload.length),
|
|
},
|
|
body: payload,
|
|
}),
|
|
url: "/api/upload",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
});
|
|
|
|
it("bodyParser: true behaves like the default 1 MB limit", async () => {
|
|
const payload = "A".repeat(2 * 1024 * 1024);
|
|
const response = await handlePagesApiRoute({
|
|
match: createMatch(
|
|
(_req: any, res: any) => {
|
|
res.json({ ok: true });
|
|
},
|
|
{ api: { bodyParser: true } },
|
|
),
|
|
request: new Request("https://example.com/api/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"content-length": String(payload.length),
|
|
},
|
|
body: payload,
|
|
}),
|
|
url: "/api/upload",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
});
|
|
});
|