Files
cloudflare__vinext/tests/app-route-handler-policy.test.ts
T
Nathan Nguyen f6b5b8a9d6 fix: emit no-store Cache-Control for revalidate = 0 route handlers (#865)
* test: cover revalidate = 0 route handler never-cache semantics

Route handlers with `export const revalidate = 0` currently emit no
Cache-Control header at all. Downstream gates treat the 0 value as
equivalent to "no revalidate configured" and skip the header write,
which lets CDNs and browsers apply heuristic caching to a response the
author explicitly opted out of.

The existing policy test asserted the buggy collapse (`toBeNull()` for
revalidate = 0), protecting the behaviour instead of verifying it.
Rewrite the policy test to assert the preserved 0 and add coverage for
the read/write/header gates for revalidate = 0. Add a response-level
test asserting the exact Cache-Control string Next.js emits for
revalidate = 0 (private, no-cache, no-store, max-age=0, must-revalidate).

These tests fail against the current implementation and will pass once
the policy helper preserves 0 and the header builder branches on 0.

* fix: emit no-store Cache-Control for revalidate = 0 route handlers

A route handler exporting `export const revalidate = 0` currently
sends no Cache-Control header at all. Downstream CDNs and browsers
fall back to heuristic caching, so a response the author explicitly
opted out of caching can still be cached and replayed.

getAppRouteHandlerRevalidateSeconds filtered on `> 0`, collapsing the
"never cache" signal (0) and the "no revalidate configured" signal
(null) into the same null return. The three gates downstream
(shouldReadAppRouteHandlerCache, shouldApplyAppRouteHandlerRevalidateHeader,
shouldWriteAppRouteHandlerCache) all short-circuit when revalidateSeconds
is null, so the header was never applied and no cache entry was written.
That second part was accidentally correct; the first part left the
response vulnerable.

Preserve 0 as a distinct value, using Number.isFinite to keep NaN and
Infinity out of the number branch along with negatives. Keep the ISR
read and write gates on `> 0` so a never-cache handler never serves or
persists ISR entries. Keep the header gate on `!== null` so 0 still
triggers a Cache-Control write, and branch buildRouteHandlerCacheControl
on 0 to emit Next.js's canonical no-store string (matches
packages/next/src/server/lib/cache-control.ts).
2026-04-21 17:53:30 +01:00

