test(01-02): add red PWA cache isolation contracts

- exercise signed-in service-worker install at CacheStorage boundary
- require data-free offline navigation and immutable public assets
- require current and legacy session cache cleanup
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:37:20 +02:00
1 parent 668f85a58d
commit 3a813b51bd
3 files changed
+113 -1

No files matched your search

+56 -1
View File
@@ -45,6 +45,8 @@ class FakeCache {
putDelay: Promise<void> | undefined;
onPut: (() => void) | undefined;
keysError: Error | undefined;
addAllResponse:
((request: Request) => Promise<Response> | Response) | undefined;
private keyOf(request: RequestInfo | URL): string {
if (typeof request === "string") return new URL(request, ORIGIN).href;
@@ -65,7 +67,13 @@ class FakeCache {
async addAll(urls: string[]): Promise<void> {
for (const url of urls) {
this.map.set(this.keyOf(url), new Response(`precached:${url}`));
const request = new Request(new URL(url, ORIGIN), {
credentials: "same-origin",
});
const response = this.addAllResponse
? await this.addAllResponse(request)
: new Response(`precached:${url}`);
this.map.set(this.keyOf(request), response);
}
}
@@ -199,6 +207,16 @@ async function dispatchActivate(harness: SwHarness): Promise<void> {
await settled;
}
async function dispatchInstall(harness: SwHarness): Promise<void> {
let settled: Promise<unknown> = Promise.resolve();
harness.listeners.get("install")!({
waitUntil: (p: Promise<unknown>) => {
settled = p;
},
});
await settled;
}
function dispatchNavigationFetch(
harness: SwHarness,
path: string,
@@ -474,6 +492,43 @@ describe("sw.js — activate", () => {
});
});
describe("sw.js — authenticated install cache isolation", () => {
it("never requests or stores signed-in root HTML while retaining public immutable assets", async () => {
const harness = bootServiceWorker();
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
const requested: Request[] = [];
const healthSentinel = "PRIVATE_DASHBOARD_WEIGHT_81_7_KG";
statics.addAllResponse = async (request) => {
requested.push(request);
return new Response(
new URL(request.url).pathname === "/"
? `<html><body>${healthSentinel}</body></html>`
: `public-asset:${new URL(request.url).pathname}`,
);
};
await dispatchInstall(harness);
expect
.soft(requested.map((request) => new URL(request.url).pathname))
.not.toContain("/");
expect
.soft(requested.every((request) => request.credentials === "same-origin"))
.toBe(true);
const cachedBodies = await Promise.all(
[...statics.map.values()].map((response) => response.clone().text()),
);
expect.soft(cachedBodies.join("\n")).not.toContain(healthSentinel);
expect(await statics.match(`${ORIGIN}/logo-192.png`)).toBeDefined();
const offline = await dispatchNavigationFetch(harness, "/");
const offlineBody = await offline.text();
expect.soft(offline.status).toBe(503);
expect.soft(offlineBody).toContain("Offline");
expect(offlineBody).not.toContain(healthSentinel);
});
});
describe("sw.js — networkFirst offline fallback", () => {
it("never serves a shell cached under a previous CACHE_VERSION", async () => {
const harness = bootServiceWorker();
@@ -79,6 +79,17 @@ describe("public/sw.js offline fallback locale neutrality (F-7, 2026-05-16)", ()
});
});
describe("public/sw.js authenticated install isolation (SEC-02)", () => {
it("precaches public immutable assets but never the authenticated root route", () => {
const sw = readSw();
const precache = /const PRECACHE_URLS = \[([^\]]+)\]/.exec(sw)?.[1];
expect(precache).toBeDefined();
expect.soft(precache).not.toMatch(/["']\/["']/);
expect(precache).toMatch(/\/(?:logo|icons)\/?[^"']*\.(?:png|svg)/);
});
});
describe("public/manifest.json PWA identity hygiene (F-7, 2026-05-16)", () => {
it("declares an explicit scope, id, and display_override", () => {
const raw = readFileSync(MANIFEST_PATH, "utf-8");
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import {
clearOfflineCachesForSessionEnd,
clearPersistedQueryCache,
isPersistableKey,
restorePersistedQueryCache,
@@ -86,6 +87,51 @@ describe("persister degrades gracefully without IndexedDB", () => {
});
});
describe("session-end CacheStorage cleanup", () => {
const originalCaches = (globalThis as { caches?: CacheStorage }).caches;
afterEach(() => {
if (originalCaches === undefined) {
delete (globalThis as { caches?: CacheStorage }).caches;
} else {
(globalThis as { caches?: CacheStorage }).caches = originalCaches;
}
});
it("deletes current and legacy HealthLog static/page/data caches only", async () => {
const cacheNames = [
"healthlog-static-v1.34.0",
"healthlog-static-v1.15.20",
"healthlog-pages-v1.34.0",
"healthlog-pages-v1.15.20",
"healthlog-data-v1.34.0",
"healthlog-data-v1.15.20",
"unrelated-app-static-v7",
"workbox-precache-healthlog",
];
const deleted: string[] = [];
(globalThis as { caches?: CacheStorage }).caches = {
keys: async () => cacheNames,
delete: async (name: string) => {
deleted.push(name);
return true;
},
} as CacheStorage;
await clearOfflineCachesForSessionEnd();
expect
.soft(deleted.sort())
.toEqual(
cacheNames
.filter((name) => /^healthlog-(?:static|pages|data)-/.test(name))
.sort(),
);
expect(deleted).not.toContain("unrelated-app-static-v7");
expect(deleted).not.toContain("workbox-precache-healthlog");
});
});
describe("persister round-trips through a fake IndexedDB", () => {
const original = (globalThis as { indexedDB?: unknown }).indexedDB;