Files
clean-architecture-frontend…/tests/unit/public-response-cache.test.ts
T
DongHyeonkaandClaude Opus 5 6a8281a941 fix: make OPFS finalization and public cache repair failure-atomic
STO-RR-01. finalizePut re-acquired the origin mutation lease it was already
holding. A Web Lock is not reentrant, so an ordinary PUT stopped for good at
FINALIZE; it now calls the locked cleanup directly. A strict non-reentrant fake
lease manager pins one acquire and one release per finalization. The adapter no
longer reports a failed finalization as a plain write success either: the
journal row stays COMMITTED for reconciliation, but the caller is told the
write did not settle.

STO-RR-02. A failure raised while serving a validated request now carries that
request's kind. Defaulting every catch to CAPABILITIES made the client's own
expected-kind check reject genuine quota, integrity and abort failures as
protocol breaches and report them as UNSUPPORTED. Only an envelope the runtime
could not read still answers at protocol level.

STO-RR-03. The worker client decodes a response instead of adopting it: exact
own-data descriptors, the negotiated protocol version, the exact awaited kind,
a code inside the closed BrowserDataFailure set and a boolean retryable. An
accessor, a proxy trap, an inherited or extra field and an unknown code all
close the call as UNSUPPORTED rather than leaving it to time out.

STO-RR-04. A marker read that fails transiently is unknown, not damaged, so it
no longer deletes the candidate that may be serving traffic. Only a confirmed
corrupt or missing marker enters the repair path.

STO-RR-05. Staging never deletes a candidate it did not create. A repair
replaces exact entries in place, so a failed fetch leaves every healthy asset
and the active release usable; a candidate this call created is still removed
on failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:00:41 +09:00

