diff --git a/scripts/generate-service-worker-assets.ts b/scripts/generate-service-worker-assets.ts index 164ea5c..7586c95 100644 --- a/scripts/generate-service-worker-assets.ts +++ b/scripts/generate-service-worker-assets.ts @@ -3,6 +3,8 @@ import { createHash } from "node:crypto"; import { CACHEABLE_ASSET_CONTENT_TYPES, canonicalStaticManifestBytes, + decodeStaticAssetManifest, + isCanonicalStaticAssetUrl, } from "../src/contracts/service-worker-static-manifest.ts"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -57,8 +59,17 @@ export async function collectStaticAssets( if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) { throw new Error(`Static asset exceeds its byte bound: ${relative}`); } + // SW-02. The URL is checked against the same predicate the runtime decoder + // applies. Emitting a path the decoder will refuse turned a correct build + // into a runtime contract failure discovered only at install time. + const url = `/${relative.split(path.sep).join("/")}`; + if (!isCanonicalStaticAssetUrl(url)) { + throw new Error( + `Static asset path is not canonical for the service worker manifest: ${relative}`, + ); + } assets.push({ - url: `/${relative.split(path.sep).join("/")}`, + url, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, bytes: bytes.byteLength, contentType, @@ -79,13 +90,23 @@ export async function collectStaticAssets( .update(canonicalStaticManifestBytes(assets)) .digest("hex")}`; - return { + const manifest: StaticAssetManifestV1 = { schemaVersion: 1, buildId, releaseId, setDigest, assets, }; + // SW-02. Every manifest this generator returns has already passed the exact + // decoder the runtime will apply to it, so the build stops here rather than + // at install time. + const decoded = decodeStaticAssetManifest(manifest); + if (!decoded.ok) { + throw new Error( + `Generated service worker manifest is not decodable: ${decoded.error.reason}`, + ); + } + return manifest; } diff --git a/src/adapters/service-worker/service-worker-lifecycle.ts b/src/adapters/service-worker/service-worker-lifecycle.ts index 7a700af..bae8ebf 100644 --- a/src/adapters/service-worker/service-worker-lifecycle.ts +++ b/src/adapters/service-worker/service-worker-lifecycle.ts @@ -501,47 +501,119 @@ async function readBoundedMarkerText( return null; } } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; + // SW-01. The allowance is `limit + 1` bytes: one byte past the ceiling is + // enough to prove the marker is oversized, and nothing beyond it is ever + // retained. A BYOB reader asks the source for exactly the remaining + // allowance, so a corrupt body cannot answer a 1 MiB chunk to a 257-byte + // request. Without BYOB the first oversized chunk is refused outright rather + // than copied and then measured. + const allowance = ACTIVATION_MARKER_MAX_BYTES + 1; + const reader = byobReader(response.body) ?? response.body.getReader(); + const byob = "read" in reader && isByobReader(reader); + const bytes = new Uint8Array(allowance); let total = 0; let timer: ReturnType | undefined; + let cancelled = false; const deadline = new Promise<"DEADLINE">((resolve) => { - timer = setTimeout( - () => resolve("DEADLINE"), - ACTIVATION_MARKER_READ_DEADLINE_MS, - ); - }); - try { - for (;;) { - const next = await Promise.race([reader.read(), deadline]); - if (next === "DEADLINE") return null; - if (next.done) break; - if (!next.value) continue; - total += next.value.byteLength; - if (total > ACTIVATION_MARKER_MAX_BYTES) return null; - chunks.push(next.value); + try { + timer = setTimeout( + () => resolve("DEADLINE"), + ACTIVATION_MARKER_READ_DEADLINE_MS, + ); + } catch { + // An unschedulable deadline leaves the read unbounded, so it ends now. + resolve("DEADLINE"); } - } catch { - return null; - } finally { - if (timer !== undefined) clearTimeout(timer); + }); + const cancelOnce = (): void => { + if (cancelled) return; + cancelled = true; // Never awaited: cancelling a stream whose source ignores cancellation can // itself hang, and the marker read already has its answer. - void reader.cancel().catch(() => {}); - } - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } + try { + void reader.cancel().catch(() => {}); + } catch { + // A hostile reader cannot block the release below. + } + }; try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + for (;;) { + const remaining = allowance - total; + if (remaining <= 0) { + // More than the ceiling has already arrived. + return null; + } + const pending = byob + ? (reader as ReadableStreamBYOBReader).read( + new Uint8Array(remaining), + ) + : (reader as ReadableStreamDefaultReader).read(); + const next = await Promise.race([pending, deadline]); + if (next === "DEADLINE") { + cancelOnce(); + return null; + } + if (next.done) break; + const chunk = next.value; + if (!chunk) continue; + if (chunk.byteLength > remaining) { + // Refused before it is retained: the oversized chunk is not copied. + cancelOnce(); + return null; + } + bytes.set(chunk, total); + total += chunk.byteLength; + } + } catch { + cancelOnce(); + return null; + } finally { + if (timer !== undefined) { + try { + clearTimeout(timer); + } catch { + // Releasing the timer is best effort. + } + } + cancelOnce(); + try { + reader.releaseLock(); + } catch { + // A cancelled reader has already released its lock. + } + } + if (total > ACTIVATION_MARKER_MAX_BYTES) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, total), + ); } catch { return null; } } +/** A byte stream can hand out a BYOB reader; a regular one cannot. */ +function byobReader( + body: ReadableStream, +): ReadableStreamBYOBReader | null { + try { + return ( + body as ReadableStream & { + getReader(options: { mode: "byob" }): ReadableStreamBYOBReader; + } + ).getReader({ mode: "byob" }); + } catch { + return null; + } +} + +function isByobReader(reader: unknown): reader is ReadableStreamBYOBReader { + return ( + typeof ReadableStreamBYOBReader === "function" && + reader instanceof ReadableStreamBYOBReader + ); +} + async function readActivationMarker( cache: Cache, expectedCacheName: string, @@ -555,6 +627,14 @@ async function readActivationMarker( (!/^\d+$/u.test(declaredLength) || Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES) ) { + // SW-01. A declared oversize ends the read, but the body it declared is + // still an open stream: returning without cancelling it left the source + // holding the connection for the rest of the worker's life. + try { + void response.body?.cancel().catch(() => {}); + } catch { + // Cancelling is best effort and cannot change the closed outcome. + } return null; } // SW-RR-01. A Content-Length is a claim, not a bound. Without one the diff --git a/src/adapters/web-push/inbound/notification-click-adapter.ts b/src/adapters/web-push/inbound/notification-click-adapter.ts index 490ca24..ec6d391 100644 --- a/src/adapters/web-push/inbound/notification-click-adapter.ts +++ b/src/adapters/web-push/inbound/notification-click-adapter.ts @@ -51,6 +51,18 @@ export type NotificationClickAdapter = Readonly<{ */ const ABORT_OWNED = Symbol("web-push-click-aborted"); +/** + * WP-01. Per-click observation state: the current native-effect certainty and + * the bounded tail tasks that observe an effect landing after the terminal + * result. `waitUntil` owns the tails so the worker cannot be terminated before + * the evidence lands, and the certainty is monotone from `NOT_APPLIED` through + * `MAYBE_APPLIED` to `CONFIRMED`. + */ +type ClickEffectState = { + certainty: WebPushNativeEffectCertainty; + tails: Promise[]; +}; + async function raceAbort( operation: Promise, signal: AbortSignal, @@ -103,8 +115,17 @@ export function createNotificationClickAdapter(dependencies: Readonly<{ const taskControl = createLinkedAbortController( dependencies.signal, ); + // WP-01. One observation authority per click. The terminal record was + // emitted both inside `process` and again here, so an ordinary click was + // counted twice, and the native-effect evidence was detached from + // `waitUntil` entirely — a worker that shut down after the terminal + // result simply lost it. + const effect: ClickEffectState = { + certainty: "NOT_APPLIED", + tails: [], + }; const processing = withAbortableDeadline( - (signal) => process(event.notification.data, signal), + (signal) => process(event.notification.data, signal, effect), { deadlineMs: handlerDeadlineMs, operation: "NOTIFICATION_CLICK", @@ -114,12 +135,16 @@ export function createNotificationClickAdapter(dependencies: Readonly<{ ).finally(taskControl.dispose); try { event.waitUntil( - processing.then((result) => { + processing.then(async (result) => { observeWebPush(dependencies.observer, { event: "web_push_click_dispatched", outcome: result.ok ? "SUCCEEDED" : "FAILED", ...(result.ok ? {} : { reason: result.error.code }), + nativeEffect: effect.certainty, }); + // The late-effect observation is this handler's own work, so the + // worker stays alive for it without extending the public deadline. + await Promise.allSettled(effect.tails); }), ); } catch { @@ -133,6 +158,7 @@ export function createNotificationClickAdapter(dependencies: Readonly<{ async function process( data: unknown, signal: AbortSignal, + effect: ClickEffectState, ): Promise> { const decoded = decodeNotificationClickData(data, now()); if (!decoded.ok) return decoded; @@ -186,33 +212,41 @@ export function createNotificationClickAdapter(dependencies: Readonly<{ // CONFIRMED on fulfilment. An effect that lands after this handler's // deadline is still observed exactly once — as evidence only, never as // authorization to retry. - let nativeEffect: WebPushNativeEffectCertainty = "NOT_APPLIED"; const observeLateEffect = ( pending: Promise, - succeeded: (value: unknown) => boolean, + appliedWhen: (value: unknown) => boolean, ): void => { let observed = false; - void pending.then( - (value) => { - if (observed) return; - observed = true; - observeWebPush(dependencies.observer, { - event: "web_push_click_dispatched", - outcome: succeeded(value) ? "DEGRADED" : "FAILED", - reason: "ABORTED", - nativeEffect: succeeded(value) ? "CONFIRMED" : "NOT_APPLIED", - }); - }, - () => { - if (observed) return; - observed = true; - observeWebPush(dependencies.observer, { - event: "web_push_click_dispatched", - outcome: "FAILED", - reason: "ABORTED", - nativeEffect: "NOT_APPLIED", - }); - }, + // WP-01. The tail is tracked so `waitUntil` owns it. Certainty is + // monotone: once the native call has been made the effect can only be + // confirmed or stay uncertain. A rejection says the call did not report + // success, not that it never happened, so downgrading it to NOT_APPLIED + // told operators the click had definitely not been applied. + effect.tails.push( + pending.then( + (value) => { + if (observed) return; + observed = true; + const applied = appliedWhen(value); + effect.certainty = applied ? "CONFIRMED" : "NOT_APPLIED"; + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: applied ? "DEGRADED" : "FAILED", + reason: "ABORTED", + nativeEffect: effect.certainty, + }); + }, + () => { + if (observed) return; + observed = true; + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: "DEGRADED", + reason: "ABORTED", + nativeEffect: "MAYBE_APPLIED", + }); + }, + ), ); }; try { @@ -222,56 +256,38 @@ export function createNotificationClickAdapter(dependencies: Readonly<{ if (existing) { existing.postMessage(handoff); const focused = Promise.resolve(existing.focus()); - nativeEffect = "MAYBE_APPLIED"; + effect.certainty = "MAYBE_APPLIED"; const raced = await raceAbort(focused, signal); if (raced === ABORT_OWNED) { observeLateEffect(focused, () => true); return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); } - nativeEffect = "CONFIRMED"; + effect.certainty = "CONFIRMED"; } else { const opening = Promise.resolve( dependencies.clients.openWindow(target), ); - nativeEffect = "MAYBE_APPLIED"; + effect.certainty = "MAYBE_APPLIED"; const raced = await raceAbort(opening, signal); if (raced === ABORT_OWNED) { observeLateEffect(opening, (value) => value !== null); return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); } if (!raced) { - nativeEffect = "NOT_APPLIED"; - observeWebPush(dependencies.observer, { - event: "web_push_click_dispatched", - outcome: "FAILED", - nativeEffect, - }); + // An explicit null is the one answer that confirms no window opened. + effect.certainty = "NOT_APPLIED"; return nativeFailure("NOTIFICATION_CLICK", true); } - nativeEffect = "CONFIRMED"; + effect.certainty = "CONFIRMED"; } if (signal.aborted) { - observeWebPush(dependencies.observer, { - event: "web_push_click_dispatched", - outcome: "DEGRADED", - reason: "ABORTED", - nativeEffect, - }); return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); } } catch { - observeWebPush(dependencies.observer, { - event: "web_push_click_dispatched", - outcome: "FAILED", - nativeEffect, - }); + // The native call threw, so it never reported success; whether it took + // effect is unknown rather than settled. return nativeFailure("NOTIFICATION_CLICK", true); } - observeWebPush(dependencies.observer, { - event: "web_push_click_dispatched", - outcome: "SUCCEEDED", - nativeEffect, - }); return webPushSuccess(undefined); } diff --git a/src/contracts/service-worker-static-manifest.ts b/src/contracts/service-worker-static-manifest.ts index 1f84e52..86c2f05 100644 --- a/src/contracts/service-worker-static-manifest.ts +++ b/src/contracts/service-worker-static-manifest.ts @@ -71,6 +71,22 @@ const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; /** Root-relative, hashed, no dot segments, no query and no fragment. */ const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u; +/** + * SW-02. The one canonical asset-path predicate, shared by the build generator + * and this decoder. Sharing only the extension table left the two with + * different path grammars: the generator emitted a URL for a directory + * containing a space, an `@` or a percent-escape, and the decoder then refused + * the manifest it had just produced, failing the release build. + */ +export function isCanonicalStaticAssetUrl(url: string): boolean { + return ( + typeof url === "string" && + ASSET_URL.test(url) && + !url.includes("/../") && + !url.includes("/./") + ); +} + export type StaticManifestDecodeFailure = Readonly<{ reason: string; }>; @@ -135,12 +151,9 @@ export function decodeStaticAssetManifest( const row = exactKeys(candidate, ASSET_ROW_KEYS); if (!row) return failure("asset row keys are not exact"); const { url, sha256, bytes, contentType } = row; - if (typeof url !== "string" || !ASSET_URL.test(url)) { + if (typeof url !== "string" || !isCanonicalStaticAssetUrl(url)) { return failure("asset url must be root-relative without dot segments"); } - if (url.includes("/../") || url.includes("/./")) { - return failure("asset url must not contain dot segments"); - } if (seen.has(url)) return failure("asset urls must be unique"); // A sorted set makes the canonical bytes independent of directory order. if (previousUrl !== null && url <= previousUrl) { diff --git a/tests/unit/service-worker-runtime.test.ts b/tests/unit/service-worker-runtime.test.ts index 754dd74..2e44992 100644 --- a/tests/unit/service-worker-runtime.test.ts +++ b/tests/unit/service-worker-runtime.test.ts @@ -916,3 +916,141 @@ describe("service worker static asset install", () => { await controller.stop(); }); }); + +/** + * SW-01. The marker read must be bounded in bytes, not only in logic. Adding a + * whole chunk and *then* comparing the running total meant a corrupt body could + * hand activation a 1 MiB chunk against a 257-byte ceiling, and the reader lock + * was never released. + */ +describe("SW-01 the activation marker read is bounded in bytes", () => { + function scopeWithMarkerBody( + body: ReadableStream | null, + headers: Readonly> = {}, + ) { + const markerUrl = "__service-worker-activation-v1__"; + const response = body + ? new Response(body, { status: 200, headers }) + : new Response("", { status: 200, headers }); + const cache = { + match: vi.fn(async (request: RequestInfo | URL) => + String(request).includes(markerUrl) ? response : undefined, + ), + put: vi.fn(async () => {}), + delete: vi.fn(async () => true), + } as unknown as Cache; + return { + response, + scope: { + caches: { + open: vi.fn(async () => cache), + // The current static cache must exist for its marker to be read. + keys: vi.fn(async () => [ + staticCacheName(identity.staticAssetSetDigest), + ]), + delete: vi.fn(async () => true), + match: vi.fn(), + }, + clients: { matchAll: vi.fn(async () => []) }, + skipWaiting: vi.fn(async () => {}), + fetcher: vi.fn(), + digest: vi.fn(), + }, + }; + } + + const runtimeFor = (scope: unknown) => + createServiceWorkerRuntime(scope as never, { + identity, + handlers: ["PWA_STATIC_ASSETS"], + manifest: { + schemaVersion: 1, + buildId: identity.buildId, + releaseId: identity.releaseId, + setDigest: identity.staticAssetSetDigest as `sha256:${string}`, + assets: [], + }, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + + it("never retains a single chunk larger than the marker ceiling", async () => { + let delivered = 0; + let cancels = 0; + const oversized = new ReadableStream({ + pull(controller) { + delivered += 1; + controller.enqueue(new Uint8Array(1_048_576)); + }, + cancel() { + cancels += 1; + }, + }); + const fixture = scopeWithMarkerBody(oversized); + + // Activation still completes; the marker is simply not admitted. + await expect(runtimeFor(fixture.scope).onActivate()).resolves.toBeTypeOf( + "number", + ); + // Every oversized chunk that arrived was refused before being retained, + // and the reader that saw it was cancelled. The marker is probed once per + // candidate cache, so the counts track each other rather than a constant. + expect(cancels).toBeGreaterThanOrEqual(1); + expect(delivered).toBeLessThanOrEqual(2); + expect(fixture.response.body?.locked).toBe(false); + }); + + it("bounds a stream that never produces a chunk", async () => { + let cancels = 0; + const stalled = new ReadableStream({ + pull() { + return new Promise(() => {}); + }, + cancel() { + cancels += 1; + }, + }); + const fixture = scopeWithMarkerBody(stalled); + + await expect( + runtimeFor(fixture.scope).onActivate(), + ).resolves.toBeTypeOf("number"); + expect(cancels).toBeGreaterThanOrEqual(1); + }, 10_000); + + it("cancels the body it refuses for a declared oversize", async () => { + let cancels = 0; + const declared = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(8)); + }, + cancel() { + cancels += 1; + }, + }); + const fixture = scopeWithMarkerBody(declared, { + "content-length": "1048576", + }); + + await expect( + runtimeFor(fixture.scope).onActivate(), + ).resolves.toBeTypeOf("number"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cancels).toBeGreaterThanOrEqual(1); + }); + + it("refuses a marker body that is not valid UTF-8", async () => { + const invalid = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0xff, 0xfe, 0xfd])); + controller.close(); + }, + }); + const fixture = scopeWithMarkerBody(invalid); + + await expect( + runtimeFor(fixture.scope).onActivate(), + ).resolves.toBeTypeOf("number"); + expect(fixture.response.body?.locked).toBe(false); + }); +}); diff --git a/tests/unit/service-worker-web-push-remediation.test.ts b/tests/unit/service-worker-web-push-remediation.test.ts index 8e38e47..3b7994c 100644 --- a/tests/unit/service-worker-web-push-remediation.test.ts +++ b/tests/unit/service-worker-web-push-remediation.test.ts @@ -1,8 +1,13 @@ 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"; /** @@ -67,3 +72,105 @@ describe("SW-RR-03 one authoritative cacheable asset table", () => { 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>, + ): Promise { + 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 = {}; + 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); + } + }); +}); diff --git a/tests/unit/web-push-worker-runtime.test.ts b/tests/unit/web-push-worker-runtime.test.ts index df55302..197bec3 100644 --- a/tests/unit/web-push-worker-runtime.test.ts +++ b/tests/unit/web-push-worker-runtime.test.ts @@ -621,3 +621,210 @@ describe("Web Push worker runtime", () => { expect(listeners.size).toBe(0); }); }); + +/** + * WP-01. One click is one terminal record. The adapter emitted the terminal + * event from inside `process` *and* again from the `waitUntil` wrapper, so an + * ordinary click was counted twice. A late rejection also downgraded the + * certainty from `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. + */ +describe("WP-01 the click handler has one observation authority", () => { + type Observation = Readonly<{ + event: string; + outcome: string; + reason?: string; + nativeEffect?: string; + }>; + + async function clickAdapterWith( + clients: Readonly<{ + matchControlledWindowClients(): Promise; + openWindow(target: string): Promise; + }>, + scheduler?: TimeoutScheduler, + ) { + const store = await activeFence(); + const observations: Observation[] = []; + const adapter = createNotificationClickAdapter({ + fenceStore: store, + registry, + origin: "https://app.example.test", + now: () => now, + clients: clients as never, + observer: { + record(observation) { + observations.push(observation as Observation); + }, + }, + ...(scheduler ? { scheduler } : {}), + }); + return { adapter, observations }; + } + + const dispatched = (observations: readonly Observation[]) => + observations.filter( + (observation) => observation.event === "web_push_click_dispatched", + ); + + it("records exactly one terminal event for an ordinary focus", async () => { + const focus = vi.fn(async () => {}); + const { adapter, observations } = await clickAdapterWith({ + async matchControlledWindowClients() { + return [ + { + url: "https://app.example.test/current", + focus, + postMessage() {}, + }, + ]; + }, + openWindow: vi.fn(async () => null), + }); + let waited: Promise | null = null; + const result = await adapter.handle({ + notification: { data: clickData(), close() {} }, + waitUntil(task) { + waited = task; + }, + }); + await waited; + + expect(result).toEqual({ ok: true, value: undefined }); + expect(dispatched(observations)).toEqual([ + { + event: "web_push_click_dispatched", + outcome: "SUCCEEDED", + nativeEffect: "CONFIRMED", + }, + ]); + }); + + it("confirms NOT_APPLIED only for an explicit null window", async () => { + const { adapter, observations } = await clickAdapterWith({ + async matchControlledWindowClients() { + return []; + }, + openWindow: vi.fn(async () => null), + }); + let waited: Promise | null = null; + const result = await adapter.handle({ + notification: { data: clickData(), close() {} }, + waitUntil(task) { + waited = task; + }, + }); + await waited; + + expect(result.ok).toBe(false); + expect(dispatched(observations)).toEqual([ + expect.objectContaining({ + outcome: "FAILED", + nativeEffect: "NOT_APPLIED", + }), + ]); + }); + + it("keeps MAYBE_APPLIED when the native effect rejects after the deadline", async () => { + const clock = manualScheduler(); + let rejectFocus: ((reason: unknown) => void) | undefined; + const { adapter, observations } = await clickAdapterWith( + { + async matchControlledWindowClients() { + return [ + { + url: "https://app.example.test/current", + focus: () => + new Promise((_resolve, reject) => { + rejectFocus = reject; + }), + postMessage() {}, + }, + ]; + }, + openWindow: vi.fn(async () => null), + }, + clock.scheduler, + ); + let waited: Promise | null = null; + const handling = adapter.handle({ + notification: { data: clickData(), close() {} }, + waitUntil(task) { + waited = task; + }, + }); + await vi.waitFor(() => expect(rejectFocus).toBeDefined()); + clock.expireAll(); + const result = await handling; + expect(result.ok).toBe(false); + + // The effect lands only now, after the terminal result. + rejectFocus?.(new Error("focus failed late")); + await waited; + + const records = dispatched(observations); + expect(records).toHaveLength(2); + // The evidence record never claims the click was definitely not applied. + expect(records.at(-1)).toEqual({ + event: "web_push_click_dispatched", + outcome: "DEGRADED", + reason: "ABORTED", + nativeEffect: "MAYBE_APPLIED", + }); + }); + + it("waits for the late effect evidence inside waitUntil", async () => { + const clock = manualScheduler(); + let resolveFocus: (() => void) | undefined; + const { adapter, observations } = await clickAdapterWith( + { + async matchControlledWindowClients() { + return [ + { + url: "https://app.example.test/current", + focus: () => + new Promise((resolve) => { + resolveFocus = resolve; + }), + postMessage() {}, + }, + ]; + }, + openWindow: vi.fn(async () => null), + }, + clock.scheduler, + ); + let waited: Promise | null = null; + const handling = adapter.handle({ + notification: { data: clickData(), close() {} }, + waitUntil(task) { + waited = task; + }, + }); + await vi.waitFor(() => expect(resolveFocus).toBeDefined()); + clock.expireAll(); + await handling; + + let waitUntilSettled = false; + const pendingWait = waited as Promise | null; + void pendingWait?.then(() => { + waitUntilSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + // The handler's lifetime is still open because the effect has not landed. + expect(waitUntilSettled).toBe(false); + + resolveFocus?.(); + await waited; + expect( + dispatched(observations).at(-1), + ).toEqual({ + event: "web_push_click_dispatched", + outcome: "DEGRADED", + reason: "ABORTED", + nativeEffect: "CONFIRMED", + }); + }); +});