176 lines
6.1 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
getAppRouteHandlerRevalidateSeconds,
hasAppRouteHandlerDefaultExport,
resolveAppRouteHandlerMethod,
resolveAppRouteHandlerSpecialError,
shouldApplyAppRouteHandlerRevalidateHeader,
shouldReadAppRouteHandlerCache,
shouldWriteAppRouteHandlerCache,
} from "../packages/vinext/src/server/app-route-handler-policy.js";
describe("app route handler policy helpers", () => {
it("preserves revalidate = 0 as a distinct never-cache signal", () => {
// revalidate = 0 must not collapse to null. Downstream header and cache
// gates rely on 0 being observable to emit a no-store Cache-Control and
// to skip ISR writes. Collapsing it to null would emit no Cache-Control
// at all and let CDNs/browsers apply heuristic caching to a response
// the author explicitly opted out of.
expect(getAppRouteHandlerRevalidateSeconds({ revalidate: 60 })).toBe(60);
expect(getAppRouteHandlerRevalidateSeconds({ revalidate: 0 })).toBe(0);
expect(getAppRouteHandlerRevalidateSeconds({ revalidate: Infinity })).toBeNull();
expect(getAppRouteHandlerRevalidateSeconds({ revalidate: Number.NaN })).toBeNull();
expect(getAppRouteHandlerRevalidateSeconds({ revalidate: false })).toBeNull();
});
it("treats revalidate = 0 as never-cache for route handler ISR read/write gates", () => {
const readBase = {
dynamicConfig: "auto",
handlerFn() {},
isAutoHead: false,
isKnownDynamic: false,
isProduction: true,
method: "GET",
revalidateSeconds: 0,
};
// A never-cache handler must not be served from ISR. Otherwise stale
// entries written before the handler was marked never-cache would keep
// replaying.
expect(shouldReadAppRouteHandlerCache(readBase)).toBe(false);
const writeBase = {
dynamicConfig: "auto",
dynamicUsedInHandler: false,
handlerSetCacheControl: false,
isAutoHead: false,
isProduction: true,
method: "GET",
revalidateSeconds: 0,
};
// Writing a never-cache response to ISR would persist uncacheable
// content under a key that later requests would try to serve.
expect(shouldWriteAppRouteHandlerCache(writeBase)).toBe(false);
// The framework still owns the Cache-Control header for revalidate = 0
// unless the handler set its own. Gating this off would leave the
// response with no Cache-Control and expose it to heuristic caching.
expect(shouldApplyAppRouteHandlerRevalidateHeader(writeBase)).toBe(true);
});
it("detects invalid default-export route handlers", () => {
expect(hasAppRouteHandlerDefaultExport({ default() {} })).toBe(true);
expect(hasAppRouteHandlerDefaultExport({ default: "nope" })).toBe(false);
expect(hasAppRouteHandlerDefaultExport({ GET() {} })).toBe(false);
});
it("resolves auto-options and auto-head route handler behavior", () => {
const resolvedOptions = resolveAppRouteHandlerMethod(
{
GET() {},
POST() {},
},
"OPTIONS",
);
expect(resolvedOptions.shouldAutoRespondToOptions).toBe(true);
expect(resolvedOptions.allowHeaderForOptions).toBe("GET, HEAD, OPTIONS, POST");
const resolvedHead = resolveAppRouteHandlerMethod(
{
GET() {
return Response.json({ ok: true });
},
},
"HEAD",
);
expect(resolvedHead.isAutoHead).toBe(true);
expect(typeof resolvedHead.handlerFn).toBe("function");
});
it("determines when route handler ISR cache reads are allowed", () => {
const base = {
dynamicConfig: "auto",
handlerFn() {},
isAutoHead: false,
isKnownDynamic: false,
isProduction: true,
method: "GET",
revalidateSeconds: 60,
};
expect(shouldReadAppRouteHandlerCache(base)).toBe(true);
expect(shouldReadAppRouteHandlerCache({ ...base, dynamicConfig: "force-dynamic" })).toBe(false);
expect(shouldReadAppRouteHandlerCache({ ...base, isKnownDynamic: true })).toBe(false);
expect(shouldReadAppRouteHandlerCache({ ...base, method: "POST" })).toBe(false);
expect(shouldReadAppRouteHandlerCache({ ...base, method: "HEAD", isAutoHead: true })).toBe(
true,
);
expect(shouldReadAppRouteHandlerCache({ ...base, method: "HEAD", isAutoHead: false })).toBe(
false,
);
});
it("determines when route handler cache headers and writes are allowed", () => {
const base = {
dynamicConfig: "auto",
dynamicUsedInHandler: false,
handlerSetCacheControl: false,
isAutoHead: false,
isProduction: true,
method: "GET",
revalidateSeconds: 60,
};
expect(shouldApplyAppRouteHandlerRevalidateHeader(base)).toBe(true);
expect(
shouldApplyAppRouteHandlerRevalidateHeader({ ...base, dynamicUsedInHandler: true }),
).toBe(false);
expect(
shouldApplyAppRouteHandlerRevalidateHeader({ ...base, handlerSetCacheControl: true }),
).toBe(false);
expect(shouldWriteAppRouteHandlerCache(base)).toBe(true);
expect(shouldWriteAppRouteHandlerCache({ ...base, isProduction: false })).toBe(false);
expect(shouldWriteAppRouteHandlerCache({ ...base, dynamicConfig: "force-dynamic" })).toBe(
false,
);
});
it("maps special route handler digests to typed redirect and status results", () => {
expect(
resolveAppRouteHandlerSpecialError(
{ digest: "NEXT_REDIRECT;replace;%2Ftarget%3Fok%3D1;308" },
"https://example.com/source",
),
).toEqual({
kind: "redirect",
location: "https://example.com/target?ok=1",
statusCode: 308,
});
expect(
resolveAppRouteHandlerSpecialError(
{ digest: "NEXT_NOT_FOUND" },
"https://example.com/source",
),
).toEqual({
kind: "status",
statusCode: 404,
});
expect(
resolveAppRouteHandlerSpecialError(
{ digest: "NEXT_HTTP_ERROR_FALLBACK;401" },
"https://example.com/source",
),
).toEqual({
kind: "status",
statusCode: 401,
});
expect(resolveAppRouteHandlerSpecialError(new Error("no digest"), "https://example.com")).toBe(
null,
);
});
});