mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
113 lines
3.8 KiB
TypeScript
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");
|
|
});
|
|
});
|