Files
cloudflare__vinext/tests/app-route-handler-dispatch.test.ts
T
Nathan Nguyen 390b4608be fix(server): reject invalid HTTP methods with 400 in app route handlers (#1048)
Vinext previously uppercased the request method and fell through to the normal 405 response for any unrecognized method. Next.js rejects non-standard methods with 400 before dispatch.

- Add isValidHTTPMethod() predicate to app-route-handler-runtime.ts

- Return empty 400 in dispatchAppRouteHandler() before auto-OPTIONS/405

- Port Next.js test: HEADER => 400 Bad Request

Next.js source reference:

https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/app-route/module.ts#L390-L392

https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L531-L538
2026-05-04 18:56:23 +01:00

270 lines
8.6 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import { dispatchAppRouteHandler } from "../packages/vinext/src/server/app-route-handler-dispatch.js";
import type { CachedRouteValue } from "../packages/vinext/src/shims/cache.js";
import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js";
function buildCachedRouteValue(body: string): CachedRouteValue {
return {
kind: "APP_ROUTE",
body: new TextEncoder().encode(body).buffer,
status: 200,
headers: {
"content-type": "text/plain",
},
};
}
function buildISRCacheEntry(value: CachedRouteValue, isStale = false): ISRCacheEntry {
return {
isStale,
value: {
lastModified: Date.now(),
value,
},
};
}
describe("app route handler dispatch", () => {
it("rejects invalid HTTP methods with 400 before auto-OPTIONS/405 logic", async () => {
// Ported from Next.js: test/e2e/app-dir/app-routes/app-custom-routes.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L531-L538
const route = {
pattern: "/api/status",
routeHandler: {
GET() {
throw new Error("GET should not run for invalid methods");
},
},
routeSegments: ["api", "status"],
};
let clearCount = 0;
const invalidMethodResponse = await dispatchAppRouteHandler({
cleanPathname: "/api/status",
clearRequestContext() {
clearCount += 1;
},
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
throw new Error("invalid method should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("invalid method should not write route cache");
},
middlewareContext: {
headers: new Headers([["x-middleware", "present"]]),
status: null,
},
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/status", { method: "HEADER" }),
route,
scheduleBackgroundRegeneration() {
throw new Error("invalid method should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(invalidMethodResponse.status).toBe(400);
expect(invalidMethodResponse.headers.get("x-middleware")).toBe("present");
await expect(invalidMethodResponse.text()).resolves.toBe("");
expect(clearCount).toBe(1);
});
it("handles framework-generated OPTIONS responses and unsupported methods at the dispatch boundary", async () => {
const route = {
pattern: "/api/demo",
routeHandler: {
GET() {
throw new Error("GET should not run for OPTIONS or DELETE");
},
POST() {
throw new Error("POST should not run for OPTIONS or DELETE");
},
},
routeSegments: ["api", "demo"],
};
let clearCount = 0;
const optionsResponse = await dispatchAppRouteHandler({
cleanPathname: "/api/demo",
clearRequestContext() {
clearCount += 1;
},
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
throw new Error("OPTIONS should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("OPTIONS should not write route cache");
},
middlewareContext: {
headers: new Headers([["x-middleware", "present"]]),
status: null,
},
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/demo", { method: "OPTIONS" }),
route,
scheduleBackgroundRegeneration() {
throw new Error("OPTIONS should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(optionsResponse.status).toBe(204);
expect(optionsResponse.headers.get("allow")).toBe("GET, HEAD, OPTIONS, POST");
expect(optionsResponse.headers.get("x-middleware")).toBe("present");
await expect(optionsResponse.text()).resolves.toBe("");
const unsupportedResponse = await dispatchAppRouteHandler({
cleanPathname: "/api/demo",
clearRequestContext() {
clearCount += 1;
},
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
throw new Error("DELETE should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("DELETE should not write route cache");
},
middlewareContext: {
headers: new Headers([["x-middleware-delete", "present"]]),
status: null,
},
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/demo", { method: "DELETE" }),
route,
scheduleBackgroundRegeneration() {
throw new Error("DELETE should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(unsupportedResponse.status).toBe(405);
expect(unsupportedResponse.headers.get("x-middleware-delete")).toBe("present");
await expect(unsupportedResponse.text()).resolves.toBe("");
expect(clearCount).toBe(2);
});
it("reads eligible ISR route handler responses before executing user code", async () => {
const handlerSpy = vi.fn(() => new Response("should not run"));
let didClearRequestContext = false;
let requestedCacheKey: string | null = null;
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/static",
clearRequestContext() {
didClearRequestContext = true;
},
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet(key) {
requestedCacheKey = key;
return buildISRCacheEntry(buildCachedRouteValue("from-cache"));
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("cache hit should not write route cache");
},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/static"),
route: {
pattern: "/api/static",
routeHandler: {
GET: handlerSpy,
revalidate: 60,
},
routeSegments: ["api", "static"],
},
scheduleBackgroundRegeneration() {
throw new Error("fresh cache hit should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(requestedCacheKey).toBe("route:/api/static");
expect(response.status).toBe(200);
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
await expect(response.text()).resolves.toBe("from-cache");
expect(handlerSpy).not.toHaveBeenCalled();
expect(didClearRequestContext).toBe(true);
});
it("attaches App Router route context when stale route handler cache schedules regeneration", async () => {
const handlerSpy = vi.fn(() => new Response("regenerated"));
let scheduledContext:
| {
routerKind: "App Router";
routePath: string;
routeType: "route";
}
| undefined;
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/stale",
clearRequestContext() {},
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("stale"), true);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("stale response should not synchronously write route cache");
},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/stale"),
route: {
pattern: "/api/stale",
routeHandler: {
GET: handlerSpy,
revalidate: 60,
},
routeSegments: ["api", "stale"],
},
scheduleBackgroundRegeneration(_key, _renderFn, errorContext) {
scheduledContext = errorContext;
},
searchParams: new URLSearchParams(),
});
expect(response.status).toBe(200);
expect(response.headers.get("x-vinext-cache")).toBe("STALE");
await expect(response.text()).resolves.toBe("stale");
expect(handlerSpy).not.toHaveBeenCalled();
expect(scheduledContext).toEqual({
routerKind: "App Router",
routePath: "/api/stale",
routeType: "route",
});
});
});