Files
HealthLog/src/__tests__/proxy-page-no-store.test.ts
T
Marc-André Bombeck 860cb61b0b fix(proxy): pin no-store on every session-gated page
The dashboard, Insights, the workouts list, the medications list and the
Coach server-prefetch their data and serialise one account's health record
into the HTML document through a TanStack HydrationBoundary. Those pages
are dynamic today only as a side effect of reading the session cookie —
a property of the page code, not a guarantee. A stray revalidate export, a
force-static segment, or a caching proxy in front of the app would be
enough to retain one account's record and hand it to the next caller.

Stamp `Cache-Control: private, no-store` at the edge instead, driven by the
same predicate that governs the auth redirect, so a page cannot be
session-gated without also being uncacheable. Public legal, auth and
locale-catalog surfaces keep their cacheable posture; API routes keep
managing their own caching.

Two guards: the proxy test pins the header on gated pages and its absence
on the public ones, and a structural test walks the app tree and refuses a
prefetching page that sits on a public path or opts into static /
revalidated rendering.
2026-07-19 10:59:40 +02:00

113 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* Session-gated pages serialise one account's health record into the HTML
* document — the dashboard, Insights, the workouts list, the medications
* list and the Coach all server-prefetch their data through a TanStack
* `HydrationBoundary`, so the record ships inside the payload rather than
* arriving over a later fetch.
*
* Those pages are dynamic today only because they read the session cookie.
* That is a property of the page code, not a guarantee — so the proxy pins
* `Cache-Control: private, no-store` on every session-gated page response.
* `private` bars shared caches (a CDN, a reverse proxy); `no-store` also
* bars the browser disk cache and the bfcache snapshot.
*
* These tests pin both sides: gated pages carry the header, and the public
* surfaces that are deliberately cacheable do not.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function makeRequest(pathname: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`, {
headers: { cookie: "healthlog_session=sess-1" },
});
}
/** The pages that server-prefetch and dehydrate a record into the HTML. */
const PREFETCHING_PAGES = [
"/",
"/coach",
"/insights",
"/insights/workouts",
"/medications",
];
/** Other session-gated surfaces that render account data. */
const OTHER_GATED_PAGES = [
"/documents",
"/settings/profile",
"/admin/users",
"/mood",
];
describe("proxy.ts no-store on session-gated pages", () => {
it.each(PREFETCHING_PAGES)(
"sets private, no-store on the prefetching page %s",
(path) => {
const res = proxy(makeRequest(path));
const cacheControl = res.headers.get("cache-control") ?? "";
expect(cacheControl).toContain("no-store");
expect(cacheControl).toContain("private");
},
);
it.each(OTHER_GATED_PAGES)(
"sets private, no-store on the gated page %s",
(path) => {
const res = proxy(makeRequest(path));
const cacheControl = res.headers.get("cache-control") ?? "";
expect(cacheControl).toContain("no-store");
expect(cacheControl).toContain("private");
},
);
it("still emits no-store when the request carries no session cookie", () => {
// The unauthenticated caller gets a redirect to /auth/login, which never
// reaches the header block — assert the redirect rather than a header, so
// the test states what actually happens on that arm.
const res = proxy(new NextRequest("http://localhost/medications"));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/auth/login");
});
it("leaves the public legal + auth surfaces cacheable", () => {
for (const path of ["/privacy", "/about", "/auth/login"]) {
const res = proxy(makeRequest(path));
expect(res.headers.get("cache-control"), path).toBeNull();
}
});
it("leaves the immutable locale catalog cacheable", () => {
const res = proxy(makeRequest("/i18n/en"));
expect(res.headers.get("cache-control")).toBeNull();
});
it("does not blanket API routes, which manage their own caching", () => {
const res = proxy(makeRequest("/api/user/avatar/user-1"));
expect(res.headers.get("cache-control")).toBeNull();
});
it("keeps the stricter share-link posture on /c/<token>", () => {
const token = "hls_000000000000000000000000000000000000000000000000";
const res = proxy(makeRequest(`/c/${token}`));
const cacheControl = res.headers.get("cache-control") ?? "";
expect(cacheControl).toContain("no-store");
expect(cacheControl).toContain("must-revalidate");
});
});