mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +00:00
1600 lines
69 KiB
TypeScript
1600 lines
69 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[], log: [] as unknown[], session: SYNC_SESSION },
|
||
};
|
||
});
|
||
|
||
vi.mock('../../api/jmap-client', () => ({
|
||
jmapClient: {
|
||
username: 'user@example.com',
|
||
serverUrl: 'https://mail.example.com',
|
||
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;
|
||
}
|
||
async request(calls: Array<[string, Record<string, any>, string]>) {
|
||
const [name, args, id] = calls[0];
|
||
DETACHED.log.push([name, args]);
|
||
if (name === 'PushSubscription/get') {
|
||
return { methodResponses: [[name, { list: [{ id: args.ids[0], types: DETACHED.types }] }, id]] };
|
||
}
|
||
return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] };
|
||
}
|
||
},
|
||
}));
|
||
|
||
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,
|
||
forgetPushServerState,
|
||
isValidRelayUrl,
|
||
mailAccountsWithoutPush,
|
||
notificationTapJmapAccountId,
|
||
readPushAccountIds,
|
||
readPushJmapAccountIds,
|
||
PushSetupError,
|
||
pushTypesFor,
|
||
refreshPushSubscriptionTypes,
|
||
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 deleted = fetchMock.mock.calls.filter((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE').map((c) => String(c[0]));
|
||
expect(deleted).toEqual([`https://relay.home.lan/api/push/register/${OUR_DCID}`]);
|
||
|
||
// 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('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', '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 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');
|
||
listMock.mockResolvedValue([recorded(80)]);
|
||
installFetch({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
listMock.mockImplementation(async () => []);
|
||
createMock.mockImplementation(async () => CREATED);
|
||
updateMock.mockImplementation(async () => undefined);
|
||
});
|
||
|
||
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('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('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('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('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();
|
||
listMock.mockResolvedValue([
|
||
{ id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] },
|
||
]);
|
||
expect((await resyncPushNotifications({ relayBaseUrl: RELAY }))?.subscriptionId).toBe('new-server-id');
|
||
});
|
||
});
|
||
|
||
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('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);
|
||
});
|
||
});
|