From 4586f16c2ead15ac4866dbe203b930afa79acf7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:36:58 +0100 Subject: [PATCH] fix: remove the unverified subscription when push verification fails, and poll through a network blip --- lib/__tests__/web-push.test.ts | 62 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 31 +++++++++++------ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index b02630fa9..7f3dc3811 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -488,6 +488,68 @@ describe('enableWebPush when the verification does not come', () => { afterEach(() => { vi.useRealTimers(); }); + it('keeps polling through a network blip', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + let polls = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/verify/')) { + polls += 1; + if (polls === 1) throw new TypeError('Failed to fetch'); + return json({ verificationCode: 'code' }); + } + return json({}); + }); + vi.stubGlobal('fetch', fetchMock); + + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(5_000); + + expect(await settled).toBe('done'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code'); + }); + + it('removes its unverified subscription and reads as off when the code never comes', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + installSilentRelay(() => false); + + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(120_000); + + expect(await settled).toMatch(/verif/i); + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + expect(client.verifyPushSubscription).not.toHaveBeenCalled(); + }); + + it('removes its subscription when the server refuses the code', async () => { + const client = makeClient([]); + installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('Verification code does not match.')); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow('does not match'); + + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('keeps the old subscription working when its replacement fails verification', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('nope')); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true })).rejects.toThrow('nope'); + + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); + }); + it('touches the subscription after the server\'s minute, which makes it send the code', async () => { vi.useFakeTimers(); const client = makeClient([]); diff --git a/lib/web-push.ts b/lib/web-push.ts index 568c20143..66d296a7b 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -658,12 +658,16 @@ async function pollVerificationCode( askedAgain = true; await askAgain().catch(() => undefined); } - const res = await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), - ); - if (res.ok) { - const body = (await res.json()) as { verificationCode?: string | null }; - if (body.verificationCode) return body.verificationCode; + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), + ); + if (res.ok) { + const body = (await res.json()) as { verificationCode?: string | null }; + if (body.verificationCode) return body.verificationCode; + } + } catch { + // A blip on the way to the relay; the code is still coming. } await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 2000); @@ -881,10 +885,17 @@ export async function enableWebPush( const serverAssignedId = created.result; rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); - const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => - params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), - ); - await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + try { + const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => + params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), + ); + await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + } catch (err) { + // Unverified, it never delivers anything, takes up one of the account's + // places and stands in the way of the next verification. + await params.client.destroyPushSubscription(serverAssignedId).catch(() => undefined); + throw err; + } localStorage.setItem(subIdKey, serverAssignedId); localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); if (replacedServerId) {