Files
cloudflare__vinext/tests/app-rsc-response-finalizer.test.ts
T
Nathan Nguyen 01b64a1f71 refactor(app-rsc): extract response finalisation into server/app-rsc-response-finalizer (#1035)
* refactor(app-rsc): introduce app-rsc-request-normalization module

The RSC entry handler inlined all URL parsing, pathname decoding, basePath
checking, RSC detection, and header sanitization directly in its
_handleRequest template. That made the logic impossible to unit-test, hard to
audit for security, and duplicated normalizeMountedSlotsHeader in three places.

Create server/app-rsc-request-normalization.ts to own the full normalization
pipeline as a single, typed function (normalizeRscRequest). The function
returns a discriminated Response | NormalizedRscRequest, encoding early exits
(400/404) in the type rather than as throw-then-catch pairs.

The ordering within the function is security-critical and documented step by
step:
  1. Parse URL
  2. Protocol-relative guard (on raw pathname, before normalizePath collapses //)
  3. Strict segment-wise percent-decode (malformed encoding -> 400)
  4. normalizePath (collapse //, resolve . and .. segments)
  5. basePath check + strip (missing prefix -> 404; /__vinext/ bypasses)
  6/7. RSC detection and cleanPathname
  8. Sanitize X-Vinext-Interception-Context (strip null bytes)
  9. Normalize x-vinext-mounted-slots (dedup, sort)

Consolidate normalizeMountedSlotsHeader: the function existed independently in
isr-cache.ts and as a private copy in app-elements.ts. Both now import from
the new module. isr-cache.ts re-exports it for callers already importing from
there.

43 new behavior tests cover the security-sensitive paths: protocol-relative
variants (literal, backslash, %5C, %2F), malformed percent sequences, basePath
enforcement, %2F preservation, RSC detection by suffix and Accept header,
interception context sanitization, and mounted-slots normalization.

* refactor(app-rsc): extract response finalisation into typed helper

Config header application runs inline in the generated RSC entry handler()
as a template string with no tests. Every response variant (page, route
handler, server action, not-found) goes through handler(), but the shared
finalisation logic cannot be tested or reused — a classic rot factory.

Create server/app-rsc-response-finalizer.ts that owns:
- Skip 3xx redirect responses. Response.redirect() produces immutable Headers
  that throw on write; Next.js also deliberately excludes config headers from
  redirects.
- Normalize the request pathname (segment-wise percent-decode + collapse)
  before matching config header source patterns.
- Strip basePath at a segment boundary before matching. The old inline code
  used pathname.startsWith(basePath) which incorrectly matched /app2/page when
  basePath is /app; stripBasePath() enforces the separator boundary.
- Apply matching config headers via applyConfigHeadersToResponse, preserving
  middleware-set header precedence.

The generated entry now calls __finalizeAppRscResponse(response, request, {...})
in place of ~15 lines of template logic, and drops the now-unused
applyConfigHeadersToResponse and normalizePathnameForRouteMatch imports from
the generated code.

Tests cover: header applied to 200, empty configHeaders no-op, source
pattern miss, immutable 307 no throw, mutable 308 header skipped, basePath
strip, basePath segment-boundary (regression for startsWith bug), nested
basePath, has-condition match, has-condition miss.
2026-05-03 18:18:32 +01:00

197 lines
8.4 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js";
import type { RequestContext } from "../packages/vinext/src/config/config-matchers.js";
function makeRequestContext(headers: Headers = new Headers()): RequestContext {
return {
headers,
cookies: {},
query: new URLSearchParams(),
host: "example.com",
};
}
// ── config headers applied to non-redirect responses ────────────────────
describe("finalizeAppRscResponse — config header application", () => {
it("applies a matching config header to a 200 response", () => {
// Behavior: /about page response gets x-added header from next.config.js headers[].
// Regression: expected null to be "config"
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/about");
finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }],
requestContext: makeRequestContext(),
});
expect(response.headers.get("x-added")).toBe("config");
});
it("does not apply config headers when none are configured", () => {
// Behavior: empty configHeaders list → response returned as-is with no mutation.
// Regression: unexpected header appears, or function throws.
const response = new Response("body", { status: 200, headers: { "x-existing": "keep" } });
const request = new Request("http://example.com/about");
const result = finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [],
requestContext: makeRequestContext(),
});
expect(result).toBe(response);
expect(result.headers.get("x-existing")).toBe("keep");
});
it("does not apply config headers when source pattern does not match", () => {
// Behavior: /blog response is unaffected by a config header scoped to /about.
// Regression: expected "config" to be null.
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/blog");
finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }],
requestContext: makeRequestContext(),
});
expect(response.headers.get("x-added")).toBeNull();
});
});
// ── redirect responses skipped ──────────────────────────────────────────
describe("finalizeAppRscResponse — redirect responses are not mutated", () => {
it("does not throw when called with an immutable 307 redirect response", () => {
// Behavior: Response.redirect() creates immutable headers; calling finalizeAppRscResponse
// on such a response must never throw "Cannot modify immutable headers".
// Regression: TypeError: Cannot modify immutable headers
const response = Response.redirect("http://example.com/new", 307);
const request = new Request("http://example.com/old");
expect(() =>
finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [{ source: "/old", headers: [{ key: "x-added", value: "yes" }] }],
requestContext: makeRequestContext(),
}),
).not.toThrow();
});
it("does not apply config headers to a mutable 308 permanent redirect", () => {
// Behavior: 308 redirect responses skip config header application regardless of mutability.
// Regression: expected "yes" to be null — header applied to redirect response.
const response = new Response(null, { status: 308, headers: { Location: "/new" } });
const request = new Request("http://example.com/old");
finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [{ source: "/old", headers: [{ key: "x-added", value: "yes" }] }],
requestContext: makeRequestContext(),
});
expect(response.headers.get("x-added")).toBeNull();
});
});
// ── basePath stripping ──────────────────────────────────────────────────
describe("finalizeAppRscResponse — basePath stripping before pattern matching", () => {
it("strips basePath before matching config header source patterns", () => {
// Behavior: config header source "/about" applies to request "/app/about" when basePath="/app".
// Regression: expected null to be "config" — header not matched because /app/about ≠ /about.
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/app/about");
finalizeAppRscResponse(response, request, {
basePath: "/app",
configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }],
requestContext: makeRequestContext(),
});
expect(response.headers.get("x-added")).toBe("config");
});
it("does not strip basePath when pathname only shares a string prefix (segment boundary)", () => {
// Behavior: /app2/page with basePath /app must not strip /app, because /app2 is a
// different path segment. The config header source "/2/page" must not match.
// Regression: expected "yes" to be null — basePath incorrectly stripped past segment
// boundary, turning /app2/page into /2/page which then matched the source.
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/app2/page");
finalizeAppRscResponse(response, request, {
basePath: "/app",
configHeaders: [{ source: "/2/page", headers: [{ key: "x-wrong-strip", value: "yes" }] }],
requestContext: makeRequestContext(),
});
expect(response.headers.get("x-wrong-strip")).toBeNull();
});
it("strips nested basePath correctly", () => {
// Behavior: config header source "/guide" applies to /docs/v2/guide when basePath="/docs/v2".
// Regression: expected null to be "config".
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/docs/v2/guide");
finalizeAppRscResponse(response, request, {
basePath: "/docs/v2",
configHeaders: [{ source: "/guide", headers: [{ key: "x-added", value: "config" }] }],
requestContext: makeRequestContext(),
});
expect(response.headers.get("x-added")).toBe("config");
});
});
// ── request context snapshot ────────────────────────────────────────────
describe("finalizeAppRscResponse — has/missing conditions use original request context", () => {
it("applies header only when has-condition matches the provided request context", () => {
// Behavior: config header with has[type=header] applies only when the original request
// carries the expected header. The requestContext is the pre-middleware snapshot.
// Regression: header applied unconditionally (requestContext ignored).
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/about");
const reqCtxWithFlag = makeRequestContext(new Headers({ "x-preview": "1" }));
finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [
{
source: "/about",
has: [{ type: "header", key: "x-preview", value: "1" }],
headers: [{ key: "x-conditional", value: "yes" }],
},
],
requestContext: reqCtxWithFlag,
});
expect(response.headers.get("x-conditional")).toBe("yes");
});
it("does not apply header when has-condition does not match the request context", () => {
// Behavior: header skipped when the has-condition fails for the original request.
// Regression: expected "yes" to be null — condition bypassed.
const response = new Response("body", { status: 200 });
const request = new Request("http://example.com/about");
finalizeAppRscResponse(response, request, {
basePath: "",
configHeaders: [
{
source: "/about",
has: [{ type: "header", key: "x-preview", value: "1" }],
headers: [{ key: "x-conditional", value: "yes" }],
},
],
requestContext: makeRequestContext(), // no x-preview header
});
expect(response.headers.get("x-conditional")).toBeNull();
});
});