fix: make public cache staging repairable
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ba79060a83
commit
b893d95b36
@@ -6,6 +6,7 @@ import type {
|
||||
} 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 {
|
||||
@@ -140,6 +141,159 @@ async function manifestFor(
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user