Files
clean-architecture-frontend…/tests/unit/service-worker-web-push-remediation.test.ts
T
DongHyeonkaandClaude Opus 5 d7b35cfca3 fix: bound the activation marker in bytes and give a click one observer
The marker read added a whole chunk to a running total and compared the
total afterwards, so a corrupt body could hand activation a 1 MiB chunk
against a 257-byte ceiling. It now reads at most the remaining allowance —
through a BYOB reader where the source offers one, and by refusing an
oversized chunk before copying it otherwise. A declared oversize cancels
the body it refuses instead of leaving the stream open, and the reader
lock is released on every path.

The build generator and the runtime decoder shared only the extension
table, not the path grammar. The generator happily emitted
`/assets/bad@name-abcdefgh.js`, which the decoder then refused — a correct
build failing at install time. Both now use one exported canonical path
predicate and the generator decodes its own output before returning it.

The notification click handler emitted its terminal record from inside
`process` and again from the `waitUntil` wrapper, so an ordinary click was
counted twice. Worse, a late rejection downgraded `MAYBE_APPLIED` to
`NOT_APPLIED` — telling operators the click had definitely not been
applied when nobody knew that — and the late observation ran outside
`waitUntil`, so a worker shutdown lost the evidence. There is one
observation authority per click now, certainty is monotone, only an
explicit null window confirms `NOT_APPLIED`, and the bounded tail is owned
by `waitUntil` without extending the public deadline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:26:14 +09:00

177 lines
5.9 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
CACHEABLE_ASSET_CONTENT_TYPES,
decodeStaticAssetManifest,
isCanonicalStaticAssetUrl,
} from "../../src/contracts/service-worker-static-manifest.ts";
/**
* SW-RR-03. The build generator and the shared runtime decoder must agree on
* exactly which asset kinds exist. A generator that emits `.mjs` or `.png` while
* the decoder refuses them turns a correct build into a runtime contract
* failure, and the reverse admits a kind no build produces.
*/
describe("SW-RR-03 one authoritative cacheable asset table", () => {
it("covers every extension the generator emits", async () => {
const generator = await import(
"../../scripts/generate-service-worker-assets.ts"
);
expect(generator).toBeDefined();
for (const extension of [
".js",
".mjs",
".css",
".woff2",
".svg",
".png",
".webp",
]) {
expect(CACHEABLE_ASSET_CONTENT_TYPES[extension]).toBeDefined();
}
});
it("decodes a manifest row for every table entry", () => {
const assets = Object.entries(CACHEABLE_ASSET_CONTENT_TYPES).map(
([extension, contentType], index) => ({
url: `/assets/name-abcdefgh${index}${extension}`,
sha256: `sha256:${"a".repeat(64)}`,
bytes: 16,
contentType,
}),
);
const decoded = decodeStaticAssetManifest({
schemaVersion: 1,
buildId: "build-1",
releaseId: "release-1",
assets,
setDigest: `sha256:${"b".repeat(64)}`,
});
expect(decoded).toMatchObject({ ok: true });
});
it("refuses a row whose extension is not in the table", () => {
const decoded = decodeStaticAssetManifest({
schemaVersion: 1,
buildId: "build-1",
releaseId: "release-1",
setDigest: `sha256:${"b".repeat(64)}`,
assets: [
{
url: "/assets/control-abcdefgh.json",
sha256: `sha256:${"a".repeat(64)}`,
bytes: 16,
contentType: "application/json",
},
],
});
expect(decoded.ok).toBe(false);
});
});
/**
* SW-02. Sharing only the extension table left the generator and the decoder
* with different path grammars: the generator emitted a URL for a directory
* containing an `@` or a space, and the decoder then refused the manifest it
* had just produced, failing the release build at install time.
*/
describe("SW-02 the generator and the decoder share one path grammar", () => {
async function distWith(
files: Readonly<Record<string, string>>,
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), "sw-assets-"));
for (const [relative, content] of Object.entries(files)) {
const absolute = path.join(root, relative);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, content);
}
return root;
}
it("emits a manifest the runtime decoder accepts for every table entry", async () => {
const { collectStaticAssets } = await import(
"../../scripts/generate-service-worker-assets.ts"
);
const files: Record<string, string> = {};
for (const [index, extension] of Object.keys(
CACHEABLE_ASSET_CONTENT_TYPES,
).entries()) {
files[`assets/name-abcdefg${index}${extension}`] = `content-${index}`;
}
files["assets/nested/deep/name-abcdefgz.js"] = "nested";
const root = await distWith(files);
const manifest = await collectStaticAssets(root, "build-1", "release-1");
expect(manifest.assets.length).toBe(
Object.keys(CACHEABLE_ASSET_CONTENT_TYPES).length + 1,
);
expect(decodeStaticAssetManifest(manifest)).toMatchObject({ ok: true });
for (const asset of manifest.assets) {
expect(isCanonicalStaticAssetUrl(asset.url)).toBe(true);
}
});
it.each([
{ label: "an at sign", name: "bad@name-abcdefgh.js" },
{ label: "a space", name: "bad name-abcdefgh.js" },
{ label: "a percent escape", name: "bad%20name-abcdefgh.js" },
{ label: "a hash", name: "bad#name-abcdefgh.js" },
])(
"stops the build rather than emitting a path the decoder refuses ($label)",
async ({ name }) => {
const { collectStaticAssets } = await import(
"../../scripts/generate-service-worker-assets.ts"
);
const root = await distWith({
"assets/good-abcdefgh.js": "ok",
[`assets/${name}`]: "bad",
});
await expect(
collectStaticAssets(root, "build-1", "release-1"),
).rejects.toThrow(/not canonical/u);
},
);
it("agrees with the decoder on the whole path policy table", () => {
const cases: readonly (readonly [string, boolean])[] = [
["/assets/name-abcdefgh.js", true],
["/assets/nested/name-abcdefgh.js", true],
["/assets/bad@name-abcdefgh.js", false],
["/assets/bad name-abcdefgh.js", false],
["/assets/bad%20name-abcdefgh.js", false],
["/assets/bad#name-abcdefgh.js", false],
["/assets/../name-abcdefgh.js", false],
["/assets/./name-abcdefgh.js", false],
["assets/name-abcdefgh.js", false],
["/assets/\\name-abcdefgh.js", false],
["/assets/naïve-abcdefgh.js", false],
];
for (const [url, canonical] of cases) {
expect(isCanonicalStaticAssetUrl(url), url).toBe(canonical);
const decoded = decodeStaticAssetManifest({
schemaVersion: 1,
buildId: "build-1",
releaseId: "release-1",
setDigest: `sha256:${"a".repeat(64)}`,
assets: [
{
url,
sha256: `sha256:${"a".repeat(64)}`,
bytes: 4,
contentType: "text/javascript",
},
],
});
// The decoder still owns the digest check, so a canonical path may fail
// for other reasons; a non-canonical one must always fail.
if (!canonical) expect(decoded.ok, url).toBe(false);
}
});
});