mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
1992 lines
87 KiB
TypeScript
1992 lines
87 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
// Provide the Android native FCM surface setupPushNotifications needs. The
|
||
// global test-setup mocks react-native with an empty NativeModules, so override
|
||
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
|
||
// the runtime permission request).
|
||
vi.mock('react-native', () => {
|
||
class NativeEventEmitter {
|
||
addListener() {
|
||
return { remove: () => undefined };
|
||
}
|
||
}
|
||
return {
|
||
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
|
||
NativeModules: {
|
||
BulwarkFcm: {
|
||
getToken: vi.fn(async () => 'fcm-token-xyz'),
|
||
deleteToken: vi.fn(async () => undefined),
|
||
},
|
||
BulwarkUnifiedPush: {
|
||
getDistributors: vi.fn(async () => ['io.heckel.ntfy']),
|
||
getSavedDistributor: vi.fn(async () => 'io.heckel.ntfy'),
|
||
getAckDistributor: vi.fn(async () => 'io.heckel.ntfy'),
|
||
saveDistributor: vi.fn(async () => undefined),
|
||
register: vi.fn(async () => undefined),
|
||
unregister: vi.fn(async () => undefined),
|
||
getEndpoint: vi.fn(async () => ({
|
||
url: 'https://ntfy.sh/upAbCdEf?up=1',
|
||
p256dh: 'B'.repeat(87),
|
||
auth: 'a'.repeat(22),
|
||
})),
|
||
},
|
||
},
|
||
NativeEventEmitter,
|
||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||
// What the stores the server check drops from need at load.
|
||
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) },
|
||
Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) },
|
||
Linking: { openURL: async () => undefined },
|
||
};
|
||
});
|
||
|
||
// What createPushSubscription resolves to: the new id and the expiry the
|
||
// server settled on (unknown here unless a test says otherwise).
|
||
const { CREATED } = vi.hoisted(() => ({
|
||
CREATED: { id: 'new-server-id', expires: null as string | null },
|
||
}));
|
||
|
||
// A client of another signed-in account (device sync patches its push types
|
||
// without the singleton), on a server with contacts and calendars unless a
|
||
// test says otherwise.
|
||
const { DETACHED, SYNC_SESSION } = vi.hoisted(() => {
|
||
const SYNC_SESSION = {
|
||
capabilities: {
|
||
'urn:ietf:params:jmap:core': {},
|
||
'urn:ietf:params:jmap:contacts': {},
|
||
'urn:ietf:params:jmap:calendars': {},
|
||
} as Record<string, unknown>,
|
||
};
|
||
return {
|
||
SYNC_SESSION,
|
||
DETACHED: {
|
||
loaded: true,
|
||
types: ['EmailDelivery'] as string[],
|
||
deviceClientId: null as string | null,
|
||
// The subscription's expiry, and what an update of it settles on.
|
||
expires: null as string | null,
|
||
settles: null as string | null,
|
||
// Every subscription of the account, for a listing with ids: null, and
|
||
// whether that listing fails.
|
||
all: [] as Array<{ id: string; deviceClientId?: string }>,
|
||
listFails: false,
|
||
log: [] as unknown[],
|
||
session: SYNC_SESSION,
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock('../../api/jmap-client', () => ({
|
||
jmapClient: {
|
||
username: 'user@example.com',
|
||
serverUrl: 'https://mail.example.com',
|
||
isConnected: true,
|
||
accountId: 'jmap-primary',
|
||
currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } },
|
||
},
|
||
JMAPClient: class {
|
||
get currentSession() {
|
||
return DETACHED.session;
|
||
}
|
||
async loadAccount(id: string) {
|
||
DETACHED.log.push(['load', id]);
|
||
return DETACHED.loaded;
|
||
}
|
||
get username() {
|
||
return 'bob';
|
||
}
|
||
get accountId() {
|
||
return 'bob-jmap';
|
||
}
|
||
async request(calls: Array<[string, Record<string, any>, string]>) {
|
||
const methodResponses = calls.map(([name, args, id]) => {
|
||
DETACHED.log.push([name, args]);
|
||
if (name === 'PushSubscription/get' && args.ids === null) {
|
||
if (DETACHED.listFails) throw new Error('network down');
|
||
return [name, { list: DETACHED.all }, id];
|
||
}
|
||
if (args.destroy) return [name, { destroyed: args.destroy }, id];
|
||
if (name === 'PushSubscription/get') {
|
||
const listed = {
|
||
id: args.ids[0],
|
||
types: DETACHED.types,
|
||
...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}),
|
||
...(DETACHED.expires ? { expires: DETACHED.expires } : {}),
|
||
};
|
||
return [name, { list: [listed] }, id];
|
||
}
|
||
const target = Object.keys(args.update)[0];
|
||
if (args.update[target].expires && DETACHED.settles) DETACHED.expires = DETACHED.settles;
|
||
return [name, { updated: { [target]: null } }, id];
|
||
});
|
||
return { methodResponses };
|
||
}
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../api/email', () => ({
|
||
getMailboxes: vi.fn(async () => [
|
||
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
|
||
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
|
||
]),
|
||
getSharedMailboxes: vi.fn(async () => []),
|
||
}));
|
||
|
||
vi.mock('../../api/push', () => ({
|
||
listPushSubscriptions: vi.fn(async () => []),
|
||
createPushSubscription: vi.fn(async () => CREATED),
|
||
verifyPushSubscription: vi.fn(async () => undefined),
|
||
destroyPushSubscription: vi.fn(async () => undefined),
|
||
updatePushSubscription: vi.fn(async () => undefined),
|
||
}));
|
||
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import {
|
||
setupPushNotifications,
|
||
deviceClientIdKey,
|
||
disablePushForAccount,
|
||
enablePushFromSettings,
|
||
forgetPushServerState,
|
||
isValidRelayUrl,
|
||
mailAccountsWithoutPush,
|
||
notificationTapJmapAccountId,
|
||
readPushAccountIds,
|
||
readPushJmapAccountIds,
|
||
PushSetupError,
|
||
pushTypesFor,
|
||
readPushAccountStates,
|
||
refreshPushSubscriptionTypes,
|
||
renewOtherPushAccounts,
|
||
resyncPushNotifications,
|
||
revokePushDevice,
|
||
teardownPushNotificationsForAccount,
|
||
} from '../push-notifications';
|
||
import {
|
||
listPushSubscriptions,
|
||
createPushSubscription,
|
||
destroyPushSubscription,
|
||
updatePushSubscription,
|
||
verifyPushSubscription,
|
||
} from '../../api/push';
|
||
import { getSharedMailboxes } from '../../api/email';
|
||
import { JMAPMethodError, JMAPSetError } from '../../api/jmap-result';
|
||
import type { EmailPushConfig, JMAPSession } from '../../api/types';
|
||
import { jmapClient } from '../../api/jmap-client';
|
||
import { NativeModules } from 'react-native';
|
||
import { generateAccountId } from '../account-utils';
|
||
import { reconcileServerFingerprint } from '../server-change';
|
||
|
||
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
|
||
const RELAY = 'https://relay.example.com';
|
||
|
||
// State the fake relay reports for each foreign deviceClientId's /active probe.
|
||
type RelayState = 'dead' | 'live' | 'unknown';
|
||
|
||
function installFetch(states: Record<string, RelayState>): void {
|
||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = typeof input === 'string' ? input : input.toString();
|
||
if (url.includes('/api/push/register')) {
|
||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||
}
|
||
if (url.includes('/api/push/verify/')) {
|
||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||
}
|
||
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
|
||
if (active) {
|
||
const dcid = decodeURIComponent(active[1]);
|
||
const state = states[dcid] ?? 'unknown';
|
||
if (state === 'unknown') {
|
||
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
|
||
}
|
||
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
}) as typeof fetch;
|
||
}
|
||
|
||
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
||
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
||
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
|
||
const updateMock = updatePushSubscription as ReturnType<typeof vi.fn>;
|
||
const SUB_KEY = 'push:subscriptionId:v2:' + ACCOUNT_ID;
|
||
|
||
function sub(id: string, deviceClientId: string) {
|
||
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
|
||
}
|
||
|
||
describe('setupPushNotifications leftover reaping', () => {
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
// Pin our deviceClientId so we control which leftovers are "ours".
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
});
|
||
|
||
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
|
||
listMock.mockResolvedValue([
|
||
sub('own-old', OUR_DCID), // our own previous attempt -> reap
|
||
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
|
||
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
|
||
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
|
||
]);
|
||
installFetch({
|
||
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
|
||
livelivelivelivelivelivelivelive: 'live',
|
||
unknwunknwunknwunknwunknwunknwun: 'unknown',
|
||
});
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.verified).toBe(true);
|
||
const reaped = destroyMock.mock.calls.map((c) => c[0]);
|
||
expect(reaped).toContain('own-old');
|
||
expect(reaped).toContain('foreign-dead');
|
||
expect(reaped).not.toContain('foreign-live');
|
||
expect(reaped).not.toContain('foreign-unknown');
|
||
});
|
||
|
||
it('keeps foreign subs when the relay probe fails (network error)', async () => {
|
||
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
|
||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = typeof input === 'string' ? input : input.toString();
|
||
if (url.includes('/api/push/register')) {
|
||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||
}
|
||
if (url.includes('/api/push/verify/')) {
|
||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||
}
|
||
if (url.includes('/api/push/active/')) throw new Error('network down');
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
}) as typeof fetch;
|
||
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
|
||
});
|
||
|
||
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
|
||
listMock.mockResolvedValue([]);
|
||
installFetch({});
|
||
|
||
// Fire several overlapping setups, as App.tsx does while auth settles and
|
||
// on FCM token refresh. Only one underlying JMAP subscription must be made.
|
||
const results = await Promise.all([
|
||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||
]);
|
||
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('setupPushNotifications subscription shape', () => {
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
listMock.mockResolvedValue([]);
|
||
updateMock.mockResolvedValue(true);
|
||
installFetch({});
|
||
});
|
||
|
||
it('subscribes to EmailDelivery only', async () => {
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(createMock.mock.calls[0][0].types).toEqual(['EmailDelivery']);
|
||
expect(createMock.mock.calls[0][0].emailPush).toBeUndefined();
|
||
});
|
||
|
||
it('records the JMAP account id so pushes can be routed per account', async () => {
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(await readPushJmapAccountIds()).toEqual({ [ACCOUNT_ID]: 'jmap-primary' });
|
||
});
|
||
|
||
it('adds a junk-excluding emailPush filter when the server advertises emailpush', async () => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||
};
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
const emailPush = createMock.mock.calls[0][0].emailPush;
|
||
expect(emailPush['jmap-primary']).toEqual({
|
||
filter: {
|
||
operator: 'AND',
|
||
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['junk'] }],
|
||
},
|
||
properties: ['id', 'threadId'],
|
||
urgency: 'high',
|
||
});
|
||
});
|
||
|
||
it('patches types on an existing subscription that still listens to Email/Mailbox', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([
|
||
{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['Email', 'EmailDelivery', 'Mailbox'] },
|
||
]);
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(result.subscriptionId).toBe('existing');
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(updateMock.mock.calls[0][1].types).toEqual(['EmailDelivery']);
|
||
});
|
||
|
||
it('leaves a healthy subscription alone', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([
|
||
{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['EmailDelivery'] },
|
||
]);
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('leaves an emailPush filter alone when the server already holds it', async () => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||
};
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([
|
||
{
|
||
id: 'existing',
|
||
deviceClientId: OUR_DCID,
|
||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||
types: ['EmailDelivery'],
|
||
// As the server echoes it back: same content, its own key order.
|
||
emailPush: {
|
||
'jmap-primary': {
|
||
urgency: 'high',
|
||
properties: ['id', 'threadId'],
|
||
filter: { conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['junk'] }], operator: 'AND' },
|
||
},
|
||
},
|
||
},
|
||
]);
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('re-patches an emailPush filter that has drifted', async () => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||
};
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([
|
||
{
|
||
id: 'existing',
|
||
deviceClientId: OUR_DCID,
|
||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||
types: ['EmailDelivery'],
|
||
emailPush: {
|
||
'jmap-primary': {
|
||
filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['old-junk'] }] },
|
||
properties: ['id', 'threadId'],
|
||
urgency: 'high',
|
||
},
|
||
},
|
||
},
|
||
]);
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(updateMock.mock.calls[0][1].emailPush['jmap-primary'].filter.conditions[1]).toEqual({
|
||
inMailboxOtherThan: ['junk'],
|
||
});
|
||
});
|
||
|
||
it('forceRecreate destroys the recorded subscription and creates a new one', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([
|
||
{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 80 * 86400000).toISOString(), types: ['EmailDelivery'] },
|
||
]);
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true });
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).toContain('existing');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(result.subscriptionId).toBe('new-server-id');
|
||
});
|
||
|
||
it('says the mail server refuses a relay that is not on a public domain (Stalwart 1.0)', async () => {
|
||
const relay = 'https://relay.home.lan';
|
||
createMock.mockRejectedValueOnce(new JMAPSetError(
|
||
'invalidProperties',
|
||
'Failed to create push subscription new: invalidProperties – properties=[url] – Host is not a public domain name',
|
||
['url'],
|
||
));
|
||
const err = await setupPushNotifications({ relayBaseUrl: relay }).catch((e) => e);
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('jmap');
|
||
expect(err.message).toContain(relay);
|
||
expect(err.message).toContain('public domain name');
|
||
expect(err.message).not.toContain('properties=[url]');
|
||
});
|
||
|
||
it('keeps the relay a working registration goes through when the server refuses the new one', async () => {
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 5 * 86400000).toISOString(), types: ['EmailDelivery'] }]);
|
||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
expect(destroyMock).not.toHaveBeenCalledWith('existing');
|
||
const relayCalls = fetchMock.mock.calls
|
||
.filter((c) => String(c[0]).includes('/api/push/register'))
|
||
.map((c) => `${(c[1] as RequestInit | undefined)?.method ?? 'GET'} ${String(c[0])}`);
|
||
// The refused URL may be another name of the relay in use: after dropping
|
||
// the record there, this device registers with the previous relay again.
|
||
expect(relayCalls).toEqual([
|
||
'POST https://relay.home.lan/api/push/register',
|
||
`DELETE https://relay.home.lan/api/push/register/${OUR_DCID}`,
|
||
`POST ${RELAY}/api/push/register`,
|
||
]);
|
||
const restored = fetchMock.mock.calls.filter((c) => String(c[0]) === `${RELAY}/api/push/register`);
|
||
expect(JSON.parse(String((restored[0][1] as RequestInit).body))).toMatchObject({ subscriptionId: OUR_DCID, fcmToken: 'fcm-token-xyz' });
|
||
|
||
// The same relay refused (a server that tightened its rules) stays as it is.
|
||
fetchMock.mockClear();
|
||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||
await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch(() => undefined);
|
||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||
expect(fetchMock.mock.calls.some((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false);
|
||
});
|
||
|
||
it('hands the settings the relay in use back after the server refused a new one', async () => {
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 5 * 86400000).toISOString(), types: ['EmailDelivery'] }]);
|
||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||
|
||
const outcome = await enablePushFromSettings({ relayBaseUrl: 'https://relay.home.lan/', forceRecreate: true });
|
||
|
||
expect(outcome.error).toBeInstanceOf(PushSetupError);
|
||
expect(outcome.relayBaseUrl).toBe(RELAY);
|
||
expect(outcome.enabled).toBe(true);
|
||
|
||
// Nothing in use yet: the typed relay stays, so it can be corrected.
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
listMock.mockResolvedValue([]);
|
||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||
expect(await enablePushFromSettings({ relayBaseUrl: 'https://relay.home.lan/' }))
|
||
.toMatchObject({ relayBaseUrl: 'https://relay.home.lan', enabled: false });
|
||
|
||
// A run that works shows the relay it registered with.
|
||
expect(await enablePushFromSettings({ relayBaseUrl: 'https://relay.example.com/' }))
|
||
.toEqual({ error: null, relayBaseUrl: RELAY, enabled: true });
|
||
});
|
||
|
||
it('rejects plain-http relay URLs', async () => {
|
||
await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' });
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('surfaces the relay error body and phase when registration fails', async () => {
|
||
global.fetch = vi.fn(async () => ({
|
||
ok: false,
|
||
status: 400,
|
||
text: async () => JSON.stringify({ error: 'Invalid fcmToken' }),
|
||
})) as unknown as typeof fetch;
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('relay');
|
||
expect(err.message).toContain('Invalid fcmToken');
|
||
});
|
||
|
||
it('tags a Firebase token failure with the token phase', async () => {
|
||
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
|
||
native.getToken.mockRejectedValueOnce(new Error('SERVICE_NOT_AVAILABLE'));
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
expect(err.phase).toBe('token');
|
||
expect(err.message).toContain('SERVICE_NOT_AVAILABLE');
|
||
});
|
||
});
|
||
|
||
describe('push types for device sync (#34)', () => {
|
||
const DEVICE_SYNC_KEY = 'device-sync:v1';
|
||
const CONTACTS = 'com.android.contacts';
|
||
const CALENDAR = 'com.android.calendar';
|
||
const healthy = (types: string[]) => ({
|
||
id: 'existing',
|
||
deviceClientId: OUR_DCID,
|
||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||
types,
|
||
});
|
||
|
||
async function syncing(enabled: Record<string, boolean>, extra: Record<string, unknown> = {}): Promise<void> {
|
||
await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({
|
||
state: { accounts: { [ACCOUNT_ID]: { enabled, ...extra } } },
|
||
version: 0,
|
||
}));
|
||
}
|
||
|
||
// A server that does not know a type refuses the whole write.
|
||
const refuseUnknownTypes = (types: string[] | undefined) => {
|
||
const unknown = (types ?? []).filter((type) => type !== 'EmailDelivery');
|
||
if (unknown.length > 0) throw new JMAPMethodError('invalidProperties', `Unknown types: ${unknown.join(', ')}`);
|
||
};
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
(jmapClient as { currentSession: unknown }).currentSession = SYNC_SESSION;
|
||
DETACHED.session = SYNC_SESSION;
|
||
listMock.mockResolvedValue([]);
|
||
updateMock.mockResolvedValue(true);
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
DETACHED.session = SYNC_SESSION;
|
||
createMock.mockImplementation(async () => CREATED);
|
||
updateMock.mockImplementation(async () => undefined);
|
||
});
|
||
|
||
it('adds the contact and calendar types of what the account syncs to the device', async () => {
|
||
expect(await pushTypesFor(ACCOUNT_ID, SYNC_SESSION as unknown as JMAPSession)).toEqual(['EmailDelivery']);
|
||
await syncing({ [CONTACTS]: true, [CALENDAR]: false });
|
||
expect(await pushTypesFor(ACCOUNT_ID, SYNC_SESSION as unknown as JMAPSession))
|
||
.toEqual(['EmailDelivery', 'ContactCard', 'AddressBook']);
|
||
expect(await pushTypesFor('someone@else.example', SYNC_SESSION as unknown as JMAPSession)).toEqual(['EmailDelivery']);
|
||
// Removed in Android Settings: nothing to route any more.
|
||
await syncing({ [CONTACTS]: true }, { removedInAndroidSettings: true });
|
||
expect(await pushTypesFor(ACCOUNT_ID, SYNC_SESSION as unknown as JMAPSession)).toEqual(['EmailDelivery']);
|
||
});
|
||
|
||
it('adds only the types of what the server offers', async () => {
|
||
await syncing({ [CONTACTS]: true, [CALENDAR]: true });
|
||
const calendarsOnly = { capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:calendars': {} } };
|
||
expect(await pushTypesFor(ACCOUNT_ID, calendarsOnly as unknown as JMAPSession))
|
||
.toEqual(['EmailDelivery', 'CalendarEvent', 'Calendar']);
|
||
expect(await pushTypesFor(ACCOUNT_ID, null)).toEqual(['EmailDelivery']);
|
||
|
||
// Sync was turned on while another account's server was served; this one
|
||
// has neither: its subscription stays mail-only.
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
createMock.mockImplementation(async (params: { types: string[] }) => {
|
||
refuseUnknownTypes(params.types);
|
||
return CREATED;
|
||
});
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(result.subscriptionId).toBe('new-server-id');
|
||
expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery']]);
|
||
});
|
||
|
||
it("does not add types another account's server does not offer", async () => {
|
||
const OTHER = 'bob@other.example.net';
|
||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'bob-sub');
|
||
await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({
|
||
state: { accounts: { [OTHER]: { enabled: { [CONTACTS]: true } } } },
|
||
version: 0,
|
||
}));
|
||
DETACHED.session = { capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:calendars': {} } };
|
||
DETACHED.log = [];
|
||
DETACHED.types = ['EmailDelivery'];
|
||
await refreshPushSubscriptionTypes(OTHER);
|
||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||
});
|
||
|
||
it('keeps mail push on a server that refuses the device sync types', async () => {
|
||
await syncing({ [CONTACTS]: true });
|
||
createMock.mockImplementation(async (params: { types: string[] }) => {
|
||
refuseUnknownTypes(params.types);
|
||
return CREATED;
|
||
});
|
||
updateMock.mockImplementation(async (_id: string, patch: { types?: string[] }) => refuseUnknownTypes(patch.types));
|
||
|
||
// A new subscription: mail-only rather than none.
|
||
const created = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(created).toEqual({ subscriptionId: 'new-server-id', verified: true });
|
||
expect(createMock.mock.calls.map((c) => c[0].types))
|
||
.toEqual([['EmailDelivery', 'ContactCard', 'AddressBook'], ['EmailDelivery']]);
|
||
|
||
// The working subscription is kept and its expiry pushed forward, never
|
||
// replaced by one the server refuses as well.
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy(['EmailDelivery'])]);
|
||
const refreshed = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(refreshed.subscriptionId).toBe('existing');
|
||
expect(updateMock.mock.calls.map((c) => c[1].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook'], undefined]);
|
||
expect(updateMock.mock.calls[1][1].expires).toBeDefined();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("retries with the mail types alone when Stalwart refuses the types property", async () => {
|
||
await syncing({ [CONTACTS]: true });
|
||
// Stalwart's own answer to a type it doesn't know.
|
||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Invalid data type.', ['types']));
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(createMock.mock.calls.map((c) => c[0].types))
|
||
.toEqual([['EmailDelivery', 'ContactCard', 'AddressBook'], ['EmailDelivery']]);
|
||
});
|
||
|
||
it('does not drop the device sync types over a failure that is not about them', async () => {
|
||
await syncing({ [CONTACTS]: true });
|
||
for (const failure of [
|
||
new Error('Network request failed'),
|
||
new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']),
|
||
new JMAPSetError('overQuota', 'There are too many subscriptions'),
|
||
]) {
|
||
createMock.mockClear();
|
||
createMock.mockRejectedValueOnce(failure);
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook']]);
|
||
}
|
||
|
||
// The next attempt (the next launch) subscribes with them.
|
||
createMock.mockClear();
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook']]);
|
||
});
|
||
|
||
it('subscribes with them', async () => {
|
||
await syncing({ [CALENDAR]: true });
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(createMock.mock.calls[0][0].types).toEqual(['EmailDelivery', 'CalendarEvent', 'Calendar']);
|
||
});
|
||
|
||
it('patches a subscription made before sync was turned on', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy(['EmailDelivery'])]);
|
||
await syncing({ [CONTACTS]: true, [CALENDAR]: true });
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(updateMock.mock.calls[0][1].types)
|
||
.toEqual(['EmailDelivery', 'ContactCard', 'AddressBook', 'CalendarEvent', 'Calendar']);
|
||
});
|
||
|
||
it('re-applies the types after a toggle', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy(['EmailDelivery'])]);
|
||
await syncing({ [CONTACTS]: true });
|
||
await refreshPushSubscriptionTypes(ACCOUNT_ID);
|
||
expect(updateMock).toHaveBeenCalledWith('existing', { types: ['EmailDelivery', 'ContactCard', 'AddressBook'] });
|
||
|
||
// An account without a push subscription has nothing to patch.
|
||
updateMock.mockClear();
|
||
DETACHED.log = [];
|
||
await refreshPushSubscriptionTypes('someone@else.example');
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
expect(DETACHED.log).toEqual([]);
|
||
|
||
// Already what it should be, in another order: nothing to send.
|
||
listMock.mockResolvedValue([healthy(['AddressBook', 'EmailDelivery', 'ContactCard'])]);
|
||
await refreshPushSubscriptionTypes(ACCOUNT_ID);
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("patches another account's subscription through a client of its own", async () => {
|
||
const OTHER = 'bob@other.example.net';
|
||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'bob-sub');
|
||
await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({
|
||
state: { accounts: { [OTHER]: { enabled: { [CALENDAR]: true } } } },
|
||
version: 0,
|
||
}));
|
||
DETACHED.log = [];
|
||
DETACHED.types = ['EmailDelivery'];
|
||
await refreshPushSubscriptionTypes(OTHER);
|
||
expect(DETACHED.log).toEqual([
|
||
['load', OTHER],
|
||
['PushSubscription/get', { ids: ['bob-sub'], properties: ['id', 'deviceClientId', 'types'] }],
|
||
['PushSubscription/set', { update: { 'bob-sub': { types: ['EmailDelivery', 'CalendarEvent', 'Calendar'] } } }],
|
||
]);
|
||
// The singleton's subscriptions were not touched.
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
|
||
// Already right: read, nothing written.
|
||
DETACHED.log = [];
|
||
DETACHED.types = ['Calendar', 'CalendarEvent', 'EmailDelivery'];
|
||
await refreshPushSubscriptionTypes(OTHER);
|
||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||
});
|
||
|
||
it("leaves alone another client's subscription that reuses the recorded id", async () => {
|
||
// Stalwart gives a destroyed subscription's id to the next one, such as
|
||
// the webmail's on the same account.
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([{ ...healthy(['EmailDelivery']), deviceClientId: 'webmailwebmailwebmailwebmailwebm' }]);
|
||
await syncing({ [CONTACTS]: true });
|
||
await refreshPushSubscriptionTypes(ACCOUNT_ID);
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
|
||
// The same through another account's client.
|
||
const OTHER = 'bob@other.example.net';
|
||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
|
||
await AsyncStorage.setItem(deviceClientIdKey(OTHER), 'bobsphonebobsphonebobsphonebobsp');
|
||
await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({
|
||
state: { accounts: { [OTHER]: { enabled: { [CALENDAR]: true } } } },
|
||
version: 0,
|
||
}));
|
||
DETACHED.log = [];
|
||
DETACHED.types = ['EmailDelivery'];
|
||
DETACHED.deviceClientId = 'webmailwebmailwebmailwebmailwebm';
|
||
try {
|
||
await refreshPushSubscriptionTypes(OTHER);
|
||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||
|
||
// Its own subscription still gets the types.
|
||
DETACHED.log = [];
|
||
DETACHED.deviceClientId = 'bobsphonebobsphonebobsphonebobsp';
|
||
await refreshPushSubscriptionTypes(OTHER);
|
||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get', 'PushSubscription/set']);
|
||
} finally {
|
||
DETACHED.deviceClientId = null;
|
||
}
|
||
});
|
||
|
||
it('leaves the subscription of an account that turned push off alone', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(`push:optedOut:v1:${ACCOUNT_ID}`, '1');
|
||
listMock.mockResolvedValue([healthy(['EmailDelivery'])]);
|
||
await syncing({ [CONTACTS]: true });
|
||
await refreshPushSubscriptionTypes(ACCOUNT_ID);
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('setupPushNotifications with ACL-shared accounts on Stalwart 0.16.16-0.16.23 (B18)', () => {
|
||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||
const sharedMock = getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||
// 0.16.16 to 0.16.23 list emailpush on every account, ACL shares included.
|
||
const calendars = (mayCreateCalendar: boolean) => ({
|
||
'urn:ietf:params:jmap:emailpush': {},
|
||
'urn:ietf:params:jmap:calendars': { mayCreateCalendar },
|
||
});
|
||
// What Stalwart puts in the session: a group the user belongs to and a
|
||
// mailbox another user shared by ACL look alike except for the create flags.
|
||
const SESSION = {
|
||
capabilities: { 'urn:ietf:params:jmap:emailpush': {} },
|
||
accounts: {
|
||
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: calendars(true) },
|
||
team: { name: 'team', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(true) },
|
||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: calendars(false) },
|
||
},
|
||
};
|
||
// The refusal is a SetError on the subscription (`notCreated` /
|
||
// `notUpdated`), not a method error.
|
||
const forbidden = () =>
|
||
new JMAPSetError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||
// Stalwart refuses the whole map as soon as it names an account the user
|
||
// doesn't own; `allowed` is what it accepts.
|
||
const refuseUnless = (allowed: string[]) => (emailPush?: Record<string, EmailPushConfig>) => {
|
||
if (emailPush && Object.keys(emailPush).some((id) => !allowed.includes(id))) throw forbidden();
|
||
};
|
||
const healthy = () => ({
|
||
id: 'existing',
|
||
deviceClientId: OUR_DCID,
|
||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||
types: ['EmailDelivery'],
|
||
});
|
||
const mapKeys = (emailPush: Record<string, EmailPushConfig> | undefined) =>
|
||
Object.keys(emailPush ?? {}).sort();
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
(jmapClient as { currentSession: unknown }).currentSession = SESSION;
|
||
sharedMock.mockResolvedValue([
|
||
{ id: 'team:inbox', originalId: 'inbox', role: 'inbox', accountId: 'team' },
|
||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||
]);
|
||
listMock.mockResolvedValue([]);
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
sharedMock.mockResolvedValue([]);
|
||
createMock.mockImplementation(async () => CREATED);
|
||
updateMock.mockImplementation(async () => undefined);
|
||
});
|
||
|
||
it('narrows a refused map on refresh instead of destroying the working subscription', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy()]);
|
||
const accept = refuseUnless(['jmap-primary', 'team']);
|
||
updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> }) =>
|
||
accept(patch.emailPush),
|
||
);
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.subscriptionId).toBe('existing');
|
||
expect(updateMock).toHaveBeenCalledTimes(2);
|
||
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['acl-b', 'jmap-primary', 'team']);
|
||
// The group keeps its junk filter; only the ACL share is dropped.
|
||
expect(mapKeys(updateMock.mock.calls[1][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
expect(JSON.parse((await AsyncStorage.getItem(REFUSED_KEY))!)).toEqual(['acl-b']);
|
||
});
|
||
|
||
it('leaves a remembered refusal out of the map on the next run', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['acl-b']));
|
||
listMock.mockResolvedValue([healthy()]);
|
||
const accept = refuseUnless(['jmap-primary', 'team']);
|
||
updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> }) =>
|
||
accept(patch.emailPush),
|
||
);
|
||
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||
});
|
||
|
||
it('falls back to the primary account alone when the group is refused too', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy()]);
|
||
const accept = refuseUnless(['jmap-primary']);
|
||
updateMock.mockImplementation(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> }) =>
|
||
accept(patch.emailPush),
|
||
);
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.subscriptionId).toBe('existing');
|
||
expect(updateMock).toHaveBeenCalledTimes(3);
|
||
expect(mapKeys(updateMock.mock.calls[2][1].emailPush)).toEqual(['jmap-primary']);
|
||
expect(JSON.parse((await AsyncStorage.getItem(REFUSED_KEY))!).sort()).toEqual(['acl-b', 'team']);
|
||
});
|
||
|
||
it('narrows a refused map when creating a subscription', async () => {
|
||
const accept = refuseUnless(['jmap-primary', 'team']);
|
||
createMock.mockImplementation(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
|
||
accept(params.emailPush);
|
||
return CREATED;
|
||
});
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result).toEqual({ subscriptionId: 'new-server-id', verified: true });
|
||
expect(createMock).toHaveBeenCalledTimes(2);
|
||
expect(mapKeys(createMock.mock.calls[1][0].emailPush)).toEqual(['jmap-primary', 'team']);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||
});
|
||
|
||
it('does not retry other refusals with a narrower map', async () => {
|
||
createMock.mockImplementation(async () => {
|
||
throw new JMAPMethodError('overQuota', 'There are too many subscriptions.');
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('jmap');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('destroys the replaced subscription only after the new one is verified', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy()]);
|
||
|
||
await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true });
|
||
|
||
const destroyOrder = destroyMock.mock.invocationCallOrder[
|
||
destroyMock.mock.calls.findIndex((c) => c[0] === 'existing')
|
||
];
|
||
expect(destroyOrder).toBeGreaterThan(verifyMock.mock.invocationCallOrder[0]);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||
});
|
||
|
||
it('keeps the working subscription when its replacement is refused', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([healthy()]);
|
||
createMock.mockImplementation(async () => {
|
||
throw forbidden();
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err.phase).toBe('jmap');
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
});
|
||
|
||
it('does not narrow the map when the whole call is refused as forbidden', async () => {
|
||
// A method-level `forbidden` (no permission to subscribe at all) says
|
||
// nothing about the accounts in the map.
|
||
createMock.mockImplementation(async () => {
|
||
throw new JMAPMethodError('forbidden', 'You do not have enough permissions.');
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('jmap');
|
||
expect(err.message).toBe("The mail server doesn't allow background notifications for this account.");
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull();
|
||
});
|
||
|
||
it('explains a refused create when the server has too many subscriptions', async () => {
|
||
createMock.mockImplementation(async () => {
|
||
throw new JMAPSetError('overQuota', 'There are too many push subscriptions, please delete some.');
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err.phase).toBe('jmap');
|
||
expect(err.message).toContain('too many push registrations on the mail server');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('setupPushNotifications with ACL-shared accounts on Stalwart 1.0', () => {
|
||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||
const sharedMock = getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||
const OWNER = { accountIdForPrincipal: 'jmap-primary', principalId: 'jmap-primary' };
|
||
// 1.0 lists emailpush on the user's own account and their groups only; an
|
||
// ACL share lacks it (and Submission, Sieve, VacationResponse, Quota), and
|
||
// every account carries principals:owner (jmap/src/api/session.rs).
|
||
const SESSION_1_0 = {
|
||
capabilities: { 'urn:ietf:params:jmap:emailpush': {}, 'urn:ietf:params:jmap:metadata': {} },
|
||
accounts: {
|
||
'jmap-primary': {
|
||
name: 'user',
|
||
isPersonal: true,
|
||
isReadOnly: false,
|
||
accountCapabilities: {
|
||
'urn:ietf:params:jmap:mail': {},
|
||
'urn:ietf:params:jmap:emailpush': {},
|
||
'urn:ietf:params:jmap:principals:owner': OWNER,
|
||
},
|
||
},
|
||
team: {
|
||
name: 'team',
|
||
isPersonal: false,
|
||
isReadOnly: false,
|
||
accountCapabilities: {
|
||
'urn:ietf:params:jmap:mail': {},
|
||
'urn:ietf:params:jmap:emailpush': {},
|
||
'urn:ietf:params:jmap:principals:owner': { ...OWNER, principalId: 'team' },
|
||
},
|
||
},
|
||
'acl-b': {
|
||
name: 'userb',
|
||
isPersonal: false,
|
||
isReadOnly: true,
|
||
accountCapabilities: {
|
||
'urn:ietf:params:jmap:mail': {},
|
||
'urn:ietf:params:jmap:principals:owner': { ...OWNER, principalId: 'acl-b' },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
const mapKeys = (emailPush: Record<string, EmailPushConfig> | undefined) =>
|
||
Object.keys(emailPush ?? {}).sort();
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
(jmapClient as { currentSession: unknown }).currentSession = SESSION_1_0;
|
||
sharedMock.mockResolvedValue([
|
||
{ id: 'team:inbox', originalId: 'inbox', role: 'inbox', accountId: 'team' },
|
||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||
]);
|
||
listMock.mockResolvedValue([]);
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
sharedMock.mockResolvedValue([]);
|
||
});
|
||
|
||
it('leaves the ACL share out of the map up front, without a refused call', async () => {
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.verified).toBe(true);
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(mapKeys(createMock.mock.calls[0][0].emailPush)).toEqual(['jmap-primary', 'team']);
|
||
expect(await AsyncStorage.getItem(REFUSED_KEY)).toBeNull();
|
||
});
|
||
|
||
it('ignores accounts remembered as refused while the server was on 0.16', async () => {
|
||
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['team']));
|
||
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(mapKeys(createMock.mock.calls[0][0].emailPush)).toEqual(['jmap-primary', 'team']);
|
||
});
|
||
|
||
it('adds the group to an existing map in one update, leaving the ACL share out', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
const filter = { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'], urgency: 'high' };
|
||
listMock.mockResolvedValue([{
|
||
id: 'existing',
|
||
deviceClientId: OUR_DCID,
|
||
expires: new Date(Date.now() + 80 * 86400000).toISOString(),
|
||
types: ['EmailDelivery'],
|
||
emailPush: { 'jmap-primary': filter },
|
||
}]);
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.subscriptionId).toBe('existing');
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(mapKeys(updateMock.mock.calls[0][1].emailPush)).toEqual(['jmap-primary', 'team']);
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('setupPushNotifications when the server cannot be asked', () => {
|
||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||
const dueForRefresh = () => ({ id: 'existing', deviceClientId: OUR_DCID, expires: inDays(3), types: ['EmailDelivery'] });
|
||
const named = (name: string, message: string) => Object.assign(new Error(message), { name });
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
listMock.mockImplementation(async () => []);
|
||
updateMock.mockImplementation(async () => undefined);
|
||
});
|
||
|
||
it.each([false, true])('keeps the subscription and its key when listing fails (forceRecreate: %s)', async (forceRecreate) => {
|
||
listMock.mockRejectedValue(named('NetworkError', 'Network request failed'));
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('jmap');
|
||
expect(err.message).toContain('Network request failed');
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
});
|
||
|
||
it.each([
|
||
['a network error', named('NetworkError', 'Network request failed')],
|
||
['a timeout', named('RequestTimeoutError', 'Request timed out after 30s')],
|
||
['rate limiting', named('RateLimitError', 'Rate limited by server')],
|
||
['a refused method call', new JMAPMethodError('serverFail', 'Store unavailable')],
|
||
])('keeps a working subscription when its refresh fails with %s', async (_what, error) => {
|
||
listMock.mockResolvedValue([dueForRefresh()]);
|
||
updateMock.mockRejectedValue(error);
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('jmap');
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
});
|
||
|
||
it('replaces the subscription when the server says it is gone, destroying it after verification', async () => {
|
||
listMock.mockResolvedValue([dueForRefresh()]);
|
||
updateMock.mockRejectedValue(new JMAPSetError('notFound'));
|
||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.subscriptionId).toBe('new-server-id');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
const destroyOrder = destroyMock.mock.invocationCallOrder[
|
||
destroyMock.mock.calls.findIndex((c) => c[0] === 'existing')
|
||
];
|
||
expect(destroyOrder).toBeGreaterThan(verifyMock.mock.invocationCallOrder[0]);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||
});
|
||
});
|
||
|
||
describe('mailAccountsWithoutPush', () => {
|
||
const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID;
|
||
const account = (isPersonal: boolean, caps: Record<string, unknown>, isReadOnly = false) => ({
|
||
name: 'x', isPersonal, isReadOnly, accountCapabilities: caps,
|
||
});
|
||
const owned = { 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: true } };
|
||
const aclShared = { 'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false } };
|
||
const emailPush = { 'urn:ietf:params:jmap:emailpush': {} };
|
||
|
||
beforeEach(async () => {
|
||
await AsyncStorage.clear();
|
||
});
|
||
|
||
afterEach(() => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
});
|
||
|
||
it('names the accounts without emailpush on Stalwart 1.0', async () => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: emailPush,
|
||
accounts: {
|
||
'jmap-primary': account(true, { ...owned, ...emailPush }),
|
||
team: account(false, { ...owned, ...emailPush }),
|
||
'acl-b': account(false, aclShared, true),
|
||
},
|
||
};
|
||
expect(await mailAccountsWithoutPush(['team', 'acl-b', 'acl-b', 'jmap-primary'])).toEqual(['acl-b']);
|
||
});
|
||
|
||
it('falls back to refused and not-owned accounts on Stalwart 0.16.16-0.16.23', async () => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: emailPush,
|
||
accounts: {
|
||
'jmap-primary': account(true, { ...owned, ...emailPush }),
|
||
team: account(false, { ...owned, ...emailPush }),
|
||
'acl-b': account(false, { ...aclShared, ...emailPush }),
|
||
'acl-c': account(false, { ...owned, ...emailPush }),
|
||
},
|
||
};
|
||
expect(await mailAccountsWithoutPush(['team', 'acl-b'])).toEqual(['acl-b']);
|
||
// An account the server refused in the map, whatever it looks like.
|
||
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['acl-c']));
|
||
expect(await mailAccountsWithoutPush(['team', 'acl-b', 'acl-c'])).toEqual(['acl-b', 'acl-c']);
|
||
});
|
||
|
||
it('judges by ownership alone on Stalwart 0.16.6-0.16.15, which has no emailpush', async () => {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: {},
|
||
accounts: {
|
||
'jmap-primary': account(true, owned),
|
||
team: account(false, owned),
|
||
'acl-b': account(false, aclShared),
|
||
},
|
||
};
|
||
expect(await mailAccountsWithoutPush(['team', 'acl-b'])).toEqual(['acl-b']);
|
||
});
|
||
|
||
it('names nothing for the user\'s own mailboxes', async () => {
|
||
expect(await mailAccountsWithoutPush(['jmap-primary'])).toEqual([]);
|
||
expect(await mailAccountsWithoutPush([])).toEqual([]);
|
||
});
|
||
|
||
it('takes the emailpush capability as the whole answer on Stalwart 1.0 (principals:owner)', async () => {
|
||
const owner = (principalId: string) => ({
|
||
'urn:ietf:params:jmap:principals:owner': { accountIdForPrincipal: 'jmap-primary', principalId },
|
||
});
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: emailPush,
|
||
accounts: {
|
||
'jmap-primary': account(true, { ...owned, ...emailPush, ...owner('jmap-primary') }),
|
||
// A group with mail only: no calendars, contacts or files to tell
|
||
// that the user belongs to it.
|
||
'mail-group': account(false, { ...emailPush, ...owner('mail-group') }),
|
||
},
|
||
};
|
||
expect(await mailAccountsWithoutPush(['mail-group'])).toEqual([]);
|
||
// Nor does a refusal remembered while the server was on 0.16 count.
|
||
await AsyncStorage.setItem(REFUSED_KEY, JSON.stringify(['mail-group']));
|
||
expect(await mailAccountsWithoutPush(['mail-group'])).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('setupPushNotifications at the server\'s subscription limit', () => {
|
||
const overQuota = () => new JMAPSetError('overQuota', 'There are too many push subscriptions, please delete some.');
|
||
const recorded = (expiresInDays: number) => ({
|
||
id: 'existing',
|
||
deviceClientId: OUR_DCID,
|
||
expires: new Date(Date.now() + expiresInDays * 86400000).toISOString(),
|
||
types: ['EmailDelivery'],
|
||
});
|
||
const verifyMock = verifyPushSubscription as ReturnType<typeof vi.fn>;
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
listMock.mockResolvedValue([recorded(80)]);
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
listMock.mockImplementation(async () => []);
|
||
createMock.mockImplementation(async () => CREATED);
|
||
updateMock.mockImplementation(async () => undefined);
|
||
});
|
||
|
||
it('keeps the working record when a forced re-register moves to another relay', async () => {
|
||
// Stalwart answers overQuota before it looks at the URL; freeing a place
|
||
// would only lead to the new relay being refused with the old record gone.
|
||
let attempts = 0;
|
||
createMock.mockImplementation(async () => {
|
||
attempts += 1;
|
||
throw attempts === 1
|
||
? overQuota()
|
||
: new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']);
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.message).toContain('too many push registrations on the mail server');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
// The working record still delivers through the old relay, so that one
|
||
// stays the relay in use, and this device is registered with it again.
|
||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||
const relayCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls
|
||
.filter((c) => String(c[0]).includes('/api/push/register'))
|
||
.map((c) => `${(c[1] as RequestInit | undefined)?.method ?? 'GET'} ${String(c[0])}`);
|
||
expect(relayCalls).toEqual([
|
||
'POST https://relay.home.lan/api/push/register',
|
||
`DELETE https://relay.home.lan/api/push/register/${OUR_DCID}`,
|
||
`POST ${RELAY}/api/push/register`,
|
||
]);
|
||
});
|
||
|
||
it('keeps the old relay when the new one never verifies the replacement', async () => {
|
||
verifyMock.mockRejectedValueOnce(new Error('invalid verification code'));
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.other.example', forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('verify');
|
||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
||
expect(fetchMock.mock.calls.some((c) => String(c[0]) === `https://relay.other.example/api/push/register/${OUR_DCID}`
|
||
&& (c[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(true);
|
||
|
||
// A move that works keeps the new relay.
|
||
expect((await setupPushNotifications({ relayBaseUrl: 'https://relay.other.example', forceRecreate: true })).verified).toBe(true);
|
||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe('https://relay.other.example');
|
||
});
|
||
|
||
it('a forced re-register frees the old record\'s place and tries once more', async () => {
|
||
createMock.mockRejectedValueOnce(overQuota());
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true });
|
||
|
||
expect(result).toEqual({ subscriptionId: 'new-server-id', verified: true });
|
||
expect(createMock).toHaveBeenCalledTimes(2);
|
||
const oldDestroys = destroyMock.mock.calls.flatMap((c, i) => (c[0] === 'existing' ? [destroyMock.mock.invocationCallOrder[i]] : []));
|
||
// Once, between the refused create and the retry - not again after verification.
|
||
expect(oldDestroys).toHaveLength(1);
|
||
expect(oldDestroys[0]).toBeGreaterThan(createMock.mock.invocationCallOrder[0]);
|
||
expect(oldDestroys[0]).toBeLessThan(createMock.mock.invocationCallOrder[1]);
|
||
expect(verifyMock).toHaveBeenCalledWith('new-server-id', 'CODE');
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||
});
|
||
|
||
it('retries only once, and says why when the limit still refuses', async () => {
|
||
createMock.mockImplementation(async () => {
|
||
throw overQuota();
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('jmap');
|
||
expect(err.message).toContain('too many push registrations on the mail server');
|
||
expect(createMock).toHaveBeenCalledTimes(2);
|
||
// The old record is gone, and so is its key.
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
|
||
});
|
||
|
||
it('keeps the old record when its removal fails', async () => {
|
||
createMock.mockRejectedValueOnce(overQuota());
|
||
destroyMock.mockRejectedValueOnce(new Error('Network request failed'));
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
});
|
||
|
||
it('never gives up a working record for a replacement the user did not ask for', async () => {
|
||
// The refresh is refused, so the setup replaces the record by itself.
|
||
listMock.mockResolvedValue([recorded(1)]);
|
||
updateMock.mockRejectedValue(new JMAPSetError('invalidProperties'));
|
||
createMock.mockImplementation(async () => {
|
||
throw overQuota();
|
||
});
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err.phase).toBe('jmap');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('existing');
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
});
|
||
});
|
||
|
||
describe('setupPushNotifications over UnifiedPush', () => {
|
||
type UpNative = {
|
||
getDistributors: ReturnType<typeof vi.fn>;
|
||
getSavedDistributor: ReturnType<typeof vi.fn>;
|
||
register: ReturnType<typeof vi.fn>;
|
||
getEndpoint: ReturnType<typeof vi.fn>;
|
||
};
|
||
const upNative = () => (NativeModules as { BulwarkUnifiedPush: UpNative }).BulwarkUnifiedPush;
|
||
|
||
// Like installFetch, plus the two UnifiedPush relay endpoints; captures the
|
||
// registration body so tests can assert on it.
|
||
function installUpFetch(): { body: () => Record<string, unknown> | null } {
|
||
let captured: Record<string, unknown> | null = null;
|
||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const url = typeof input === 'string' ? input : input.toString();
|
||
if (url.includes('/api/push/vapid-public-key')) {
|
||
return { ok: true, status: 200, json: async () => ({ publicKey: 'VAPID-PUB' }) } as Response;
|
||
}
|
||
if (url.includes('/api/push/register/unifiedpush')) {
|
||
captured = JSON.parse(String(init?.body ?? 'null')) as Record<string, unknown>;
|
||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||
}
|
||
if (url.includes('/api/push/verify/')) {
|
||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
}) as typeof fetch;
|
||
return { body: () => captured };
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem('push:transport:v1', 'unifiedpush');
|
||
listMock.mockResolvedValue([]);
|
||
});
|
||
|
||
it('registers the distributor endpoint with the relay instead of an FCM token', async () => {
|
||
const relayReg = installUpFetch();
|
||
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result.verified).toBe(true);
|
||
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
|
||
expect(native.getToken).not.toHaveBeenCalled();
|
||
// The relay's VAPID key is handed to the distributor at registration.
|
||
expect(upNative().register).toHaveBeenCalledWith('VAPID-PUB');
|
||
expect(relayReg.body()).toEqual({
|
||
subscriptionId: OUR_DCID,
|
||
endpoint: 'https://ntfy.sh/upAbCdEf?up=1',
|
||
keys: { p256dh: 'B'.repeat(87), auth: 'a'.repeat(22) },
|
||
accountLabel: undefined,
|
||
});
|
||
// The JMAP subscription flow is transport-independent.
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('narrows a refused emailPush map over UnifiedPush as well (Stalwart 0.16.16+)', async () => {
|
||
installUpFetch();
|
||
const emailPush = { 'urn:ietf:params:jmap:emailpush': {} };
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: emailPush,
|
||
accounts: {
|
||
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: emailPush },
|
||
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: false, accountCapabilities: emailPush },
|
||
},
|
||
};
|
||
(getSharedMailboxes as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b' },
|
||
]);
|
||
createMock.mockImplementation(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
|
||
if (params.emailPush && 'acl-b' in params.emailPush) {
|
||
throw new JMAPSetError('forbidden', 'No access to one of the accounts in the emailPush map.');
|
||
}
|
||
return CREATED;
|
||
});
|
||
|
||
try {
|
||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(result.verified).toBe(true);
|
||
expect(createMock).toHaveBeenCalledTimes(2);
|
||
expect(Object.keys(createMock.mock.calls[1][0].emailPush)).toEqual(['jmap-primary']);
|
||
} finally {
|
||
(jmapClient as { currentSession: unknown }).currentSession = {
|
||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||
};
|
||
createMock.mockImplementation(async () => CREATED);
|
||
}
|
||
});
|
||
|
||
it("puts the distributor back on the previous relay's VAPID key when the server refuses the new relay", async () => {
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 5 * 86400000).toISOString(), types: ['EmailDelivery'] }]);
|
||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||
const registered: string[] = [];
|
||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const url = typeof input === 'string' ? input : input.toString();
|
||
if (url.endsWith('/api/push/vapid-public-key')) {
|
||
const publicKey = url.startsWith(RELAY) ? 'VAPID-PUB' : 'VAPID-LAN';
|
||
return { ok: true, status: 200, json: async () => ({ publicKey }) } as Response;
|
||
}
|
||
if (url.includes('/api/push/register')) {
|
||
registered.push(`${init?.method ?? 'GET'} ${url}`);
|
||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
}) as typeof fetch;
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(upNative().register.mock.calls.map((c) => c[0])).toEqual(['VAPID-LAN', 'VAPID-PUB']);
|
||
expect(registered).toEqual([
|
||
'POST https://relay.home.lan/api/push/register/unifiedpush',
|
||
`DELETE https://relay.home.lan/api/push/register/${OUR_DCID}`,
|
||
`POST ${RELAY}/api/push/register/unifiedpush`,
|
||
]);
|
||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||
});
|
||
|
||
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
|
||
upNative().getEndpoint.mockResolvedValue({
|
||
url: 'https://legacy.example/up123456',
|
||
p256dh: null,
|
||
auth: null,
|
||
});
|
||
const relayReg = installUpFetch();
|
||
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(relayReg.body()).toMatchObject({
|
||
endpoint: 'https://legacy.example/up123456',
|
||
keys: null,
|
||
});
|
||
});
|
||
|
||
it('fails with the distributor phase when no distributor is installed', async () => {
|
||
upNative().getDistributors.mockResolvedValue([]);
|
||
installUpFetch();
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err).toBeInstanceOf(PushSetupError);
|
||
expect(err.phase).toBe('distributor');
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('requires an explicit choice when several distributors are installed', async () => {
|
||
upNative().getDistributors.mockResolvedValue(['io.heckel.ntfy', 'org.unifiedpush.distributor.sunup']);
|
||
upNative().getSavedDistributor.mockResolvedValue(null);
|
||
installUpFetch();
|
||
|
||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||
|
||
expect(err.phase).toBe('distributor');
|
||
expect(err.message).toContain('choose one');
|
||
});
|
||
});
|
||
|
||
describe('resyncPushNotifications', () => {
|
||
const OPTED_OUT_KEY = 'push:optedOut:v1:' + ACCOUNT_ID;
|
||
const EXPIRES_KEY = 'push:subscriptionExpires:v1:' + ACCOUNT_ID;
|
||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
listMock.mockResolvedValue([]);
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
createMock.mockImplementation(async () => CREATED);
|
||
});
|
||
|
||
it('keeps a healthy registration up to date', async () => {
|
||
const expires = inDays(3);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([
|
||
{ id: 'existing', deviceClientId: OUR_DCID, expires, types: ['EmailDelivery'] },
|
||
]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('existing');
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
// The expiry the server reported is what a later resync measures against.
|
||
expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(expires);
|
||
});
|
||
|
||
it('records the expiry the server settled on after a refresh, not the one before it', async () => {
|
||
const before = inDays(1);
|
||
const after = inDays(7);
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValueOnce([{ id: 'existing', deviceClientId: OUR_DCID, expires: before, types: ['EmailDelivery'] }]);
|
||
listMock.mockResolvedValueOnce([{ id: 'existing', deviceClientId: OUR_DCID, expires: after, types: ['EmailDelivery'] }]);
|
||
|
||
await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||
expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(after);
|
||
});
|
||
|
||
it('leaves an account alone after the user turned push off for it', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||
listMock.mockResolvedValue([sub('existing', OUR_DCID)]);
|
||
|
||
await disablePushForAccount(ACCOUNT_ID);
|
||
vi.clearAllMocks();
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result).toBeNull();
|
||
expect(listMock).not.toHaveBeenCalled();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(await readPushAccountIds()).toEqual([]);
|
||
});
|
||
|
||
it('leaves push off after this device was revoked from the device list', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([sub('existing', OUR_DCID)]);
|
||
|
||
await revokePushDevice({
|
||
accountId: ACCOUNT_ID,
|
||
device: { id: 'existing', deviceClientId: OUR_DCID, isThisDevice: true },
|
||
relayBaseUrl: RELAY,
|
||
});
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result).toBeNull();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('waits for the session instead of asking the server without one', async () => {
|
||
// An account being added: the client holds its credentials, no session yet.
|
||
const client = jmapClient as unknown as { isConnected: boolean };
|
||
client.isConnected = false;
|
||
try {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(result).toBeNull();
|
||
expect(listMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||
} finally {
|
||
client.isConnected = true;
|
||
}
|
||
});
|
||
|
||
it('keeps push on when the client moved to another account during the revocation check', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||
const client = jmapClient as unknown as { serverUrl: string };
|
||
// The listing answers for the account added meanwhile, which has none of ours.
|
||
listMock.mockImplementation(async () => {
|
||
client.serverUrl = 'https://mail.example.com:8443';
|
||
return [];
|
||
});
|
||
try {
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
expect(result).toBeNull();
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
expect(await readPushAccountIds()).toEqual([ACCOUNT_ID]);
|
||
} finally {
|
||
client.serverUrl = 'https://mail.example.com';
|
||
listMock.mockReset();
|
||
listMock.mockResolvedValue([]);
|
||
}
|
||
});
|
||
|
||
it('does not re-register a subscription another device revoked', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||
listMock.mockResolvedValue([]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result).toBeNull();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
|
||
expect(await readPushAccountIds()).toEqual([]);
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
|
||
});
|
||
|
||
it("takes a recorded subscription whose id now names another client's for revoked", async () => {
|
||
// Revoked here, then the webmail on the same account subscribed and got
|
||
// the freed id.
|
||
await AsyncStorage.setItem(SUB_KEY, 'b');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||
listMock.mockResolvedValue([
|
||
{ id: 'b', deviceClientId: 'webmailwebmailwebmailwebmailwebm', expires: inDays(7), types: ['EmailDelivery'] },
|
||
]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result).toBeNull();
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('b');
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
|
||
});
|
||
|
||
it('re-creates the subscription once the server behind the account was replaced (0.16.23 to 1.0)', async () => {
|
||
// A server reinstalled for Stalwart 1.0 holds none of the subscriptions,
|
||
// which looks exactly like a revocation from elsewhere; the fingerprint
|
||
// check tells them apart and forgets the recorded expiry.
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||
listMock.mockResolvedValue([]);
|
||
|
||
await forgetPushServerState(ACCOUNT_ID);
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('new-server-id');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||
expect(await readPushAccountIds()).toEqual([ACCOUNT_ID]);
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||
});
|
||
|
||
it('re-creates the subscription when this build first sees a server replaced before it ran (0.16.23 to 1.0)', async () => {
|
||
// Left by the released build, which recorded the expiry but no server
|
||
// fingerprint, against the 0.16 server; the fresh 1.0 install holds no
|
||
// subscriptions. The first check can't tell a replacement from none.
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(60));
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||
listMock.mockResolvedValue([]);
|
||
const session10 = {
|
||
apiUrl: 'https://mail.example.com/jmap/',
|
||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||
accounts: {
|
||
'jmap-primary': {
|
||
name: 'user', isPersonal: true, isReadOnly: false,
|
||
accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals:owner': {} },
|
||
},
|
||
},
|
||
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:metadata': {} },
|
||
};
|
||
|
||
expect(await reconcileServerFingerprint(ACCOUNT_ID, session10 as never)).toBe(false);
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('new-server-id');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(await readPushAccountIds()).toEqual([ACCOUNT_ID]);
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||
});
|
||
|
||
it('does not take over another device\'s subscription that reuses the recorded id', async () => {
|
||
// Stalwart's ids are short and sequential: after a reinstall another
|
||
// device's subscription can come back under the id this device recorded.
|
||
await AsyncStorage.setItem(SUB_KEY, 'b');
|
||
listMock.mockResolvedValue([
|
||
{ id: 'b', deviceClientId: 'otherotherotherotherotherotherot', expires: inDays(3), types: ['Email'] },
|
||
]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('new-server-id');
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
// The relay can't vouch for it either way, so it stays.
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('b');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id');
|
||
});
|
||
|
||
it('still reuses a recorded subscription the server lists without a device client id', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([{ id: 'existing', expires: inDays(80), types: ['EmailDelivery'] }]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('existing');
|
||
expect(createMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('re-creates a subscription that simply lapsed', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(-1));
|
||
listMock.mockResolvedValue([]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('new-server-id');
|
||
expect(createMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('re-creates a missing subscription whose expiry it never learned', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
listMock.mockResolvedValue([]);
|
||
|
||
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(result?.subscriptionId).toBe('new-server-id');
|
||
});
|
||
|
||
it('changes nothing when the server cannot be asked', async () => {
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||
listMock.mockRejectedValueOnce(new Error('network down'));
|
||
|
||
await expect(resyncPushNotifications({ relayBaseUrl: RELAY })).rejects.toThrow('network down');
|
||
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||
});
|
||
|
||
it('turns push back on when the user enables it again', async () => {
|
||
await disablePushForAccount(ACCOUNT_ID);
|
||
createMock.mockImplementation(async () => ({ id: 'new-server-id', expires: inDays(7) }));
|
||
|
||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||
|
||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||
expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull();
|
||
vi.clearAllMocks();
|
||
// Turning push off forgot the device client id, so the new subscription
|
||
// carries a new one.
|
||
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(ACCOUNT_ID));
|
||
expect(deviceClientId).not.toBe(OUR_DCID);
|
||
listMock.mockResolvedValue([
|
||
{ id: 'new-server-id', deviceClientId, expires: inDays(7), types: ['EmailDelivery'] },
|
||
]);
|
||
expect((await resyncPushNotifications({ relayBaseUrl: RELAY }))?.subscriptionId).toBe('new-server-id');
|
||
});
|
||
});
|
||
|
||
describe('renewOtherPushAccounts (#1054)', () => {
|
||
const OTHER = 'bob@other.example.net';
|
||
const OTHER_DCID = 'bobsphonebobsphonebobsphonebobsp';
|
||
const inDays = (days: number) => new Date(Date.now() + days * 86400000).toISOString();
|
||
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID, OTHER]));
|
||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
|
||
await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID);
|
||
Object.assign(DETACHED, { loaded: true, deviceClientId: OTHER_DCID, expires: inDays(2), settles: inDays(7), log: [] });
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.assign(DETACHED, { deviceClientId: null, expires: null, settles: null });
|
||
});
|
||
|
||
it("extends another account's subscription through a client of its own and records the server's expiry", async () => {
|
||
const settles = DETACHED.settles;
|
||
|
||
await renewOtherPushAccounts();
|
||
|
||
const calls = DETACHED.log.map((entry) => (entry as unknown[])[0]);
|
||
// The loaded account is the launch-time resync's; only the other is touched here.
|
||
expect(calls).toEqual(['load', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
|
||
expect(DETACHED.log[0]).toEqual(['load', OTHER]);
|
||
expect(updateMock).not.toHaveBeenCalled();
|
||
expect(listMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(settles);
|
||
// Its relay registration carries this device's current token.
|
||
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
||
const registration = fetchMock.mock.calls.find((c) => String(c[0]) === `${RELAY}/api/push/register`);
|
||
expect(JSON.parse(String((registration![1] as RequestInit).body))).toEqual({
|
||
subscriptionId: OTHER_DCID,
|
||
fcmToken: 'fcm-token-xyz',
|
||
accountLabel: 'bob',
|
||
});
|
||
});
|
||
|
||
it('only records the expiry of a subscription with more than a week left', async () => {
|
||
DETACHED.expires = inDays(30);
|
||
await renewOtherPushAccounts();
|
||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${OTHER}`)).toBe(DETACHED.expires);
|
||
});
|
||
|
||
it("leaves alone another client's subscription that reuses the id, and an account push was turned off for", async () => {
|
||
DETACHED.deviceClientId = 'webmailwebmailwebmailwebmailwebm';
|
||
await renewOtherPushAccounts();
|
||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||
|
||
DETACHED.log = [];
|
||
await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1');
|
||
await renewOtherPushAccounts();
|
||
expect(DETACHED.log).toEqual([]);
|
||
});
|
||
|
||
it('tells the settings which accounts have push on this device', async () => {
|
||
await AsyncStorage.setItem(`push:optedOut:v1:${OTHER}`, '1');
|
||
await AsyncStorage.removeItem(`push:subscriptionId:v2:${OTHER}`);
|
||
expect(await readPushAccountStates([ACCOUNT_ID, OTHER, 'carl@example.org'])).toEqual({
|
||
[ACCOUNT_ID]: { enabled: true, optedOut: false },
|
||
[OTHER]: { enabled: false, optedOut: true },
|
||
'carl@example.org': { enabled: false, optedOut: false },
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('teardownPushNotificationsForAccount', () => {
|
||
beforeEach(async () => {
|
||
vi.clearAllMocks();
|
||
await AsyncStorage.clear();
|
||
installFetch({});
|
||
});
|
||
|
||
it('destroys every server subscription for this device but keeps the FCM token', async () => {
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem(SUB_KEY, 'recorded');
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
listMock.mockResolvedValue([
|
||
sub('recorded', OUR_DCID),
|
||
sub('untracked', OUR_DCID),
|
||
sub('foreign', 'ffffffffffffffffffffffffffffffff'),
|
||
]);
|
||
await teardownPushNotificationsForAccount(ACCOUNT_ID);
|
||
const destroyed = destroyMock.mock.calls.map((c) => c[0]);
|
||
expect(destroyed).toContain('recorded');
|
||
expect(destroyed).toContain('untracked');
|
||
expect(destroyed).not.toContain('foreign');
|
||
const native = (NativeModules as { BulwarkFcm: { deleteToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
|
||
expect(native.deleteToken).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
|
||
});
|
||
|
||
it('leaves another device\'s subscription alone when it reuses the recorded id', async () => {
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem(SUB_KEY, 'b');
|
||
listMock.mockResolvedValue([
|
||
sub('b', 'ffffffffffffffffffffffffffffffff'),
|
||
sub('c', OUR_DCID),
|
||
]);
|
||
|
||
await teardownPushNotificationsForAccount(ACCOUNT_ID);
|
||
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).toEqual(['c']);
|
||
});
|
||
|
||
it('still destroys the recorded id when the server cannot list its subscriptions', async () => {
|
||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||
await AsyncStorage.setItem(SUB_KEY, 'recorded');
|
||
listMock.mockRejectedValueOnce(new Error('network down'));
|
||
|
||
await teardownPushNotificationsForAccount(ACCOUNT_ID);
|
||
|
||
expect(destroyMock.mock.calls.map((c) => c[0])).toEqual(['recorded']);
|
||
expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull();
|
||
});
|
||
|
||
describe('for a signed-in account the client does not serve', () => {
|
||
const OTHER = 'bob@other.example.net';
|
||
const OTHER_DCID = 'bobsphonebobsphonebobsphonebobsp';
|
||
const destroyedThroughOwnClient = () => DETACHED.log
|
||
.filter((entry) => (entry as unknown[])[0] === 'PushSubscription/set')
|
||
.map((entry) => ((entry as [string, { destroy: string[] }])[1]).destroy);
|
||
|
||
beforeEach(async () => {
|
||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
|
||
await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID);
|
||
// The open account's own push, with the same short id.
|
||
listMock.mockResolvedValue([{ id: 'b', types: ['EmailDelivery'] }]);
|
||
Object.assign(DETACHED, {
|
||
loaded: true,
|
||
all: [{ id: 'b', deviceClientId: OTHER_DCID }, { id: 'c', deviceClientId: 'ffffffffffffffffffffffffffffffff' }],
|
||
listFails: false,
|
||
log: [],
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
listMock.mockImplementation(async () => []);
|
||
Object.assign(DETACHED, { loaded: true, all: [], listFails: false });
|
||
});
|
||
|
||
it('destroys its subscriptions through a client of its own', async () => {
|
||
await teardownPushNotificationsForAccount(OTHER);
|
||
|
||
expect(DETACHED.log[0]).toEqual(['load', OTHER]);
|
||
expect(destroyedThroughOwnClient()).toEqual([['b']]);
|
||
expect(listMock).not.toHaveBeenCalled();
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
expect(await AsyncStorage.getItem(`push:subscriptionId:v2:${OTHER}`)).toBeNull();
|
||
});
|
||
|
||
it("never destroys the recorded id on the open account when its own server can't list", async () => {
|
||
listMock.mockRejectedValue(new Error('network down'));
|
||
DETACHED.listFails = true;
|
||
|
||
await teardownPushNotificationsForAccount(OTHER);
|
||
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
expect(destroyedThroughOwnClient()).toEqual([['b']]);
|
||
|
||
// An account that can't be loaded leaves every server alone.
|
||
DETACHED.log = [];
|
||
DETACHED.loaded = false;
|
||
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
|
||
await AsyncStorage.setItem(deviceClientIdKey(OTHER), OTHER_DCID);
|
||
await teardownPushNotificationsForAccount(OTHER);
|
||
expect(destroyMock).not.toHaveBeenCalled();
|
||
expect(destroyedThroughOwnClient()).toEqual([]);
|
||
expect(await AsyncStorage.getItem(`push:subscriptionId:v2:${OTHER}`)).toBeNull();
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('notificationTapJmapAccountId', () => {
|
||
const tap = { emailId: 'm1', threadId: 't1', accountId: ACCOUNT_ID };
|
||
|
||
it('opens a group mailbox message against the group account', () => {
|
||
expect(notificationTapJmapAccountId({ ...tap, jmapAccountId: 'team' })).toBe('team');
|
||
});
|
||
|
||
it('leaves the user\'s own mail on the default account', () => {
|
||
expect(notificationTapJmapAccountId({ ...tap, jmapAccountId: 'jmap-primary' })).toBeUndefined();
|
||
});
|
||
|
||
it('keeps notifications posted before the field existed working', () => {
|
||
expect(notificationTapJmapAccountId(tap)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('isValidRelayUrl', () => {
|
||
it('requires https except for loopback development hosts', () => {
|
||
expect(isValidRelayUrl('https://relay.example.com')).toBe(true);
|
||
expect(isValidRelayUrl('https://relay.example.com/')).toBe(true);
|
||
expect(isValidRelayUrl('http://relay.example.com')).toBe(false);
|
||
expect(isValidRelayUrl('http://localhost:3003')).toBe(true);
|
||
expect(isValidRelayUrl('http://10.0.2.2:3003')).toBe(true);
|
||
expect(isValidRelayUrl('relay.example.com')).toBe(false);
|
||
expect(isValidRelayUrl('')).toBe(false);
|
||
});
|
||
});
|