Files
webmail/lib/__tests__/web-push.test.ts
T

780 lines
31 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { EmailPushConfig, PushSubscription as JmapPushSubscription } from '@/lib/jmap/types';
import {
buildEmailPushConfig,
disableWebPush,
enableWebPush,
enableWebPushForAccounts,
listPushDevices,
isWebPushEnabled,
resetWebPushResyncState,
resyncWebPush,
revokePushDevice,
} from '@/lib/web-push';
const RELAY = 'https://relay.example';
const ACCOUNT_ID = 'acct-1';
const THIS_DEVICE = 'aaaaaaaabbbbbbbbccccccccdddddddd';
const OTHER_DEVICE = '11111111222222223333333344444444';
const DEVICE_KEY = `bulwark.push.deviceClientId.v1.${ACCOUNT_ID}`;
const SUB_KEY = `bulwark.push.subscriptionId.v1.${ACCOUNT_ID}`;
function sub(
id: string,
deviceClientId: string,
overrides: Partial<JmapPushSubscription> = {},
): JmapPushSubscription {
return {
id,
deviceClientId,
url: `${RELAY}/api/push/jmap/${deviceClientId}`,
keys: null,
expires: new Date(Date.now() + 80 * 24 * 60 * 60 * 1000).toISOString(),
types: ['EmailDelivery'],
...overrides,
};
}
interface FakeClient extends Partial<IJMAPClient> {
destroyed: string[];
}
const EMAIL_PUSH_CAP = 'urn:ietf:params:jmap:emailpush';
const JUNK_ID = 'mb-junk';
const SHARED_ACCOUNT_ID = 'acct-shared';
const SHARED_JUNK_ID = 'mb-shared-junk';
// The delivery filter enableWebPush is expected to install for ACCOUNT_ID
// given the mailboxes makeClient hands out.
const EXPECTED_EMAIL_PUSH: Record<string, EmailPushConfig> = {
[ACCOUNT_ID]: {
filter: {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [JUNK_ID] }],
},
properties: ['id', 'threadId'],
urgency: 'high',
},
[SHARED_ACCOUNT_ID]: {
filter: {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [SHARED_JUNK_ID] }],
},
properties: ['id', 'threadId'],
urgency: 'high',
},
};
function makeClient(
subs: JmapPushSubscription[],
options: { emailPushCapability?: boolean } = {},
): FakeClient & IJMAPClient {
const destroyed: string[] = [];
const mailbox = (id: string, role: string | undefined, accountId: string) => ({
id: accountId === ACCOUNT_ID ? id : `${accountId}:${id}`,
originalId: id,
name: role ?? id,
role,
accountId,
isShared: accountId !== ACCOUNT_ID,
});
const client = {
destroyed,
getAccountId: () => ACCOUNT_ID,
getCapabilities: () => (options.emailPushCapability ? { [EMAIL_PUSH_CAP]: {} } : {}),
getAllMailboxes: vi.fn(async () => [
mailbox('mb-inbox', 'inbox', ACCOUNT_ID),
mailbox(JUNK_ID, 'junk', ACCOUNT_ID),
mailbox('mb-archive', undefined, ACCOUNT_ID),
mailbox('mb-shared-inbox', 'inbox', SHARED_ACCOUNT_ID),
mailbox(SHARED_JUNK_ID, 'junk', SHARED_ACCOUNT_ID),
]),
listPushSubscriptions: vi.fn(async () => subs.filter((s) => !destroyed.includes(s.id))),
createPushSubscription: vi.fn(async () => 'push-new'),
verifyPushSubscription: vi.fn(async () => undefined),
updatePushSubscription: vi.fn(async () => true),
destroyPushSubscription: vi.fn(async (id: string) => {
destroyed.push(id);
}),
};
return client as unknown as FakeClient & IJMAPClient;
}
// enableWebPush needs a browser that supports push; jsdom has none of it.
function installPushBrowser() {
const browserSub = {
endpoint: 'https://fcm.example/endpoint',
// The key installFetch's relay advertises ('QUJD' = "ABC"), so the
// browser subscription belongs to that relay unless a test says otherwise.
options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer as ArrayBuffer | null },
getKey: () => new Uint8Array([1, 2, 3]).buffer,
unsubscribe: vi.fn(async () => true),
};
const registration = {
pushManager: {
getSubscription: vi.fn(async () => browserSub),
subscribe: vi.fn(async () => browserSub),
},
};
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: vi.fn(async () => registration),
register: vi.fn(async () => registration),
ready: Promise.resolve(registration),
},
});
(window as unknown as { PushManager: unknown }).PushManager = class {};
(window as unknown as { Notification: unknown }).Notification = { permission: 'granted' };
(globalThis as unknown as { Notification: unknown }).Notification = { permission: 'granted' };
return { browserSub, registration };
}
// Relay endpoints enableWebPush walks through, plus a per-device liveness answer.
function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
const calls: { url: string; method: string }[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? 'GET' });
const json = (body: unknown, status = 200) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}) as Response;
if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' });
if (url.includes('/api/push/verify/')) return json({ verificationCode: 'code-123' });
if (url.includes('/api/push/active/')) {
const device = url.split('/api/push/active/')[1];
const state = activeByDevice[device];
if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404);
return json({ active: state });
}
return json({});
});
vi.stubGlobal('fetch', fetchMock);
return calls;
}
beforeEach(() => {
localStorage.clear();
resetWebPushResyncState();
installPushBrowser();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('enableWebPush', () => {
it('reuses the recorded subscription by default', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
installFetch({});
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-old');
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(client.destroyed).toEqual([]);
});
it('destroys and recreates the recorded subscription with forceRecreate', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
installFetch({});
const result = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false });
expect(client.destroyed).toContain('push-old');
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123');
expect(result.subscriptionId).toBe('push-new');
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
});
it('leaves another device alone when the relay cannot vouch for it', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([sub('push-other', OTHER_DEVICE)]);
installFetch({ [OTHER_DEVICE]: 'unknown' });
await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false });
expect(client.destroyed).not.toContain('push-other');
});
it('reuses a browser subscription made with this relay key', async () => {
const { browserSub, registration } = installPushBrowser();
installFetch({});
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
expect(registration.pushManager.subscribe).not.toHaveBeenCalled();
});
it('replaces a browser subscription made with another relay key', async () => {
const { browserSub, registration } = installPushBrowser();
browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer;
const calls = installFetch({});
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
const [{ applicationServerKey }] = registration.pushManager.subscribe.mock.calls[0] as unknown as [
{ applicationServerKey: Uint8Array },
];
expect(Array.from(applicationServerKey)).toEqual([65, 66, 67]);
expect(calls.some((c) => c.url.endsWith('/api/push/register/web'))).toBe(true);
});
it('subscribes anyway when the browser cannot retrieve the existing subscription', async () => {
// Firefox rejects the lookup with `AbortError: Error retrieving push
// subscription` when its local record is stale; that must not abort enable.
const { registration } = installPushBrowser();
registration.pushManager.getSubscription.mockRejectedValue(
new DOMException('Error retrieving push subscription', 'AbortError'),
);
installFetch({});
const result = await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false });
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
expect(result.subscriptionId).toBe('push-new');
});
it('still subscribes when unsubscribing the mismatched subscription fails', async () => {
const { browserSub, registration } = installPushBrowser();
browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer;
browserSub.unsubscribe.mockRejectedValue(new Error('unsubscribe failed'));
installFetch({});
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false });
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
});
it('replaces a browser subscription whose key the browser does not expose', async () => {
const { browserSub, registration } = installPushBrowser();
browserSub.options.applicationServerKey = null;
installFetch({});
await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
});
});
// Registrations made before the client learned about the delivery filter are
// repaired in the background on app start - nobody should have to find the
// settings toggle to stop the spam pushes.
describe('resyncWebPush', () => {
it('repairs a lost browser subscription for an account that previously opted in', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const { registration } = installPushBrowser();
registration.pushManager.getSubscription.mockResolvedValue(null as never);
const client = makeClient([sub('push-old', THIS_DEVICE)]);
const calls = installFetch({});
expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(false);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/web`, method: 'POST' });
});
it.each(['default', 'denied'])('does not resubscribe or prompt with %s permission', async (permission) => {
localStorage.setItem(SUB_KEY, 'push-old');
const { registration } = installPushBrowser();
vi.stubGlobal('Notification', { permission, requestPermission: vi.fn() });
const client = makeClient([sub('push-old', THIS_DEVICE)]);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(registration.pushManager.subscribe).not.toHaveBeenCalled();
expect(Notification.requestPermission).not.toHaveBeenCalled();
});
it('allows retry after a transient relay failure', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([sub('push-old', THIS_DEVICE)]);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
installFetch({});
// Not on the very next tab focus...
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
// ...but a quarter of an hour later.
const later = Date.now() + 16 * 60_000;
vi.spyOn(Date, 'now').mockReturnValue(later);
try {
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
} finally {
vi.mocked(Date.now).mockRestore();
}
});
it('keeps the opt-in when recreating the subscription fails', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
// The server lost the old subscription, and creating a new one fails.
const client = makeClient([]);
vi.mocked(client.createPushSubscription).mockRejectedValue(new Error('server down'));
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('re-syncs an enabled registration and installs the missing filter', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(client.updatePushSubscription).toHaveBeenCalledWith(
'push-old',
expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }),
);
});
it('does nothing when push is not enabled for the account', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([], { emailPushCapability: true });
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(client.listPushSubscriptions).not.toHaveBeenCalled();
expect(client.createPushSubscription).not.toHaveBeenCalled();
});
it('runs at most once a day per account', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
installFetch({});
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1);
// A tab left open past a day renews again, before Stalwart's 7-day expiry.
now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(client.listPushSubscriptions).toHaveBeenCalledTimes(2);
now.mockRestore();
});
it('swallows failures instead of surfacing them to the app', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
client.listPushSubscriptions = vi.fn(async () => {
throw new Error('server down');
});
vi.stubGlobal('fetch', vi.fn(async () => {
throw new Error('relay down');
}));
await expect(resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).resolves.toBe(false);
});
});
// Spam filed straight into Junk used to wake the device: `EmailDelivery`
// fires for every ingested message. Servers that implement
// draft-ietf-jmap-emailpush evaluate a per-account filter before pushing, so
// the subscription carries one that excludes $junk and the Junk mailbox.
describe('enableWebPush delivery filter (emailPush)', () => {
it('installs a junk-excluding filter for every account when the server supports it', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([], { emailPushCapability: true });
installFetch({});
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
const params = (client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(params.types).toEqual(['EmailDelivery']);
expect(params.emailPush).toEqual(EXPECTED_EMAIL_PUSH);
});
it('sends no emailPush to a server without the capability', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([]);
installFetch({});
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
const params = (client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(params).not.toHaveProperty('emailPush');
expect(client.getAllMailboxes).not.toHaveBeenCalled();
});
it('adds the filter to an existing subscription that predates it', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
installFetch({});
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-old');
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(client.updatePushSubscription).toHaveBeenCalledWith(
'push-old',
expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }),
);
});
it('re-syncs a filter whose Junk mailbox id went stale', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const stale = {
...EXPECTED_EMAIL_PUSH,
[ACCOUNT_ID]: {
...EXPECTED_EMAIL_PUSH[ACCOUNT_ID],
filter: {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['mb-junk-deleted'] }],
},
},
};
const client = makeClient(
[sub('push-old', THIS_DEVICE, { emailPush: stale })],
{ emailPushCapability: true },
);
installFetch({});
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.updatePushSubscription).toHaveBeenCalledWith(
'push-old',
expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }),
);
});
it('leaves a fresh subscription alone when its filter already matches', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
// Key order differs from what we build - must still compare equal.
const stored = JSON.parse(JSON.stringify(EXPECTED_EMAIL_PUSH));
stored[ACCOUNT_ID] = {
urgency: 'high',
properties: ['id', 'threadId'],
filter: stored[ACCOUNT_ID].filter,
};
const client = makeClient(
[sub('push-old', THIS_DEVICE, { emailPush: stored })],
{ emailPushCapability: true },
);
installFetch({});
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.updatePushSubscription).not.toHaveBeenCalled();
});
});
// Inbox-only mode swaps the "not in Junk" mailbox condition for a positive
// "in Inbox" one, so mail a Sieve rule files into any other folder never
// reaches the push subscription.
describe('buildEmailPushConfig inbox-only mode', () => {
it('keeps current behaviour when inboxOnly is false', async () => {
const client = makeClient([], { emailPushCapability: true });
expect(await buildEmailPushConfig(client, false)).toEqual(EXPECTED_EMAIL_PUSH);
});
it('requires the message to be in each account Inbox when inboxOnly is true', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, true);
expect(config[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
});
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-shared-inbox' }],
});
});
it('throws when an account has no discoverable Inbox mailbox', async () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => [
{ id: JUNK_ID, originalId: JUNK_ID, name: 'junk', role: 'junk', accountId: ACCOUNT_ID },
]) as unknown as IJMAPClient['getAllMailboxes'];
await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/No Inbox mailbox/);
});
it('mutes a shared account whose Inbox we cannot see instead of failing', async () => {
// Someone shared a single folder: the account shows up with no Inbox role.
// Leaving it out of the map would let the server push it unfiltered.
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => [
{ id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID },
{ id: `${SHARED_ACCOUNT_ID}:mb-proj`, originalId: 'mb-proj', name: 'Projects', accountId: SHARED_ACCOUNT_ID },
]) as unknown as IJMAPClient['getAllMailboxes'];
const config = await buildEmailPushConfig(client, true);
expect(config[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
});
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
});
it('rethrows a mailbox-fetch failure in inbox-only mode instead of reporting no Inbox', async () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => {
throw new Error('JMAP down');
}) as unknown as IJMAPClient['getAllMailboxes'];
await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/JMAP down/);
});
it('still emits a filter for an account that has an Inbox but no Junk mailbox', async () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => [
{ id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID },
{ id: `${SHARED_ACCOUNT_ID}:mb-si`, originalId: 'mb-si', name: 'inbox', role: 'inbox', accountId: SHARED_ACCOUNT_ID },
]) as unknown as IJMAPClient['getAllMailboxes'];
const config = await buildEmailPushConfig(client, false);
expect(Object.keys(config).sort()).toEqual([ACCOUNT_ID, SHARED_ACCOUNT_ID].sort());
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }],
});
});
});
// Muted accounts and folders (#1169) are left out by the server-side filter.
describe('buildEmailPushConfig with muted folders and accounts', () => {
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}) => ({ accounts, mailboxes });
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, false, mutes([], { [`${ACCOUNT_ID}/mb-archive`]: true }));
expect(config[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [JUNK_ID, 'mb-archive'].sort() }],
});
// Another account's folders are untouched.
expect(config[SHARED_ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[SHARED_ACCOUNT_ID]);
});
it('silences a muted account entirely', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, false, mutes([SHARED_ACCOUNT_ID]));
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config[ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[ACCOUNT_ID]);
});
it('lets a muted account notify for the folders unmuted again', async () => {
const client = makeClient([], { emailPushCapability: true });
const one = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], { [`${ACCOUNT_ID}/mb-archive`]: false }));
expect(one[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-archive' }],
});
const two = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], {
[`${ACCOUNT_ID}/mb-inbox`]: false,
[`${ACCOUNT_ID}/mb-archive`]: false,
// Gone from the server: must not end up in the filter.
[`${ACCOUNT_ID}/mb-deleted`]: false,
}));
expect(two[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [
{ notKeyword: '$junk' },
{ operator: 'OR', conditions: [{ inMailbox: 'mb-archive' }, { inMailbox: 'mb-inbox' }] },
],
});
});
it('silences the account in inbox-only mode when its Inbox is muted', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, true, mutes([], { [`${SHARED_ACCOUNT_ID}/mb-shared-inbox`]: true }));
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
});
});
it('fails instead of dropping the mutes when the mailboxes cannot be read', async () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => {
throw new Error('JMAP down');
}) as unknown as IJMAPClient['getAllMailboxes'];
await expect(buildEmailPushConfig(client, false, mutes([ACCOUNT_ID]))).rejects.toThrow(/JMAP down/);
});
});
describe('disableWebPush', () => {
it('destroys every subscription this device owns, not just the recorded id', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-tracked');
const client = makeClient([
sub('push-tracked', THIS_DEVICE),
// Left behind by an earlier enable whose destroy never landed.
sub('push-orphan', THIS_DEVICE),
sub('push-other', OTHER_DEVICE),
]);
installFetch({});
await disableWebPush({ client, relayBaseUrl: RELAY });
expect(client.destroyed.sort()).toEqual(['push-orphan', 'push-tracked']);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
// The deviceClientId survives so a later enable reuses the relay record.
expect(localStorage.getItem(DEVICE_KEY)).toBe(THIS_DEVICE);
});
it('drops the relay registration for this device', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([]);
const calls = installFetch({});
await disableWebPush({ client, relayBaseUrl: RELAY });
expect(calls).toContainEqual({
url: `${RELAY}/api/push/register/${THIS_DEVICE}`,
method: 'DELETE',
});
});
});
describe('listPushDevices', () => {
it('flags this device and reports the relay status of each registration', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([
sub('push-mine', THIS_DEVICE),
sub('push-live', OTHER_DEVICE),
sub('push-dead', 'deaddeaddeaddeaddeaddeaddeaddead'),
sub('push-foreign', 'ffffffffffffffffffffffffffffffff'),
]);
installFetch({
[THIS_DEVICE]: true,
[OTHER_DEVICE]: true,
deaddeaddeaddeaddeaddeaddeaddead: false,
});
const devices = await listPushDevices({ client, relayBaseUrl: RELAY });
expect(devices.map((d) => [d.id, d.isThisDevice, d.relayStatus])).toEqual([
['push-mine', true, 'active'],
['push-live', false, 'active'],
['push-dead', false, 'inactive'],
['push-foreign', false, 'unknown'],
]);
});
});
describe('revokePushDevice', () => {
it('destroys another device and frees its relay registration', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
const client = makeClient([sub('push-mine', THIS_DEVICE), sub('push-other', OTHER_DEVICE)]);
const calls = installFetch({});
await revokePushDevice({
client,
relayBaseUrl: RELAY,
device: { id: 'push-other', deviceClientId: OTHER_DEVICE, isThisDevice: false },
});
expect(client.destroyed).toEqual(['push-other']);
expect(calls).toContainEqual({
url: `${RELAY}/api/push/register/${OTHER_DEVICE}`,
method: 'DELETE',
});
// Revoking elsewhere must not disturb this browser's own registration.
expect(localStorage.getItem(SUB_KEY)).toBe('push-mine');
});
it('runs the full local teardown when revoking this device', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({});
await revokePushDevice({
client,
relayBaseUrl: RELAY,
device: { id: 'push-mine', deviceClientId: THIS_DEVICE, isThisDevice: true },
});
expect(client.destroyed).toEqual(['push-mine']);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
});
describe('enableWebPushForAccounts', () => {
it('registers every account against the one browser subscription', async () => {
const first = makeClient([]);
const second = makeClient([]);
installFetch({});
const result = await enableWebPushForAccounts([
{ accountId: 'a', client: first, accountLabel: 'one' },
{ accountId: 'b', client: second, accountLabel: 'two' },
], { relayBaseUrl: RELAY, inboxOnly: false });
expect(result.enabled).toEqual(['a', 'b']);
expect(result.failed).toEqual([]);
expect(first.createPushSubscription).toHaveBeenCalledTimes(1);
expect(second.createPushSubscription).toHaveBeenCalledTimes(1);
});
it('stops at a browser-level refusal instead of prompting once per account', async () => {
const first = makeClient([]);
const second = makeClient([]);
installFetch({});
// Blocked notifications are a property of the browser, not of an account:
// walking on would fail identically for every one of them.
(globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
(window as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
const result = await enableWebPushForAccounts([
{ accountId: 'a', client: first },
{ accountId: 'b', client: second },
], { relayBaseUrl: RELAY, inboxOnly: false });
expect(result.enabled).toEqual([]);
expect(result.failed.map(f => f.accountId)).toEqual(['a', 'b']);
expect(second.createPushSubscription).not.toHaveBeenCalled();
});
});