mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d1e84666 | ||
|
|
55484453a1 | ||
|
|
b525c52870 | ||
|
|
b64afb462f |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.51",
|
||||
"version": "0.1.53",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.51",
|
||||
"version": "0.1.53",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.51",
|
||||
"version": "0.1.53",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } 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),
|
||||
},
|
||||
},
|
||||
NativeEventEmitter,
|
||||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/push', () => ({
|
||||
listPushSubscriptions: vi.fn(async () => []),
|
||||
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
||||
verifyPushSubscription: vi.fn(async () => undefined),
|
||||
destroyPushSubscription: vi.fn(async () => undefined),
|
||||
updatePushSubscription: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
|
||||
import {
|
||||
listPushSubscriptions,
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { generateAccountId } from '../account-utils';
|
||||
|
||||
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>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,33 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
|
||||
* the relay positively reports it inactive - a record it knows about that has
|
||||
* never forwarded a push and isn't freshly registered. Every other outcome (the
|
||||
* relay doesn't recognise the id, an older relay without this endpoint, a
|
||||
* network blip, or a live subscription) returns false, so we never reap
|
||||
* anything we can't confirm is dead. This is what lets setup clear its own
|
||||
* abandoned attempts - and dead siblings left by reinstalls that regenerated
|
||||
* the deviceClientId - without disturbing another live device or the PWA that
|
||||
* shares the account.
|
||||
*/
|
||||
async function relayReportsDead(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { active?: unknown };
|
||||
return body.active === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
@@ -241,12 +268,36 @@ async function pollVerificationCode(
|
||||
throw new Error('Timed out waiting for PushVerification from JMAP server');
|
||||
}
|
||||
|
||||
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
|
||||
// effect as auth state settles during startup and again on every FCM token
|
||||
// refresh; because a failing attempt blocks for up to 75s polling for the
|
||||
// verification code, those re-fires overlap. Without this guard each
|
||||
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
|
||||
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
|
||||
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
|
||||
// PushVerification"). Callers share the first in-flight run instead.
|
||||
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
|
||||
|
||||
/**
|
||||
* Full setup flow: ask permission, fetch the device's FCM token, register
|
||||
* with the relay, create a JMAP PushSubscription, poll for the verification
|
||||
* code, and finalise the subscription.
|
||||
* code, and finalise the subscription. Concurrent calls for the same account
|
||||
* are coalesced onto a single in-flight run.
|
||||
*/
|
||||
export async function setupPushNotifications(
|
||||
export function setupPushNotifications(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
|
||||
const existing = inFlightSetups.get(key);
|
||||
if (existing) return existing;
|
||||
const run = setupPushNotificationsInner(params).finally(() => {
|
||||
inFlightSetups.delete(key);
|
||||
});
|
||||
inFlightSetups.set(key, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function setupPushNotificationsInner(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const native = getNative();
|
||||
@@ -306,19 +357,29 @@ export async function setupPushNotifications(
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this account's
|
||||
// deviceClientId. These pile up when a previous enable attempt failed
|
||||
// mid-flow (verify race, network blip, app killed). Stalwart per-account
|
||||
// rate-limits PushVerification posts, so leaving stragglers around blocks
|
||||
// the new one's verification - the symptom is a confusing "code does not
|
||||
// match". Only reap matches for THIS account's deviceClientId; other
|
||||
// accounts' subscriptions on this server (rare but possible if the same
|
||||
// JMAP server backs multiple accounts) must be left alone.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
for (const s of stragglers) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
// Reap leftover Stalwart subscriptions that would otherwise starve the new
|
||||
// one's verification. Stalwart emits only one PushVerification per account
|
||||
// per ~60s and picks the oldest unverified subscription, so a single stale
|
||||
// straggler blocks every fresh attempt - the symptom is the confusing
|
||||
// "Timed out waiting for PushVerification" error. We can't read a
|
||||
// subscription's verified state or URL over JMAP (Stalwart hides both), only
|
||||
// its deviceClientId, so we decide what's safe to remove like this:
|
||||
// - same deviceClientId as ours: a previous attempt from THIS device,
|
||||
// always safe to reap.
|
||||
// - a different deviceClientId: could be another live device or the PWA on
|
||||
// this account. Ask the relay whether it's still alive and only reap the
|
||||
// ones it confirms are dead. Anything live - or anything the relay can't
|
||||
// vouch for (a different relay, a non-Bulwark client, a network blip) -
|
||||
// is left untouched.
|
||||
for (const s of existingSubs) {
|
||||
if (s.id === storedServerId) continue;
|
||||
if (s.deviceClientId === deviceClientId) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const serverAssignedId = await createPushSubscription({
|
||||
|
||||
Reference in New Issue
Block a user