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> = {}) { 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 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> = []; let waited: Promise | 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((resolve) => { signalDigestStarted = resolve; }); let finishDigest: ((value: ArrayBuffer) => void) | undefined; const digest = new Promise((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 | 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 void>(); const showNotification = vi.fn(async () => {}); let signalDigestStarted: (() => void) | undefined; const digestStarted = new Promise((resolve) => { signalDigestStarted = resolve; }); let finishDigest: ((value: ArrayBuffer) => void) | undefined; const digest = new Promise((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 | null = null; const bytes = new TextEncoder().encode(JSON.stringify(hint())); listeners.get("push")?.({ data: { arrayBuffer: () => bytes.slice().buffer }, waitUntil(task: Promise) { 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 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(() => {}), openWindow: async () => null, }, addEventListener(type, listener) { listeners.set(type, listener); }, removeEventListener(type, listener) { if (listeners.get(type) === listener) listeners.delete(type); }, }, }); let waited: Promise | null = null; listeners.get("pushsubscriptionchange")?.({ waitUntil(task: Promise) { 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", }); runtime.dispose(); }); it("aborts subscription-change handoff on dispose and contains waitUntil throws", async () => { const store = await activeFence(); const listeners = new Map 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(() => {}), openWindow: async () => null, }, addEventListener(type, listener) { listeners.set(type, listener); }, removeEventListener(type, listener) { if (listeners.get(type) === listener) listeners.delete(type); }, }, }); let waited: Promise | null = null; listeners.get("pushsubscriptionchange")?.({ waitUntil(task: Promise) { waited = task; }, }); await Promise.resolve(); runtime.dispose(); await waited; expect(observations).toContainEqual({ event: "web_push_subscription_rotated", outcome: "DEGRADED", reason: "ABORTED", }); const throwingStore = await activeFence(); const throwingListeners = new Map< string, (event: unknown) => void >(); const throwingObservations: unknown[] = []; let signalThrowingObservation: (() => void) | undefined; const throwingObservation = new Promise((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", }); 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 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 | null = null; listeners.get("notificationclick")?.({ notification: { data: clickData(), close() {} }, waitUntil(task: Promise) { clickWait = task; }, }); await clickWait; let subscriptionWait: Promise | null = null; listeners.get("pushsubscriptionchange")?.({ waitUntil(task: Promise) { 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); }); });