fix: replace a browser subscription the push service gave up on

This commit is contained in:
Linus Rath
2026-11-04 03:13:57 +01:00
parent a6ef818149
commit 5c62c57657
2 changed files with 112 additions and 3 deletions
+74
View File
@@ -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));
+38 -3
View File
@@ -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<boolean> {
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<v
}
localStorage.removeItem(subIdKey);
localStorage.removeItem(relayBaseKey(accountId));
localStorage.removeItem(endpointKey(accountId));
await forgetRelayRegistration(accountId);
// Enabling again starts from the full emailPush map.
localStorage.removeItem(emailPushRefusedKey(accountId));