diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index f1d8a1c36..dbb2fb478 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -741,6 +741,80 @@ describe('what enabling and disabling say when they fail', () => { }); }); +describe('an endpoint the push service gave up on', () => { + const ENDPOINT_KEY = `bulwark.push.endpoint.v1.${ACCOUNT_ID}`; + + function registered() { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/endpoint'); + return makeClient([sub('push-mine', THIS_DEVICE)]); + } + + it('gets a new subscription when the relay dropped the record for the very endpoint this browser still holds', async () => { + const { browserSub, registration } = installPushBrowser(); + const client = registered(); + installFetch({}); // The relay answers 404 for this device. + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + }); + + it('keeps the subscription while the relay knows the record', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + installFetch({ [THIS_DEVICE]: true }); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('keeps it when the relay cannot be asked', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + const calls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes('/api/push/active/')) throw new TypeError('blip'); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.some((url) => url.includes('/api/push/active/'))).toBe(true); + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('only registers when another account already moved the browser to a new endpoint', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/older'); + installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + expect(localStorage.getItem(ENDPOINT_KEY)).toBe('https://fcm.example/endpoint'); + }); + + it('does not mistake a move to another relay for a dead endpoint', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index f611ece59..11ca5a16b 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -53,6 +53,12 @@ function relayBaseKey(accountId: string): string { return RELAY_BASE_PREFIX + accountId; } +/** The browser endpoint this account last gave the relay: see relayDroppedOurEndpoint. */ +const ENDPOINT_PREFIX = 'bulwark.push.endpoint.v1.'; +function endpointKey(accountId: string): string { + return ENDPOINT_PREFIX + accountId; +} + function emailPushRefusedKey(accountId: string): string { return EMAIL_PUSH_REFUSED_PREFIX + accountId; } @@ -642,6 +648,28 @@ async function relayReportsDead( const VERIFICATION_RESEND_AFTER_MS = 62_000; const VERIFICATION_TIMEOUT_MS = 80_000; +/** + * The relay deletes a record when the push service answers 404 or 410. The browser + * doesn't get told: it keeps the subscription, and registering that endpoint again + * only lasts until the next push. Only for the endpoint this account registered: + * after another account moved the browser on, a missing record means nothing. + */ +async function relayDroppedOurEndpoint( + accountId: string, + relayBaseUrl: string, + subscriptionId: string, + endpoint: string, +): Promise { + if (localStorage.getItem(relayBaseKey(accountId)) !== relayBaseUrl) return false; + if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false; + try { + const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`)); + return res.status === 404; + } catch { + return false; + } +} + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, @@ -785,6 +813,14 @@ async function enableWebPushForThisTab( await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; } + const accountId = params.client.getAccountId(); + const deviceClientId = getOrCreateDeviceClientId(accountId); + if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) { + await pushSubscription.unsubscribe().catch(() => undefined); + pushSubscription = null; + // The other accounts' records point at the old endpoint: their turn comes too. + lastResync.clear(); + } if (!pushSubscription) { pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, @@ -792,9 +828,6 @@ async function enableWebPushForThisTab( }); } - const accountId = params.client.getAccountId(); - const deviceClientId = getOrCreateDeviceClientId(accountId); - await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, @@ -807,6 +840,7 @@ async function enableWebPushForThisTab( }, accountLabel: params.accountLabel, }); + localStorage.setItem(endpointKey(accountId), pushSubscription.endpoint); await keepRelayRegistration(accountId, { relay: relayBaseUrl, subscriptionId: deviceClientId, @@ -1001,6 +1035,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise