diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 7c2a56db0..ec749ab7b 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -45,6 +45,8 @@ class FakeCache { putDelay: Promise | undefined; onPut: (() => void) | undefined; keysError: Error | undefined; + addAllResponse: + ((request: Request) => Promise | 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 { 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 { await settled; } +async function dispatchInstall(harness: SwHarness): Promise { + let settled: Promise = Promise.resolve(); + harness.listeners.get("install")!({ + waitUntil: (p: Promise) => { + 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 === "/" + ? `${healthSentinel}` + : `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(); diff --git a/src/__tests__/sw-security-hardening.test.ts b/src/__tests__/sw-security-hardening.test.ts index 4814e6f76..a587f28ce 100644 --- a/src/__tests__/sw-security-hardening.test.ts +++ b/src/__tests__/sw-security-hardening.test.ts @@ -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"); diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index b0400670b..e8451186e 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -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;