mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12: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.
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join, relative, sep } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* Structural guard over the server-prefetching pages.
|
|
*
|
|
* A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the
|
|
* caller's own health record into the HTML document. Two things must hold for
|
|
* every such page, and neither is enforced by the type system:
|
|
*
|
|
* 1. It must be session-gated at the edge, so `src/proxy.ts` stamps
|
|
* `Cache-Control: private, no-store` on the document. A prefetching page
|
|
* placed on a public path would ship a record with no cache directive at
|
|
* all.
|
|
* 2. It must not opt into static or revalidated rendering. `export const
|
|
* revalidate = <n>` or `dynamic = "force-static"` would let Next serve one
|
|
* account's prefetched HTML to the next caller out of its own cache,
|
|
* before the request ever reaches a header.
|
|
*
|
|
* The guard walks the app tree rather than naming files, so a NEW prefetching
|
|
* page is covered the moment it lands.
|
|
*/
|
|
|
|
const APP_DIR = join(process.cwd(), "src", "app");
|
|
|
|
/** Mirrors the public-path allowlist in `src/proxy.ts`. */
|
|
const PUBLIC_ROUTE_PREFIXES = [
|
|
"/auth/",
|
|
"/privacy",
|
|
"/about",
|
|
"/c/",
|
|
"/invite/",
|
|
"/onboarding",
|
|
"/mcp",
|
|
"/i18n/",
|
|
"/.well-known/",
|
|
];
|
|
|
|
function walk(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
if (entry === "node_modules" || entry === "__tests__") continue;
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) {
|
|
out.push(...walk(full));
|
|
} else if (entry === "page.tsx" || entry === "page.ts") {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */
|
|
function routePathFor(file: string): string {
|
|
const rel = relative(APP_DIR, file).split(sep).slice(0, -1);
|
|
const segments = rel.filter(
|
|
// Route groups `(name)` and parallel/intercepting slots contribute no
|
|
// URL segment.
|
|
(s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"),
|
|
);
|
|
return "/" + segments.join("/");
|
|
}
|
|
|
|
function isPublicRoute(routePath: string): boolean {
|
|
return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p));
|
|
}
|
|
|
|
const prefetchingPages = walk(APP_DIR)
|
|
.map((file) => ({ file, source: readFileSync(file, "utf8") }))
|
|
.filter(
|
|
({ source }) =>
|
|
source.includes("HydrationBoundary") && source.includes("dehydrate"),
|
|
)
|
|
.map(({ file, source }) => ({
|
|
file,
|
|
source,
|
|
routePath: routePathFor(file),
|
|
rel: relative(process.cwd(), file),
|
|
}));
|
|
|
|
describe("server-prefetching pages cannot leak a cacheable record", () => {
|
|
it("finds the prefetching pages at all (guard is not vacuous)", () => {
|
|
expect(prefetchingPages.length).toBeGreaterThan(0);
|
|
// The dashboard is the canonical one; if it stops matching, the detection
|
|
// heuristic has drifted and every assertion below is silently skipped.
|
|
expect(prefetchingPages.map((p) => p.routePath)).toContain("/");
|
|
});
|
|
|
|
it("keeps every prefetching page behind the session gate", () => {
|
|
const publicOnes = prefetchingPages.filter((p) =>
|
|
isPublicRoute(p.routePath),
|
|
);
|
|
expect(
|
|
publicOnes.map((p) => `${p.rel} (${p.routePath})`),
|
|
"a page that dehydrates a record onto a public path gets no no-store header from the proxy",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("lets no prefetching page opt into static or revalidated rendering", () => {
|
|
const offenders: string[] = [];
|
|
for (const page of prefetchingPages) {
|
|
if (/export\s+const\s+revalidate\s*=/.test(page.source)) {
|
|
offenders.push(`${page.rel}: exports revalidate`);
|
|
}
|
|
const dynamicMatch = page.source.match(
|
|
/export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/,
|
|
);
|
|
if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") {
|
|
offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`);
|
|
}
|
|
}
|
|
expect(
|
|
offenders,
|
|
"a prefetched page must never be served from Next's own render cache",
|
|
).toEqual([]);
|
|
});
|
|
});
|