mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
702 lines
28 KiB
TypeScript
702 lines
28 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' }],
|
|
});
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|