Files
tech-log-frontend/tests/unit/web-push-subscription-adapter.test.ts

831 lines
24 KiB
TypeScript

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<ArrayBuffer> {
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> = {},
): 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<number, () => 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<WindowPushSubscriptionFacade | null>(() => {}),
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<WindowPushSubscriptionFacade | null>(() => {}),
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<void>((resolve) => {
signalRegisterStarted = resolve;
});
let finishRegister:
| ((
result: Awaited<
ReturnType<WebPushRegistrationGateway["register"]>
>,
) => void)
| undefined;
const lateRegister = new Promise<
Awaited<ReturnType<WebPushRegistrationGateway["register"]>>
>((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" } },
},
});
});
});