STO-03: reject at composition any policy that enables Vary variants while stripping vary from the stored response allowlist, since every stored variant would collide on the same cache key. STO-04: extract one verifyReleaseCandidate authority shared by the stage fast path and activation. A matching release marker is a claim, not evidence, so a restage now re-verifies each entry, deletes only the owned candidate on a mismatch and refetches. Abort or an unreadable candidate is never stage success and never moves the active pointer. STO-05: split the availability guard. Staging keeps the fetcher requirement with ONLINE_ONLY recovery; activation, rollback and cleanup need only cache storage and the mutation lock, so an offline rollback or quota-recovery cleanup is no longer reported UNSUPPORTED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1372 lines
43 KiB
TypeScript
1372 lines
43 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([]);
|
|
});
|
|
});
|