import { describe, expect, it } from "vitest"; import type { PublicCacheAsset } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts"; import { createDefaultPublicCachePolicy } from "../../src/adapters/cache-storage/public-cache-policy.ts"; import { createPublicResponseCacheAdapter } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts"; import { MemoryCacheStorage, digestHex, immediateLock, manifestFor, } from "./public-response-cache-fixture.ts"; /** * STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is * the last thing a repair may destroy. A transient marker read failure is not * evidence of damage, and a repair that has not yet fetched anything has not * yet earned the right to delete what still works. */ describe("public response cache repair is failure-atomic", () => { async function stagedRelease(releaseRegistryId: string) { const policy = createDefaultPublicCachePolicy( "https://assets.example.test", ); const firstBytes = new Uint8Array([1, 1, 1, 1]); const secondBytes = new Uint8Array([2, 2, 2, 2]); const assets: readonly PublicCacheAsset[] = [ { absoluteUrl: "https://assets.example.test/first.js", expectedByteLength: firstBytes.byteLength, expectedContentType: "application/javascript", integrity: { algorithm: "SHA-256", digestHex: await digestHex(firstBytes), }, }, { absoluteUrl: "https://assets.example.test/second.js", expectedByteLength: secondBytes.byteLength, expectedContentType: "application/javascript", integrity: { algorithm: "SHA-256", digestHex: await digestHex(secondBytes), }, }, ]; const bodies = new Map([ [assets[0]!.absoluteUrl, firstBytes], [assets[1]!.absoluteUrl, secondBytes], ]); const cacheStorage = new MemoryCacheStorage(); const fetchLog: string[] = []; let failFrom: string | null = null; const adapter = createPublicResponseCacheAdapter({ cacheStorage: cacheStorage as unknown as CacheStorage, crypto: globalThis.crypto, mutationLock: immediateLock, policy, fetcher: async (request: Request) => { fetchLog.push(request.url); if (failFrom !== null && request.url === failFrom) { throw new TypeError("network is down"); } const body = bodies.get(request.url); if (!body) throw new TypeError(`unknown asset ${request.url}`); return new Response(Uint8Array.from(body), { headers: { "cache-control": "public", "content-type": "application/javascript", }, }); }, }); const manifest = await manifestFor(releaseRegistryId, assets, policy); expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ ok: true, }); expect( await adapter.admin.activateRelease( manifest.releaseRegistryId, manifest.manifestDigestHex, ), ).toMatchObject({ ok: true }); const cacheName = [...cacheStorage.caches.keys()].find((name) => name.includes(releaseRegistryId), ); if (!cacheName) throw new Error("staged cache missing"); return { adapter, assets, cacheName, cacheStorage, fetchLog, manifest, setFailure(url: string | null) { failFrom = url; }, }; } it("does not delete an active candidate when the marker read fails transiently", async () => { const release = await stagedRelease("transient-marker"); const cache = release.cacheStorage.caches.get(release.cacheName)!; const realMatch = cache.match.bind(cache); let markerReads = 0; const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl)); cache.match = async (request: RequestInfo | URL) => { const url = request instanceof Request ? request.url : String(request); if (!assetUrls.has(url)) { markerReads += 1; throw new DOMException("Storage is busy", "InvalidStateError"); } return await realMatch(request); }; const restaged = await release.adapter.admin.stageRelease(release.manifest); expect(markerReads).toBeGreaterThan(0); expect(restaged.ok).toBe(false); expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); cache.match = realMatch; expect( await release.adapter.responses.matchActiveExact({ absoluteUrl: release.assets[0]!.absoluteUrl, }), ).toMatchObject({ ok: true }); }); it("keeps every healthy asset when one repair fetch fails", async () => { const release = await stagedRelease("partial-repair"); const cache = release.cacheStorage.caches.get(release.cacheName)!; // Corrupt only the first asset's stored bytes. const corrupted = cache.responses.findIndex( (entry) => entry.request.url === release.assets[0]!.absoluteUrl, ); expect(corrupted).toBeGreaterThanOrEqual(0); cache.responses.splice(corrupted, 1); release.setFailure(release.assets[0]!.absoluteUrl); const restaged = await release.adapter.admin.stageRelease(release.manifest); expect(restaged.ok).toBe(false); // The cache still exists and the healthy asset is still served. expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); expect( await release.adapter.responses.matchActiveExact({ absoluteUrl: release.assets[1]!.absoluteUrl, }), ).toMatchObject({ ok: true }); }); it("still removes a candidate this call created when staging fails", async () => { const policy = createDefaultPublicCachePolicy( "https://assets.example.test", ); const bytes = new Uint8Array([7, 7, 7, 7]); const asset: PublicCacheAsset = { absoluteUrl: "https://assets.example.test/fresh.js", expectedByteLength: bytes.byteLength, expectedContentType: "application/javascript", integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes), }, }; const cacheStorage = new MemoryCacheStorage(); const adapter = createPublicResponseCacheAdapter({ cacheStorage: cacheStorage as unknown as CacheStorage, crypto: globalThis.crypto, mutationLock: immediateLock, policy, fetcher: async () => { throw new TypeError("network is down"); }, }); const manifest = await manifestFor("fresh-release", [asset], policy); expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ ok: false, }); expect(await cacheStorage.keys()).toEqual([]); }); it("repairs an evicted asset in place and keeps the release usable", async () => { const release = await stagedRelease("in-place-repair"); const cache = release.cacheStorage.caches.get(release.cacheName)!; const evicted = cache.responses.findIndex( (entry) => entry.request.url === release.assets[1]!.absoluteUrl, ); cache.responses.splice(evicted, 1); expect( await release.adapter.admin.stageRelease(release.manifest), ).toMatchObject({ ok: true }); expect( await release.adapter.responses.matchActiveExact({ absoluteUrl: release.assets[1]!.absoluteUrl, }), ).toMatchObject({ ok: true }); expect( await release.adapter.responses.matchActiveExact({ absoluteUrl: release.assets[0]!.absoluteUrl, }), ).toMatchObject({ ok: true }); }); });