The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1712 lines
54 KiB
TypeScript
1712 lines
54 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([]);
|
|
});
|
|
|
|
/**
|
|
* NS-08. Handing the signal to each `Request` only asked a cooperative fetch
|
|
* to stop. A stream that ignored it held the mutation lock forever, and work
|
|
* that finished after the abort still wrote its asset and its marker.
|
|
*/
|
|
it("does not wait for a non-cooperative fetch after the caller aborts", async () => {
|
|
const cacheStorage = new MemoryCacheStorage();
|
|
const policy = createDefaultPublicCachePolicy(
|
|
"https://assets.example.test",
|
|
);
|
|
const bytes = new Uint8Array([1, 2, 3, 4]);
|
|
const asset: PublicCacheAsset = {
|
|
absoluteUrl: "https://assets.example.test/never-settles.js",
|
|
expectedByteLength: bytes.byteLength,
|
|
expectedContentType: "application/javascript",
|
|
integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes) },
|
|
};
|
|
const manifest = await manifestFor("never-settles", [asset], policy);
|
|
const fetchStarted = deferred<void>();
|
|
let locksHeld = 0;
|
|
const adapter = createPublicResponseCacheAdapter({
|
|
cacheStorage: cacheStorage as unknown as CacheStorage,
|
|
crypto: globalThis.crypto,
|
|
mutationLock: {
|
|
async run(signal, operation) {
|
|
locksHeld += 1;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
locksHeld -= 1;
|
|
}
|
|
},
|
|
},
|
|
policy,
|
|
fetcher: () => {
|
|
fetchStarted.resolve(undefined);
|
|
// Ignores the signal entirely.
|
|
return new Promise<Response>(() => {});
|
|
},
|
|
});
|
|
const controller = new AbortController();
|
|
|
|
const staging = adapter.admin.stageRelease(manifest, {
|
|
signal: controller.signal,
|
|
});
|
|
await fetchStarted.promise;
|
|
controller.abort();
|
|
|
|
await expect(staging).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED" },
|
|
});
|
|
expect(locksHeld).toBe(0);
|
|
await expect(cacheStorage.keys()).resolves.toEqual([]);
|
|
});
|
|
|
|
it("writes neither asset nor marker when a digest completes after the abort", async () => {
|
|
const cacheStorage = new MemoryCacheStorage();
|
|
const policy = createDefaultPublicCachePolicy(
|
|
"https://assets.example.test",
|
|
);
|
|
const bytes = new Uint8Array([9, 9, 9, 9]);
|
|
const asset: PublicCacheAsset = {
|
|
absoluteUrl: "https://assets.example.test/late-digest.js",
|
|
expectedByteLength: bytes.byteLength,
|
|
expectedContentType: "application/javascript",
|
|
integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes) },
|
|
};
|
|
const manifest = await manifestFor("late-digest", [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;
|
|
let puts = 0;
|
|
const adapter = createPublicResponseCacheAdapter({
|
|
cacheStorage: new Proxy(cacheStorage, {
|
|
get(target, key, receiver) {
|
|
if (key === "open") {
|
|
return async (name: string) => {
|
|
const cache = await target.open(name);
|
|
return new Proxy(cache, {
|
|
get(cacheTarget, cacheKey, cacheReceiver) {
|
|
if (cacheKey === "put") {
|
|
return async (...args: readonly unknown[]) => {
|
|
puts += 1;
|
|
return await (
|
|
cacheTarget.put as (
|
|
...values: readonly unknown[]
|
|
) => Promise<void>
|
|
)(...args);
|
|
};
|
|
}
|
|
return Reflect.get(cacheTarget, cacheKey, cacheReceiver);
|
|
},
|
|
});
|
|
};
|
|
}
|
|
return Reflect.get(target, key, receiver);
|
|
},
|
|
}) 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 controller = new AbortController();
|
|
|
|
const staging = adapter.admin.stageRelease(manifest, {
|
|
signal: controller.signal,
|
|
});
|
|
await digestStarted.promise;
|
|
controller.abort();
|
|
releaseDigest.resolve(undefined);
|
|
|
|
await expect(staging).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED" },
|
|
});
|
|
// Neither the asset nor the activation marker may be written by work the
|
|
// abort already disowned.
|
|
expect(puts).toBe(0);
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|