import { describe, expect, it, vi } from "vitest"; import { WEB_PUSH_PROTOCOLS, webPushSuccess } from "../../src/contracts/web-push.ts"; import { createPushAssociationFenceStore } from "../../src/adapters/web-push/push-association-fence-store.ts"; import { createWebPushSubscriptionAdapter, type WindowPushSubscriptionFacade, } from "../../src/adapters/web-push/push-subscription-adapter.ts"; import type { WebPushRegistrationGateway } from "../../src/adapters/web-push/push-registration-gateway.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", }); function bytes(length: number, first?: number): Uint8Array { const value = new Uint8Array(new ArrayBuffer(length)); value.fill(7); if (first !== undefined) value[0] = first; return value; } const applicationServerKey = bytes(65, 4); const vapidPublicKey = Buffer.from(applicationServerKey).toString("base64url"); function subscription( unsubscribe = vi.fn(async () => true), ): WindowPushSubscriptionFacade { return { endpoint: "https://push.example.test/subscription/opaque", expirationTime: null, options: { applicationServerKey: applicationServerKey.slice().buffer, }, getKey(name) { return name === "p256dh" ? bytes(65).buffer : bytes(16).buffer; }, unsubscribe, }; } function gateway( overrides: Partial = {}, ): WebPushRegistrationGateway { return { register: async () => webPushSuccess({ associationEpoch: "association_01", sessionBindingEpoch: "session_01", }), reconcile: async () => webPushSuccess({ state: "ACTIVE", associationEpoch: "association_01", sessionBindingEpoch: "session_01", }), revoke: async () => webPushSuccess({ state: "REVOKED" }), ...overrides, }; } async function activeFenceStore() { 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 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 control"); return store; } function manualScheduler(): Readonly<{ scheduler: TimeoutScheduler; expireAll(): void; }> { let sequence = 0; const callbacks = new Map void>(); return { scheduler: { setTimeout(callback) { sequence += 1; callbacks.set(sequence, callback); return sequence; }, clearTimeout(handle) { if (typeof handle === "number") callbacks.delete(handle); }, }, expireAll() { for (const callback of [...callbacks.values()]) callback(); }, }; } describe("Web Push window subscription adapter", () => { it("requests permission only from an explicit action, registers, fences, and inspects READY", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const native = subscription(); let current: WindowPushSubscriptionFacade | null = null; const subscribe = vi.fn(async (input) => { expect(input.userVisibleOnly).toBe(true); expect([...input.applicationServerKey]).toEqual([ ...applicationServerKey, ]); current = native; return native; }); let permission: "default" | "granted" = "default"; const requestPermission = vi.fn(async () => { permission = "granted"; return permission; }); const register = vi.fn(gateway().register); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => permission, requestPermission, }, registration: { active: true, pushManager: { getSubscription: async () => current, subscribe, }, getNotifications: async () => [], }, fenceStore, gateway: gateway({ register }), vapidPublicKey, now: () => now, idempotencyKeyFactory: () => "idempotency_01", }); expect( await adapter.enable({ authority, signal: new AbortController().signal, }), ).toEqual({ ok: true, value: { state: "PUSH_READY" } }); expect(requestPermission).toHaveBeenCalledOnce(); expect(subscribe).toHaveBeenCalledOnce(); expect(register).toHaveBeenCalledOnce(); expect( await adapter.inspect({ authority }), ).toEqual({ ok: true, value: { state: "PUSH_READY" } }); const control = await fenceStore.read(); expect(control).toMatchObject({ ok: true, value: { control: { association: { state: "ACTIVE", associationEpoch: "association_01", }, }, }, }); }); it("keeps denied and absent-user-activation flows side-effect free", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const subscribe = vi.fn(async () => subscription()); const register = vi.fn(gateway().register); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => false, permission: { permission: () => "default", requestPermission: async () => "denied", }, registration: { active: true, pushManager: { getSubscription: async () => null, subscribe, }, getNotifications: async () => [], }, fenceStore, gateway: gateway({ register }), vapidPublicKey, now: () => now, }); expect( await adapter.enable({ authority, signal: new AbortController().signal, }), ).toMatchObject({ ok: false, error: { code: "PERMISSION_DENIED" }, }); expect(subscribe).not.toHaveBeenCalled(); expect(register).not.toHaveBeenCalled(); expect(await fenceStore.read()).toEqual({ ok: true, value: null }); }); it("compensates a stale backend session binding without activating local authority", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const unsubscribe = vi.fn(async () => true); const native = subscription(unsubscribe); const revoke = vi.fn(gateway().revoke); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: async () => native, subscribe: async () => native, }, getNotifications: async () => [], }, fenceStore, gateway: gateway({ register: async () => webPushSuccess({ associationEpoch: "association_stale", sessionBindingEpoch: "session_old", }), revoke, }), vapidPublicKey, now: () => now, idempotencyKeyFactory: () => "idempotency_01", }); expect( await adapter.enable({ authority, signal: new AbortController().signal, }), ).toEqual({ ok: true, value: { state: "PUSH_UNAVAILABLE", reason: "SESSION_AUTHORITY_CHANGED", }, }); expect(revoke).toHaveBeenCalledWith({ associationEpoch: "association_stale", signal: expect.any(AbortSignal), }); expect(unsubscribe).toHaveBeenCalledOnce(); expect(await fenceStore.read()).toMatchObject({ ok: true, value: { control: { association: { state: "UNASSOCIATED" } }, }, }); }); it("commits the logout fence first, then revokes backend/native state and closes owned notifications", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const prepared = await fenceStore.prepare({ authority, updatedAt: "2026-07-28T00:00:00.000Z", }); if (!prepared.ok) throw new Error("expected prepared control"); await fenceStore.activate({ expectedRevision: prepared.value.revision, authority, associationEpoch: "association_01", updatedAt: "2026-07-28T00:00:01.000Z", }); const unsubscribe = vi.fn(async () => true); const native = subscription(unsubscribe); const notificationClose = vi.fn(); const revoke = vi.fn(async () => { const control = await fenceStore.read(); expect(control).toMatchObject({ ok: true, value: { control: { fenceGeneration: "fence_02", association: { state: "REVOKED" }, }, }, }); return webPushSuccess({ state: "REVOKED" as const }); }); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: async () => native, subscribe: async () => native, }, getNotifications: async () => [ { data: { protocol: WEB_PUSH_PROTOCOLS.click, notificationId: "notification_01", routeIntent: "OPEN_INBOX", associationEpoch: "association_01", releaseEpoch: "release_01", expiresAt: "2026-07-28T01:00:00.000Z", }, close: notificationClose, }, ], }, fenceStore, gateway: gateway({ revoke }), vapidPublicKey, now: () => now, }); expect( await adapter.revoke({ previousAuthority: authority, nextAuthority, }), ).toEqual({ ok: true, value: { state: "PUSH_UNAVAILABLE", reason: "REVOKED" }, }); expect(revoke).toHaveBeenCalledWith({ associationEpoch: "association_01", signal: expect.any(AbortSignal), }); expect(unsubscribe).toHaveBeenCalledOnce(); expect(notificationClose).toHaveBeenCalledOnce(); }); it("does not clean the current native subscription for a stale revoke authority", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const prepared = await fenceStore.prepare({ authority: nextAuthority, updatedAt: "2026-07-28T00:00:00.000Z", }); if (!prepared.ok) throw new Error("expected prepared control"); const activated = await fenceStore.activate({ expectedRevision: prepared.value.revision, authority: nextAuthority, associationEpoch: "association_new", updatedAt: "2026-07-28T00:00:01.000Z", }); if (!activated.ok) throw new Error("expected active control"); const unsubscribe = vi.fn(async () => true); const getSubscription = vi.fn(async () => subscription(unsubscribe), ); const notificationClose = vi.fn(); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription, subscribe: async () => subscription(unsubscribe), }, getNotifications: async () => [ { data: { protocol: WEB_PUSH_PROTOCOLS.click, notificationId: "notification_new", routeIntent: "OPEN_INBOX", associationEpoch: "association_new", releaseEpoch: "release_01", expiresAt: "2026-07-28T01:00:00.000Z", }, close: notificationClose, }, ], }, fenceStore, gateway: gateway(), vapidPublicKey, now: () => now, }); await expect( adapter.revoke({ previousAuthority: authority, nextAuthority, }), ).resolves.toEqual({ ok: true, value: { state: "PUSH_UNAVAILABLE", reason: "LOCAL_FENCE_UNSAFE", }, }); expect(getSubscription).not.toHaveBeenCalled(); expect(unsubscribe).not.toHaveBeenCalled(); expect(notificationClose).not.toHaveBeenCalled(); }); it("does not clean a captured old subscription after a newer association commits", async () => { const fenceStore = await activeFenceStore(); const unsubscribe = vi.fn(async () => true); const native = subscription(unsubscribe); const notificationClose = vi.fn(); const revoke = vi.fn(async () => { const current = await fenceStore.read(); if (!current.ok || !current.value) { throw new Error("expected rotated control"); } const activated = await fenceStore.activate({ expectedRevision: current.value.revision, authority: nextAuthority, associationEpoch: "association_new", updatedAt: "2026-07-28T00:00:02.000Z", }); if (!activated.ok) throw new Error("expected new association"); return webPushSuccess({ state: "REVOKED" as const }); }); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: async () => native, subscribe: async () => native, }, getNotifications: async () => [ { data: { protocol: WEB_PUSH_PROTOCOLS.click, notificationId: "notification_old", routeIntent: "OPEN_INBOX", associationEpoch: "association_01", releaseEpoch: "release_01", expiresAt: "2026-07-28T01:00:00.000Z", }, close: notificationClose, }, ], }, fenceStore, gateway: gateway({ revoke }), vapidPublicKey, now: () => now, }); await expect( adapter.revoke({ previousAuthority: authority, nextAuthority, }), ).resolves.toEqual({ ok: true, value: { state: "PUSH_UNAVAILABLE", reason: "NATIVE_UNSUBSCRIBE_AMBIGUOUS", }, }); expect(unsubscribe).not.toHaveBeenCalled(); expect(notificationClose).not.toHaveBeenCalled(); await expect(fenceStore.read()).resolves.toMatchObject({ ok: true, value: { control: { association: { state: "ACTIVE", associationEpoch: "association_new", }, }, }, }); }); it("fences an ACTIVE association when notification permission drifts", async () => { const fenceStore = await activeFenceStore(); const unsubscribe = vi.fn(async () => true); const notificationClose = vi.fn(); const revoke = vi.fn(async () => webPushSuccess({ state: "REVOKED" as const }), ); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => false, permission: { permission: () => "denied", requestPermission: async () => "denied", }, registration: { active: true, pushManager: { getSubscription: async () => subscription(unsubscribe), subscribe: async () => subscription(unsubscribe), }, getNotifications: async () => [ { data: { protocol: WEB_PUSH_PROTOCOLS.click, notificationId: "notification_expired", routeIntent: "OPEN_INBOX", associationEpoch: "association_01", releaseEpoch: "release_01", expiresAt: "2026-07-27T00:00:00.000Z", }, close: notificationClose, }, ], }, fenceStore, gateway: gateway({ revoke }), vapidPublicKey, now: () => now, }); expect(await adapter.reconcile({ authority })).toEqual({ ok: true, value: { state: "PUSH_DENIED" }, }); expect(await fenceStore.read()).toMatchObject({ ok: true, value: { control: { association: { state: "REVOKED", associationEpoch: "association_01", }, }, }, }); expect(revoke).toHaveBeenCalledOnce(); expect(unsubscribe).toHaveBeenCalledOnce(); expect(notificationClose).toHaveBeenCalledOnce(); }); it("revokes local, backend, and native state on VAPID key drift", async () => { const fenceStore = await activeFenceStore(); const unsubscribe = vi.fn(async () => true); const native = subscription(unsubscribe); const wrongApplicationServerKey = applicationServerKey.slice(); wrongApplicationServerKey[1] = 99; const mismatched: WindowPushSubscriptionFacade = { ...native, options: { applicationServerKey: wrongApplicationServerKey.buffer, }, }; const revoke = vi.fn(async () => webPushSuccess({ state: "REVOKED" as const }), ); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: async () => mismatched, subscribe: async () => mismatched, }, getNotifications: async () => [], }, fenceStore, gateway: gateway({ revoke }), vapidPublicKey, now: () => now, }); expect(await adapter.reconcile({ authority })).toEqual({ ok: true, value: { state: "PUSH_UNAVAILABLE", reason: "SUBSCRIPTION_KEY_MISMATCH", }, }); expect(await fenceStore.read()).toMatchObject({ ok: true, value: { control: { association: { state: "REVOKED", associationEpoch: "association_01", }, }, }, }); expect(revoke).toHaveBeenCalledOnce(); expect(unsubscribe).toHaveBeenCalledOnce(); }); it("bounds a non-cooperative native subscription inspection", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const clock = manualScheduler(); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: () => new Promise(() => {}), subscribe: async () => subscription(), }, getNotifications: async () => [], }, fenceStore, gateway: gateway(), vapidPublicKey, nativeOperationDeadlineMs: 1, scheduler: clock.scheduler, }); const inspection = adapter.inspect({ authority }); await Promise.resolve(); clock.expireAll(); expect(await inspection).toMatchObject({ ok: false, error: { code: "DEADLINE_EXCEEDED" }, }); }); it("aborts a non-cooperative native operation on dispose", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: () => new Promise(() => {}), subscribe: async () => subscription(), }, getNotifications: async () => [], }, fenceStore, gateway: gateway(), vapidPublicKey, }); const inspection = adapter.inspect({ authority }); await Promise.resolve(); adapter.dispose(); expect(await inspection).toMatchObject({ ok: false, error: { code: "ABORTED" }, }); }); it("maps a throwing browser facade and releases the exclusive lease", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission() { throw new Error("permission provider unavailable"); }, requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: async () => null, subscribe: async () => subscription(), }, getNotifications: async () => [], }, fenceStore, gateway: gateway(), vapidPublicKey, }); for (let attempt = 0; attempt < 2; attempt += 1) { await expect( adapter.inspect({ authority }), ).resolves.toMatchObject({ ok: false, error: { code: "NATIVE_FAILURE", operation: "SUBSCRIPTION_INSPECT", }, }); } }); it("bounds a non-cooperative backend register and revokes its late commit", async () => { const fenceStore = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const clock = manualScheduler(); let signalRegisterStarted: (() => void) | undefined; const registerStarted = new Promise((resolve) => { signalRegisterStarted = resolve; }); let finishRegister: | (( result: Awaited< ReturnType >, ) => void) | undefined; const lateRegister = new Promise< Awaited> >((resolve) => { finishRegister = resolve; }); const revoke = vi.fn(async () => webPushSuccess({ state: "REVOKED" as const }), ); const adapter = createWebPushSubscriptionAdapter({ secureContext: true, userActivationIsActive: () => true, permission: { permission: () => "granted", requestPermission: async () => "granted", }, registration: { active: true, pushManager: { getSubscription: async () => subscription(), subscribe: async () => subscription(), }, getNotifications: async () => [], }, fenceStore, gateway: gateway({ register: async () => { signalRegisterStarted?.(); return await lateRegister; }, revoke, }), vapidPublicKey, backendOperationDeadlineMs: 1, scheduler: clock.scheduler, idempotencyKeyFactory: () => "idempotency_01", }); const enabling = adapter.enable({ authority, signal: new AbortController().signal, }); await registerStarted; clock.expireAll(); expect(await enabling).toMatchObject({ ok: false, error: { code: "DEADLINE_EXCEEDED" }, }); finishRegister?.( webPushSuccess({ associationEpoch: "association_late", sessionBindingEpoch: "session_01", }), ); for (let turn = 0; turn < 8; turn += 1) { await Promise.resolve(); } expect(revoke).toHaveBeenCalledWith({ associationEpoch: "association_late", signal: expect.any(AbortSignal), }); expect(await fenceStore.read()).toMatchObject({ ok: true, value: { control: { association: { state: "UNASSOCIATED" } }, }, }); }); });