1568 lines
49 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type {
PublicCacheAsset,
PublicCacheReleaseManifest,
} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
import {
createDefaultPublicCachePolicy,
resolvePublicCachePolicy,
type PublicCacheRuntimePolicy,
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
import {
computePublicCacheManifestDigestHex,
createPublicResponseCacheAdapter,
type PublicCacheMutationLock,
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
class MemoryCache {
readonly responses: Array<Readonly<{
request: Request;
response: Response;
}>> = [];
async match(request: RequestInfo | URL): Promise<Response | undefined> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request : new Request(url);
return this.responses
.find(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, entry.response),
)
?.response.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request.clone() : new Request(url);
const existing = this.responses.findIndex(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, response),
);
const entry = {
request: nativeRequest,
response: response.clone(),
};
if (existing >= 0) this.responses.splice(existing, 1, entry);
else this.responses.push(entry);
}
}
function varyMatches(
storedRequest: Request,
incomingRequest: Request,
response: Response,
): boolean {
const vary = response.headers.get("vary");
if (!vary) return true;
return vary
.split(",")
.map((name) => name.trim().toLowerCase())
.every(
(name) =>
storedRequest.headers.get(name) ===
incomingRequest.headers.get(name),
);
}
class MemoryCacheStorage {
readonly caches = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
let cache = this.caches.get(name);
if (!cache) {
cache = new MemoryCache();
this.caches.set(name, cache);
}
return cache as unknown as Cache;
}
async keys(): Promise<string[]> {
return [...this.caches.keys()];
}
async delete(name: string): Promise<boolean> {
return this.caches.delete(name);
}
}
const immediateLock: PublicCacheMutationLock = {
async run(_signal, task) {
return await task();
},
};
function deferred<Value>() {
let settle: ((value: Value) => void) | undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return Object.freeze({
promise,
resolve(value: Value): void {
settle?.(value);
},
});
}
async function digestHex(bytes: Uint8Array): Promise<string> {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
Uint8Array.from(bytes),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function manifestFor(
releaseRegistryId: string,
assets: readonly PublicCacheAsset[],
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
"https://assets.example.test",
),
): Promise<PublicCacheReleaseManifest> {
return {
releaseRegistryId,
assets,
manifestDigestHex: await computePublicCacheManifestDigestHex(
globalThis.crypto,
releaseRegistryId,
assets,
policy,
),
};
}
describe("public response Cache Storage adapter", () => {
it("rejects a policy that enables variants but strips Vary", () => {
const base = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
// STO-03. Enabling variants while dropping Vary from the response
// allowlist makes every stored variant collide on the same key.
expect(() =>
resolvePublicCachePolicy({
...base,
allowedRequestHeaderNames: ["accept", "accept-language"],
allowedVaryHeaderNames: ["accept-language"],
allowedResponseHeaderNames: base.allowedResponseHeaderNames.filter(
(name) => name !== "vary",
),
}),
).toThrow(TypeError);
// The same policy with Vary preserved is accepted.
expect(() =>
resolvePublicCachePolicy({
...base,
allowedRequestHeaderNames: ["accept", "accept-language"],
allowedVaryHeaderNames: ["accept-language"],
}),
).not.toThrow();
});
it("restages an evicted entry even when the release marker remains", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([9, 9, 9, 9]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/evicted.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
let fetches = 0;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () => {
fetches += 1;
return new Response(bytes, {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
});
},
});
const manifest = await manifestFor("evicted-release", [asset], policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(fetches).toBe(1);
// The browser evicts the payload but leaves the marker behind.
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
name.includes("evicted-release"),
);
if (!cacheName) throw new Error("staged cache missing");
const cache = cacheStorage.caches.get(cacheName)!;
const payloadIndex = cache.responses.findIndex(
(entry) => entry.request.url === asset.absoluteUrl,
);
expect(payloadIndex).toBeGreaterThanOrEqual(0);
cache.responses.splice(payloadIndex, 1);
// A marker is a claim, not evidence: restaging must repair.
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(fetches).toBe(2);
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
});
it("activates a verified prestaged release without a fetcher", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([4, 4, 4, 4]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/offline.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
const online = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
}),
});
const manifest = await manifestFor("offline-release", [asset], policy);
expect(await online.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
// STO-05. Activation and cleanup perform no network I/O, so a missing
// fetcher must not make an offline rollback UNSUPPORTED.
const offline = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
});
expect(
await offline.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
});
it("cleans exact owned caches without a fetcher", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const cacheStorage = new MemoryCacheStorage();
const offline = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
});
expect(await offline.admin.cleanupOwned()).toMatchObject({
ok: true,
});
});
it("stages, re-verifies and atomically activates an exact release", async () => {
const cacheStorage = new MemoryCacheStorage();
const bytes = new Uint8Array([1, 2, 3, 4]);
const url = "https://assets.example.test/app.js?v=release-1";
const asset: PublicCacheAsset = {
absoluteUrl: url,
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const policy: PublicCacheRuntimePolicy = {
...createDefaultPublicCachePolicy(
"https://assets.example.test",
),
allowedQueryParameterNames: ["v"],
isQueryParameterValueAllowed: (name, value) =>
name === "v" && /^release-\d+$/u.test(value),
};
const manifest = await manifestFor("release-1", [asset], policy);
const observations: unknown[] = [];
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
now: () => 10,
observer: (event) => observations.push(event),
fetcher: async () =>
new Response(bytes, {
status: 200,
headers: {
"cache-control": "public, max-age=31536000, immutable",
"content-type": "application/javascript",
"x-user-id": "must-not-be-persisted",
},
}),
});
const staged = await adapter.admin.stageRelease(manifest);
expect(staged).toEqual({
ok: true,
value: {
releaseRegistryId: "release-1",
entryCount: 1,
totalBytes: 4,
stagedAtEpochMs: 10,
},
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
const match = await adapter.responses.matchActiveExact({
absoluteUrl: url,
});
expect(match.ok).toBe(true);
if (!match.ok || !match.value) throw new Error("cache miss");
const chunks: number[] = [];
for await (const chunk of match.value.body.stream(
new AbortController().signal,
)) {
expect(chunk.ok).toBe(true);
if (chunk.ok) chunks.push(...chunk.value);
}
expect(chunks).toEqual([1, 2, 3, 4]);
expect(match.value.headers.map(([name]) => name)).not.toContain(
"x-user-id",
);
expect(
await adapter.responses.matchActiveExact({
absoluteUrl:
"https://assets.example.test/app.js?v=release-2",
}),
).toEqual({ ok: true, value: null });
expect(JSON.stringify(observations)).not.toContain(url);
expect(JSON.stringify(observations)).not.toContain(
manifest.manifestDigestHex,
);
});
it("snapshots the complete manifest before asynchronous digest verification", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([2, 4, 6, 8]);
const asset = {
absoluteUrl: "https://assets.example.test/snapshot-crypto.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256" as const,
digestHex: await digestHex(bytes),
},
};
const signed = await manifestFor(
"snapshot-crypto",
[asset],
policy,
);
const manifest = {
releaseRegistryId: signed.releaseRegistryId,
assets: [asset],
manifestDigestHex: signed.manifestDigestHex,
};
const digestStarted = deferred<void>();
const releaseDigest = deferred<void>();
let delayFirstDigest = true;
const realCrypto = globalThis.crypto;
const delayedCrypto = {
subtle: {
async digest(
algorithm: AlgorithmIdentifier,
data: BufferSource,
): Promise<ArrayBuffer> {
if (delayFirstDigest) {
delayFirstDigest = false;
digestStarted.resolve(undefined);
await releaseDigest.promise;
}
return await realCrypto.subtle.digest(algorithm, data);
},
},
} as unknown as Crypto;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: delayedCrypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
const staging = adapter.admin.stageRelease(manifest);
await digestStarted.promise;
manifest.releaseRegistryId = "mutated-during-crypto";
manifest.manifestDigestHex = "0".repeat(64);
manifest.assets[0]!.absoluteUrl =
"https://assets.example.test/mutated-crypto.js";
releaseDigest.resolve(undefined);
await expect(staging).resolves.toMatchObject({
ok: true,
value: { releaseRegistryId: "snapshot-crypto" },
});
await expect(
adapter.admin.activateRelease(
signed.releaseRegistryId,
signed.manifestDigestHex,
),
).resolves.toMatchObject({ ok: true });
});
it("keeps the original stage signal while digest verification is pending", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([8, 6, 4, 2]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/snapshot-signal.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor(
"snapshot-signal",
[asset],
policy,
);
const digestStarted = deferred<void>();
const releaseDigest = deferred<void>();
let delayFirstDigest = true;
const realCrypto = globalThis.crypto;
const delayedCrypto = {
subtle: {
async digest(
algorithm: AlgorithmIdentifier,
data: BufferSource,
): Promise<ArrayBuffer> {
if (delayFirstDigest) {
delayFirstDigest = false;
digestStarted.resolve(undefined);
await releaseDigest.promise;
}
return await realCrypto.subtle.digest(algorithm, data);
},
},
} as unknown as Crypto;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: delayedCrypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
const original = new AbortController();
const replacement = new AbortController();
const options = { signal: original.signal };
const staging = adapter.admin.stageRelease(manifest, options);
await digestStarted.promise;
options.signal = replacement.signal;
original.abort();
releaseDigest.resolve(undefined);
await expect(staging).resolves.toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
await expect(cacheStorage.keys()).resolves.toEqual([]);
});
it("keeps the original activation signal while waiting for the mutation lock", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([7]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/activate-signal.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor("activate-signal", [asset], policy);
const lockStarted = deferred<void>();
const releaseLock = deferred<void>();
let delayLock = false;
const lock: PublicCacheMutationLock = {
async run(_signal, task) {
if (delayLock) {
lockStarted.resolve(undefined);
await releaseLock.promise;
}
return await task();
},
};
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: lock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
await expect(adapter.admin.stageRelease(manifest)).resolves.toMatchObject({
ok: true,
});
delayLock = true;
const original = new AbortController();
const replacement = new AbortController();
const options = { signal: original.signal };
const activation = adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
options,
);
await lockStarted.promise;
options.signal = replacement.signal;
original.abort();
releaseLock.resolve(undefined);
await expect(activation).resolves.toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
expect(await cacheStorage.keys()).not.toContain("ca-public-v1:control");
});
it("keeps the original lookup signal while cache discovery is pending", async () => {
const cacheStorage = new MemoryCacheStorage();
const baseKeys = cacheStorage.keys.bind(cacheStorage);
const lookupStarted = deferred<void>();
const releaseLookup = deferred<void>();
let delayKeys = false;
cacheStorage.keys = async () => {
if (delayKeys) {
lookupStarted.resolve(undefined);
await releaseLookup.promise;
delayKeys = false;
}
return await baseKeys();
};
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([9]);
const absoluteUrl =
"https://assets.example.test/lookup-signal.js";
const asset: PublicCacheAsset = {
absoluteUrl,
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor("lookup-signal", [asset], policy);
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
await expect(adapter.admin.stageRelease(manifest)).resolves.toMatchObject({
ok: true,
});
await expect(
adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).resolves.toMatchObject({ ok: true });
delayKeys = true;
const original = new AbortController();
const replacement = new AbortController();
const request = { absoluteUrl, signal: original.signal };
const lookup = adapter.responses.matchActiveExact(request);
await lookupStarted.promise;
request.signal = replacement.signal;
original.abort();
releaseLookup.resolve(undefined);
await expect(lookup).resolves.toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
});
it("captures runtime dependencies when the adapter is composed", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([5, 4, 3, 2, 1]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/dependency-snapshot.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor(
"dependency-snapshot",
[asset],
policy,
);
const mutableSubtle = {
async digest(
algorithm: AlgorithmIdentifier,
data: BufferSource,
): Promise<ArrayBuffer> {
return await globalThis.crypto.subtle.digest(algorithm, data);
},
};
const mutableCrypto = {
subtle: mutableSubtle,
} as unknown as Crypto;
const mutationLock: PublicCacheMutationLock = {
async run(_signal, task) {
return await task();
},
};
const dependencies = {
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: mutableCrypto,
mutationLock,
policy,
fetcher: async () =>
new Response(bytes, {
status: 200,
headers: {
"cache-control": "public, max-age=31536000, immutable",
"content-type": "application/javascript",
},
}),
};
const adapter = createPublicResponseCacheAdapter(dependencies);
dependencies.fetcher = async () => {
throw new TypeError("mutated dependency must not be observed");
};
cacheStorage.keys = async () => {
throw new TypeError("mutated CacheStorage method must not be observed");
};
mutationLock.run = async () => {
throw new TypeError("mutated lock method must not be observed");
};
mutableSubtle.digest = async () => {
throw new TypeError("mutated crypto method must not be observed");
};
await expect(
adapter.admin.stageRelease(manifest),
).resolves.toMatchObject({
ok: true,
value: { releaseRegistryId: "dependency-snapshot" },
});
});
it("keeps the verified manifest snapshot while waiting for the mutation lock", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([1, 3, 5, 7]);
const asset = {
absoluteUrl: "https://assets.example.test/snapshot-lock.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256" as const,
digestHex: await digestHex(bytes),
},
};
const signed = await manifestFor("snapshot-lock", [asset], policy);
const manifest = {
releaseRegistryId: signed.releaseRegistryId,
assets: [asset],
manifestDigestHex: signed.manifestDigestHex,
};
const lockStarted = deferred<void>();
const releaseLock = deferred<void>();
const delayedLock: PublicCacheMutationLock = {
async run(_signal, task) {
lockStarted.resolve(undefined);
await releaseLock.promise;
return await task();
},
};
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: delayedLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
const staging = adapter.admin.stageRelease(manifest);
await lockStarted.promise;
manifest.releaseRegistryId = "mutated-during-lock";
manifest.manifestDigestHex = "f".repeat(64);
manifest.assets[0]!.absoluteUrl =
"https://assets.example.test/mutated-lock.js";
releaseLock.resolve(undefined);
await expect(staging).resolves.toMatchObject({
ok: true,
value: { releaseRegistryId: "snapshot-lock" },
});
await expect(
adapter.admin.activateRelease(
signed.releaseRegistryId,
signed.manifestDigestHex,
),
).resolves.toMatchObject({ ok: true });
});
it("fails closed for private responses and deletes the incomplete candidate", async () => {
const cacheStorage = new MemoryCacheStorage();
const bytes = new Uint8Array([9]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/private.json",
expectedByteLength: 1,
expectedContentType: "application/json",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor("release-private", [asset]);
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy: createDefaultPublicCachePolicy(
"https://assets.example.test",
),
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "private, max-age=60",
"content-type": "application/json",
},
}),
});
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
expect(await cacheStorage.keys()).toEqual([]);
});
it("binds the exact expected Content-Type into the signed manifest and cached response", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([7, 7]);
const baseAsset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/content.bin",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/json",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const jsonManifest = await manifestFor(
"content-json",
[baseAsset],
policy,
);
const javascriptManifest = await manifestFor(
"content-js",
[
{
...baseAsset,
expectedContentType: "application/javascript",
},
],
policy,
);
expect(jsonManifest.manifestDigestHex).not.toBe(
javascriptManifest.manifestDigestHex,
);
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
expect(
await adapter.admin.stageRelease(jsonManifest),
).toMatchObject({
ok: false,
error: { code: "INTEGRITY_FAILED" },
});
expect(await cacheStorage.keys()).toEqual([]);
});
it("deletes only caches owned by its prefix", async () => {
const cacheStorage = new MemoryCacheStorage();
await cacheStorage.open("unrelated-cache");
await cacheStorage.open("ca-public-v1:stale");
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy: createDefaultPublicCachePolicy(
"https://assets.example.test",
),
fetcher: async () => new Response(),
});
expect(await adapter.admin.cleanupOwned()).toMatchObject({
ok: true,
value: { deletedOwnedCaches: 1 },
});
expect(await cacheStorage.keys()).toContain("unrelated-cache");
});
it("does not let callers retain an unreferenced staged release", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([3, 1, 4]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/orphan.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor("orphan-release", [asset], policy);
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
}),
});
await expect(adapter.admin.stageRelease(manifest)).resolves.toMatchObject({
ok: true,
});
const legacyCallerRetention = {
retainReleaseRegistryIds: [manifest.releaseRegistryId],
} as unknown as Parameters<
typeof adapter.admin.cleanupOwned
>[0];
await expect(
adapter.admin.cleanupOwned(legacyCallerRetention),
).resolves.toMatchObject({
ok: true,
value: {
deletedOwnedCaches: 1,
retainedOwnedCaches: 0,
},
});
await expect(cacheStorage.keys()).resolves.toEqual([]);
});
it("keeps the original cleanup signal while waiting for the mutation lock", async () => {
const cacheStorage = new MemoryCacheStorage();
await cacheStorage.open("ca-public-v1:stale");
const lockStarted = deferred<void>();
const releaseLock = deferred<void>();
const lock: PublicCacheMutationLock = {
async run(_signal, task) {
lockStarted.resolve(undefined);
await releaseLock.promise;
return await task();
},
};
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: lock,
policy: createDefaultPublicCachePolicy(
"https://assets.example.test",
),
fetcher: async () => new Response(),
});
const original = new AbortController();
const replacement = new AbortController();
const request = { signal: original.signal };
const cleanup = adapter.admin.cleanupOwned(request);
await lockStarted.promise;
request.signal = replacement.signal;
original.abort();
releaseLock.resolve(undefined);
await expect(cleanup).resolves.toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
await expect(cacheStorage.keys()).resolves.toContain(
"ca-public-v1:stale",
);
});
it("cancels oversized streamed control JSON before reading the remainder", async () => {
let pulls = 0;
let cancelled = false;
const oversizedControlResponse = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
if (pulls === 1) {
controller.enqueue(new Uint8Array(1_500_000));
return;
}
if (pulls === 2) {
controller.enqueue(new Uint8Array(1_000_000));
return;
}
controller.enqueue(new Uint8Array([1]));
},
cancel() {
cancelled = true;
},
}),
{
headers: { "content-type": "application/json" },
},
);
const candidate = {
async match(): Promise<Response> {
return oversizedControlResponse;
},
async put(): Promise<void> {},
};
const cacheStorage = {
async open(): Promise<Cache> {
return candidate as unknown as Cache;
},
async keys(): Promise<string[]> {
return ["ca-public-v1:release:oversized:control"];
},
async delete(): Promise<boolean> {
return false;
},
};
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
policy: createDefaultPublicCachePolicy(
"https://assets.example.test",
),
});
await expect(adapter.admin.inspect()).resolves.toMatchObject({
ok: true,
value: {
ownedCacheCount: 1,
unreadableOwnedCacheCount: 1,
},
});
// A stream may prefetch one chunk at its high-water mark, but the
// consumer must not continue pulling the remaining source.
expect(pulls).toBeLessThanOrEqual(3);
expect(cancelled).toBe(true);
});
it("retains the active and previous verified release for rollback", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bodies = new Map<string, Uint8Array>();
const manifests: PublicCacheReleaseManifest[] = [];
for (const release of ["release-a", "release-b"]) {
const url = `https://assets.example.test/${release}.js`;
const bytes = new TextEncoder().encode(release);
bodies.set(url, bytes);
const asset: PublicCacheAsset = {
absoluteUrl: url,
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
manifests.push(await manifestFor(release, [asset], policy));
}
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request) =>
new Response(
Uint8Array.from(
bodies.get(request.url) ?? new Uint8Array(),
),
{
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/javascript",
},
},
),
});
for (const manifest of manifests) {
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
}
await cacheStorage.open("ca-public-v1:stale");
const cleaned = await adapter.admin.cleanupOwned();
expect(cleaned).toMatchObject({
ok: true,
value: {
deletedOwnedCaches: 1,
retainedOwnedCaches: 3,
},
});
const releaseCaches = (await cacheStorage.keys()).filter((name) =>
name.startsWith("ca-public-v1:release:"),
);
expect(releaseCaches).toHaveLength(2);
});
it("requires allowlisted exact query values and rejects duplicates", async () => {
const base = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const policy: PublicCacheRuntimePolicy = {
...base,
allowedQueryParameterNames: ["v"],
isQueryParameterValueAllowed: (name, value) =>
name === "v" && /^r\d+$/u.test(value),
};
const bytes = new Uint8Array([1]);
const allowed: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/app.js?v=r1",
expectedByteLength: 1,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
await expect(
manifestFor(
"duplicate-query",
[
{
...allowed,
absoluteUrl:
"https://assets.example.test/app.js?v=r1&v=r2",
},
],
policy,
),
).rejects.toThrow();
await expect(
manifestFor(
"signed-query",
[
{
...allowed,
absoluteUrl:
"https://assets.example.test/app.js?sig=secret",
},
],
policy,
),
).rejects.toThrow();
});
it("supports exact Vary variants and rejects missing or extra Vary", async () => {
const cacheStorage = new MemoryCacheStorage();
const base = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const policy: PublicCacheRuntimePolicy = {
...base,
allowedVaryHeaderNames: ["accept-language"],
};
const url = "https://assets.example.test/messages.json";
const variants = [
{ language: "en", bytes: new TextEncoder().encode("hello") },
{ language: "ko", bytes: new TextEncoder().encode("안녕") },
];
const assets = await Promise.all(
variants.map(async ({ language, bytes }) => ({
absoluteUrl: url,
expectedByteLength: bytes.byteLength,
expectedContentType: "application/json",
requestHeaders: [["accept-language", language]] as const,
integrity: {
algorithm: "SHA-256" as const,
digestHex: await digestHex(bytes),
},
})),
);
const manifest = await manifestFor("language-release", assets, policy);
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request) => {
const variant = variants.find(
({ language }) =>
language === request.headers.get("accept-language"),
);
return new Response(variant?.bytes, {
headers: {
"cache-control": "public, max-age=60",
"content-type": "application/json",
vary: "Accept-Language",
},
});
},
});
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
for (const variant of variants) {
const match = await adapter.responses.matchActiveExact({
absoluteUrl: url,
requestHeaders: [["accept-language", variant.language]],
});
expect(match).toMatchObject({ ok: true });
if (!match.ok || !match.value) throw new Error("variant cache miss");
const chunks: number[] = [];
for await (const chunk of match.value.body.stream(
new AbortController().signal,
)) {
expect(chunk.ok).toBe(true);
if (chunk.ok) chunks.push(...chunk.value);
}
expect(chunks).toEqual([...variant.bytes]);
}
const missingVary = createPublicResponseCacheAdapter({
cacheStorage: new MemoryCacheStorage() as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(variants[0]!.bytes, {
headers: {
"cache-control": "public",
"content-type": "application/json",
},
}),
});
expect(
await missingVary.admin.stageRelease(
await manifestFor("missing-vary", [assets[0]!], policy),
),
).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
const noHeaderAsset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/plain.json",
expectedByteLength: variants[0]!.bytes.byteLength,
expectedContentType: "application/json",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(variants[0]!.bytes),
},
};
const extraVary = createPublicResponseCacheAdapter({
cacheStorage: new MemoryCacheStorage() as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(variants[0]!.bytes, {
headers: {
"cache-control": "public",
"content-type": "application/json",
vary: "Accept-Language",
},
}),
});
expect(
await extraVary.admin.stageRelease(
await manifestFor("extra-vary", [noHeaderAsset], policy),
),
).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
});
it("propagates AbortSignal to fetch and cancels a pending body read", async () => {
const cacheStorage = new MemoryCacheStorage();
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([1]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/slow.js",
expectedByteLength: 1,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const manifest = await manifestFor("abort-release", [asset], policy);
let networkSignal: AbortSignal | undefined;
let bodyCancelled = false;
let signalCaptured: (() => void) | undefined;
const fetchStarted = new Promise<void>((resolve) => {
signalCaptured = resolve;
});
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request) => {
networkSignal = request.signal;
signalCaptured?.();
return new Response(
new ReadableStream<Uint8Array>({
cancel() {
bodyCancelled = true;
},
}),
{
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
},
);
},
});
const controller = new AbortController();
const staged = adapter.admin.stageRelease(manifest, {
signal: controller.signal,
});
await fetchStarted;
controller.abort();
expect(await staged).toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
expect(networkSignal?.aborted).toBe(true);
expect(bodyCancelled).toBe(true);
expect(await cacheStorage.keys()).toEqual([]);
});
});
/**
* 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<string, Uint8Array>([
[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 });
});
});