WP-01: a CAS receipt is only evidence when it names the expected key and the
exact next revision. Write and remove now share one validator, so a stale or
arbitrary repository receipt can no longer be packaged as a confirmed control.
WP-05: a pre-aborted command records the operation the caller requested instead
of always reporting an inspection.
WP-06: bounded fan-out is reported honestly. The subscriptionchange client
handoff and the notification cleanup both emit countBucket and truncated, and an
incomplete cleanup returns { complete: false } and is observed as DEGRADED
separately from revoke authority.
WP-07: the user-visible native notification effect is tracked through
NOT_APPLIED, MAYBE_APPLIED and CONFIRMED phases and surfaced as observation
evidence, never as retry authorization.
WP-02, WP-03 and WP-04 stay open: they need the V2 wire protocol with server
request-shape negotiation, which belongs to the versioned-migration task rather
than this correctness pass. The ledger records them as DEFERRED_TO_MIGRATION.
Web Push remains NOT_SELECTED and AVAILABLE_NOT_COMPOSED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
624 lines
19 KiB
TypeScript
624 lines
19 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);
|
|
});
|
|
});
|