Files
clean-architecture-frontend…/tests/unit/web-push-worker-runtime.test.ts
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

831 lines
25 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
WEB_PUSH_PROTOCOLS,
type NotificationClickDataV1,
} from "../../src/contracts/web-push.ts";
import { createPushAssociationFenceStore } from "../../src/adapters/web-push/push-association-fence-store.ts";
import { createWebPushNotificationRegistry } from "../../src/adapters/web-push/notification-registry.ts";
import { createPushEventAdapter } from "../../src/adapters/web-push/inbound/push-event-adapter.ts";
import { createNotificationClickAdapter } from "../../src/adapters/web-push/inbound/notification-click-adapter.ts";
import { createWebPushServiceWorkerRuntime } from "../../src/adapters/web-push/service-worker-runtime.ts";
import type { TimeoutScheduler } from "../../src/adapters/web-push/runtime-support.ts";
import {
createFakePushControlStoreDependencies,
} from "../helpers/fake-push-control-repository.ts";
const now = Date.parse("2026-07-28T00:00:00.000Z");
const authority = Object.freeze({
fenceGeneration: "fence_01",
sessionBindingEpoch: "session_01",
releaseEpoch: "release_01",
});
const nextAuthority = Object.freeze({
fenceGeneration: "fence_02",
sessionBindingEpoch: "session_02",
releaseEpoch: "release_01",
});
const registry = createWebPushNotificationRegistry([
{
notificationType: "INBOX_ACTIVITY",
routeIntent: "OPEN_INBOX",
title: "새 알림이 있습니다",
body: "앱을 열어 최신 내용을 확인하세요.",
path: "/inbox",
},
]);
async function activeFence() {
const store = createPushAssociationFenceStore(
createFakePushControlStoreDependencies(),
);
const prepared = await store.prepare({
authority,
updatedAt: "2026-07-28T00:00:00.000Z",
});
if (!prepared.ok) throw new Error("expected prepared push control");
const active = await store.activate({
expectedRevision: prepared.value.revision,
authority,
associationEpoch: "association_01",
updatedAt: "2026-07-28T00:00:01.000Z",
});
if (!active.ok) throw new Error("expected active push control");
return store;
}
function hint(overrides: Readonly<Record<string, unknown>> = {}) {
return {
protocol: WEB_PUSH_PROTOCOLS.hint,
notificationType: "INBOX_ACTIVITY",
notificationId: "notification_01",
associationEpoch: "association_01",
releaseEpoch: "release_01",
issuedAt: "2026-07-27T23:59:00.000Z",
expiresAt: "2026-07-28T01:00:00.000Z",
routeIntent: "OPEN_INBOX",
...overrides,
};
}
function clickData(): NotificationClickDataV1 {
return {
protocol: WEB_PUSH_PROTOCOLS.click,
notificationId: "notification_01",
routeIntent: "OPEN_INBOX",
associationEpoch: "association_01",
releaseEpoch: "release_01",
expiresAt: "2026-07-28T01:00:00.000Z",
};
}
function manualScheduler(): Readonly<{
scheduler: TimeoutScheduler;
delays: readonly number[];
expireAll(): void;
}> {
let sequence = 0;
const callbacks = new Map<number, () => void>();
const delays: number[] = [];
return {
scheduler: {
setTimeout(callback, milliseconds) {
sequence += 1;
delays.push(milliseconds);
callbacks.set(sequence, callback);
return sequence;
},
clearTimeout(handle) {
if (typeof handle === "number") callbacks.delete(handle);
},
},
expireAll() {
for (const callback of [...callbacks.values()]) callback();
},
delays,
};
}
describe("Web Push worker runtime", () => {
it("waits for strict hint handling and shows only registry-owned safe copy", async () => {
const store = await activeFence();
const shown: Array<Readonly<{ title: string; options: unknown }>> = [];
let waited: Promise<void> | null = null;
const adapter = createPushEventAdapter({
fenceStore: store,
registry,
now: () => now,
tagDigest: async () => new Uint8Array(32).fill(9).buffer,
notifications: {
async showNotification(title, options) {
shown.push({ title, options });
},
},
});
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
const result = await adapter.handle({
data: { arrayBuffer: () => bytes.slice().buffer },
waitUntil(task) {
waited = task;
},
});
await waited;
expect(result).toEqual({ ok: true, value: undefined });
expect(shown).toHaveLength(1);
expect(shown[0]).toMatchObject({
title: "새 알림이 있습니다",
options: {
body: "앱을 열어 최신 내용을 확인하세요.",
data: {
protocol: WEB_PUSH_PROTOCOLS.click,
notificationId: "notification_01",
},
},
});
expect(
(shown[0]?.options as { tag: string }).tag,
).not.toContain("association_01");
});
it("drops mismatched associations before notification rendering", async () => {
const store = await activeFence();
const showNotification = vi.fn(async () => {});
const adapter = createPushEventAdapter({
fenceStore: store,
registry,
now: () => now,
tagDigest: async () => new Uint8Array(32).buffer,
notifications: { showNotification },
});
const bytes = new TextEncoder().encode(
JSON.stringify(hint({ associationEpoch: "association_old" })),
);
const result = await adapter.handle({
data: { arrayBuffer: () => bytes.slice().buffer },
waitUntil() {},
});
expect(result).toMatchObject({
ok: false,
error: { code: "ASSOCIATION_MISMATCH" },
});
expect(showNotification).not.toHaveBeenCalled();
});
it("aborts late worker work at the handler deadline before notification display", async () => {
const store = await activeFence();
const clock = manualScheduler();
const showNotification = vi.fn(async () => {});
let signalDigestStarted: (() => void) | undefined;
const digestStarted = new Promise<void>((resolve) => {
signalDigestStarted = resolve;
});
let finishDigest: ((value: ArrayBuffer) => void) | undefined;
const digest = new Promise<ArrayBuffer>((resolve) => {
finishDigest = resolve;
});
const adapter = createPushEventAdapter({
fenceStore: store,
registry,
now: () => now,
handlerDeadlineMs: 1,
scheduler: clock.scheduler,
tagDigest: async () => {
signalDigestStarted?.();
return await digest;
},
notifications: { showNotification },
});
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
const handling = adapter.handle({
data: { arrayBuffer: () => bytes.slice().buffer },
waitUntil() {},
});
await digestStarted;
clock.expireAll();
expect(await handling).toMatchObject({
ok: false,
error: { code: "DEADLINE_EXCEEDED" },
});
finishDigest?.(new Uint8Array(32).buffer);
await Promise.resolve();
await Promise.resolve();
expect(showNotification).not.toHaveBeenCalled();
});
it("rechecks the durable generation after async push work", async () => {
const store = await activeFence();
const showNotification = vi.fn(async () => {});
const adapter = createPushEventAdapter({
fenceStore: store,
registry,
now: () => now,
notifications: { showNotification },
tagDigest: async () => {
const current = await store.read();
if (!current.ok || !current.value) {
throw new Error("expected active control");
}
const rotated = await store.rotateAndRevoke({
expectedRevision: current.value.revision,
previousAuthority: authority,
nextAuthority,
updatedAt: "2026-07-28T00:00:02.000Z",
});
if (!rotated.ok) throw new Error("expected rotated control");
return new Uint8Array(32).buffer;
},
});
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
expect(
await adapter.handle({
data: { arrayBuffer: () => bytes.slice().buffer },
waitUntil() {},
}),
).toMatchObject({
ok: false,
error: { code: "ASSOCIATION_MISMATCH" },
});
expect(showNotification).not.toHaveBeenCalled();
});
it("revalidates persisted click authority and uses a same-origin handoff", async () => {
const store = await activeFence();
const messages: unknown[] = [];
const focus = vi.fn(async () => {});
const openWindow = vi.fn(async () => null);
const close = vi.fn();
let waited: Promise<void> | null = null;
const adapter = createNotificationClickAdapter({
fenceStore: store,
registry,
origin: "https://app.example.test",
now: () => now,
clients: {
async matchControlledWindowClients() {
return [
{
url: "https://app.example.test/current",
focus,
postMessage(message) {
messages.push(message);
},
},
];
},
openWindow,
},
});
const result = await adapter.handle({
notification: { data: clickData(), close },
waitUntil(task) {
waited = task;
},
});
await waited;
expect(result).toEqual({ ok: true, value: undefined });
expect(close).toHaveBeenCalledOnce();
expect(focus).toHaveBeenCalledOnce();
expect(openWindow).not.toHaveBeenCalled();
expect(messages).toEqual([
expect.objectContaining({
protocol: WEB_PUSH_PROTOCOLS.clickHandoff,
routeIntent: "OPEN_INBOX",
path: "/inbox",
}),
]);
});
it("rechecks the durable generation after client enumeration", async () => {
const store = await activeFence();
const postMessage = vi.fn();
const focus = vi.fn(async () => {});
const adapter = createNotificationClickAdapter({
fenceStore: store,
registry,
origin: "https://app.example.test",
now: () => now,
clients: {
async matchControlledWindowClients() {
const current = await store.read();
if (!current.ok || !current.value) {
throw new Error("expected active control");
}
const rotated = await store.rotateAndRevoke({
expectedRevision: current.value.revision,
previousAuthority: authority,
nextAuthority,
updatedAt: "2026-07-28T00:00:02.000Z",
});
if (!rotated.ok) throw new Error("expected rotated control");
return [
{
url: "https://app.example.test/",
focus,
postMessage,
},
];
},
openWindow: async () => null,
},
});
expect(
await adapter.handle({
notification: { data: clickData(), close() {} },
waitUntil() {},
}),
).toMatchObject({
ok: false,
error: { code: "ASSOCIATION_MISMATCH" },
});
expect(postMessage).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
});
it("aborts in-flight push work when the worker runtime is disposed", async () => {
const store = await activeFence();
const listeners = new Map<string, (event: unknown) => void>();
const showNotification = vi.fn(async () => {});
let signalDigestStarted: (() => void) | undefined;
const digestStarted = new Promise<void>((resolve) => {
signalDigestStarted = resolve;
});
let finishDigest: ((value: ArrayBuffer) => void) | undefined;
const digest = new Promise<ArrayBuffer>((resolve) => {
finishDigest = resolve;
});
const runtime = createWebPushServiceWorkerRuntime({
fenceStore: store,
registry,
now: () => now,
tagDigest: async () => {
signalDigestStarted?.();
return await digest;
},
host: {
origin: "https://app.example.test",
registration: { showNotification },
clients: {
matchAll: async () => [],
openWindow: async () => null,
},
addEventListener(type, listener) {
listeners.set(type, listener);
},
removeEventListener(type, listener) {
if (listeners.get(type) === listener) listeners.delete(type);
},
},
});
let waited: Promise<void> | null = null;
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
listeners.get("push")?.({
data: { arrayBuffer: () => bytes.slice().buffer },
waitUntil(task: Promise<void>) {
waited = task;
},
});
await digestStarted;
runtime.dispose();
await waited;
finishDigest?.(new Uint8Array(32).buffer);
for (let turn = 0; turn < 4; turn += 1) {
await Promise.resolve();
}
expect(showNotification).not.toHaveBeenCalled();
expect(listeners.size).toBe(0);
});
it("bounds a non-cooperative subscription-change handoff at ten seconds", async () => {
const store = await activeFence();
const listeners = new Map<string, (event: unknown) => void>();
const clock = manualScheduler();
const observations: unknown[] = [];
const runtime = createWebPushServiceWorkerRuntime({
fenceStore: store,
registry,
scheduler: clock.scheduler,
observer: {
record(observation) {
observations.push(observation);
},
},
host: {
origin: "https://app.example.test",
registration: { showNotification: async () => {} },
clients: {
matchAll: () => new Promise<readonly unknown[]>(() => {}),
openWindow: async () => null,
},
addEventListener(type, listener) {
listeners.set(type, listener);
},
removeEventListener(type, listener) {
if (listeners.get(type) === listener) listeners.delete(type);
},
},
});
let waited: Promise<void> | null = null;
listeners.get("pushsubscriptionchange")?.({
waitUntil(task: Promise<void>) {
waited = task;
},
});
await Promise.resolve();
expect(clock.delays).toContain(10_000);
clock.expireAll();
await waited;
expect(observations).toContainEqual({
event: "web_push_subscription_rotated",
outcome: "DEGRADED",
reason: "DEADLINE_EXCEEDED",
countBucket: expect.any(String),
truncated: expect.any(Boolean),
});
runtime.dispose();
});
it("aborts subscription-change handoff on dispose and contains waitUntil throws", async () => {
const store = await activeFence();
const listeners = new Map<string, (event: unknown) => void>();
const observations: unknown[] = [];
const runtime = createWebPushServiceWorkerRuntime({
fenceStore: store,
registry,
observer: {
record(observation) {
observations.push(observation);
},
},
host: {
origin: "https://app.example.test",
registration: { showNotification: async () => {} },
clients: {
matchAll: () => new Promise<readonly unknown[]>(() => {}),
openWindow: async () => null,
},
addEventListener(type, listener) {
listeners.set(type, listener);
},
removeEventListener(type, listener) {
if (listeners.get(type) === listener) listeners.delete(type);
},
},
});
let waited: Promise<void> | null = null;
listeners.get("pushsubscriptionchange")?.({
waitUntil(task: Promise<void>) {
waited = task;
},
});
await Promise.resolve();
runtime.dispose();
await waited;
expect(observations).toContainEqual({
event: "web_push_subscription_rotated",
outcome: "DEGRADED",
reason: "ABORTED",
countBucket: expect.any(String),
truncated: expect.any(Boolean),
});
const throwingStore = await activeFence();
const throwingListeners = new Map<
string,
(event: unknown) => void
>();
const throwingObservations: unknown[] = [];
let signalThrowingObservation: (() => void) | undefined;
const throwingObservation = new Promise<void>((resolve) => {
signalThrowingObservation = resolve;
});
const throwingRuntime = createWebPushServiceWorkerRuntime({
fenceStore: throwingStore,
registry,
observer: {
record(observation) {
throwingObservations.push(observation);
signalThrowingObservation?.();
},
},
host: {
origin: "https://app.example.test",
registration: { showNotification: async () => {} },
clients: {
matchAll: async () => [],
openWindow: async () => null,
},
addEventListener(type, listener) {
throwingListeners.set(type, listener);
},
removeEventListener(type, listener) {
if (throwingListeners.get(type) === listener) {
throwingListeners.delete(type);
}
},
},
});
expect(() =>
throwingListeners.get("pushsubscriptionchange")?.({
waitUntil() {
throw new Error("waitUntil unavailable");
},
}),
).not.toThrow();
await throwingObservation;
expect(throwingObservations).toContainEqual({
event: "web_push_subscription_rotated",
outcome: "DEGRADED",
reason: "ABORTED",
countBucket: expect.any(String),
truncated: expect.any(Boolean),
});
throwingRuntime.dispose();
});
it("installs no handlers until the uncomposed factory is called and removes all on dispose", async () => {
const store = await activeFence();
const listeners = new Map<string, (event: unknown) => void>();
const messages: unknown[] = [];
const matchPolicies: boolean[] = [];
const client = {
url: "https://app.example.test/",
focus: async () => {},
postMessage(message: unknown) {
messages.push(message);
},
};
expect(listeners.size).toBe(0);
const runtime = createWebPushServiceWorkerRuntime({
fenceStore: store,
registry,
now: () => now,
host: {
origin: "https://app.example.test",
registration: {
async showNotification() {},
},
clients: {
async matchAll(input) {
matchPolicies.push(input.includeUncontrolled);
return [client];
},
async openWindow() {
return client;
},
},
addEventListener(type, listener) {
listeners.set(type, listener);
},
removeEventListener(type, listener) {
if (listeners.get(type) === listener) listeners.delete(type);
},
},
});
expect([...listeners.keys()].sort()).toEqual([
"notificationclick",
"push",
"pushsubscriptionchange",
]);
let clickWait: Promise<void> | null = null;
listeners.get("notificationclick")?.({
notification: { data: clickData(), close() {} },
waitUntil(task: Promise<void>) {
clickWait = task;
},
});
await clickWait;
let subscriptionWait: Promise<void> | null = null;
listeners.get("pushsubscriptionchange")?.({
waitUntil(task: Promise<void>) {
subscriptionWait = task;
},
});
await subscriptionWait;
expect(matchPolicies).toEqual([false, true]);
expect(messages).toEqual([
expect.objectContaining({
protocol: WEB_PUSH_PROTOCOLS.clickHandoff,
}),
{ protocol: WEB_PUSH_PROTOCOLS.reconcileRequired },
]);
runtime.dispose();
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<readonly unknown[]>;
openWindow(target: string): Promise<unknown>;
}>,
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<void> | 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<void> | 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<void>((_resolve, reject) => {
rejectFocus = reject;
}),
postMessage() {},
},
];
},
openWindow: vi.fn(async () => null),
},
clock.scheduler,
);
let waited: Promise<void> | 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<void>((resolve) => {
resolveFocus = resolve;
}),
postMessage() {},
},
];
},
openWindow: vi.fn(async () => null),
},
clock.scheduler,
);
let waited: Promise<void> | 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<void> | 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",
});
});
});