fix: take down push for logins with no connection when signing out of everything

This commit is contained in:
Linus Rath
2026-11-04 03:13:59 +01:00
parent 9bc31b54ab
commit 248f9c261b
4 changed files with 74 additions and 2 deletions
+28
View File
@@ -6,6 +6,7 @@ import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client';
import {
buildEmailPushConfig,
disableWebPush,
dropAllWebPushRegistrations,
enableWebPush,
endWebPushForSignOut,
isWebPushEnabled,
@@ -1992,6 +1993,33 @@ describe('endWebPushForSignOut', () => {
});
});
describe('dropAllWebPushRegistrations', () => {
it('takes down every login this browser registered, connected or not, and calls no relay it did not use', async () => {
const { browserSub } = installPushBrowser();
localStorage.setItem('bulwark.push.subscriptionId.v1.a', 'x');
localStorage.setItem('bulwark.push.deviceClientId.v1.a', 'device-a');
localStorage.setItem('bulwark.push.relayBase.v1.a', RELAY);
localStorage.setItem('bulwark.push.subscriptionId.v1.b', 'y');
localStorage.setItem('bulwark.push.deviceClientId.v1.b', 'device-b');
const calls = installFetch({});
await dropAllWebPushRegistrations();
expect(calls).toEqual([{ url: `${RELAY}/api/push/register/device-a`, method: 'DELETE' }]);
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.a')).toBeNull();
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.b')).toBeNull();
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
});
it('does nothing with nothing registered', async () => {
const { browserSub } = installPushBrowser();
const calls = installFetch({});
await dropAllWebPushRegistrations();
expect(calls).toEqual([]);
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
});
describe('listPushDevices', () => {
it('flags this device and reports the relay status of each registration', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+28
View File
@@ -1119,6 +1119,34 @@ export async function endWebPushForSignOut(client: IJMAPClient): Promise<void> {
await disableWebPush({ client, relayBaseUrl }).catch(() => undefined);
}
/**
* Nobody stays signed in here. A login without a connection can't reach its mail
* server, but the relay needs no login: without the record its pings go nowhere.
* Whatever is left of the keys goes, so nothing keeps the browser subscription.
*/
export async function dropAllWebPushRegistrations(): Promise<void> {
if (typeof localStorage === 'undefined') return;
const accounts: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(SUBSCRIPTION_ID_PREFIX)) accounts.push(key.slice(SUBSCRIPTION_ID_PREFIX.length));
}
if (accounts.length === 0) return;
for (const accountId of accounts) {
const relay = localStorage.getItem(relayBaseKey(accountId));
const device = localStorage.getItem(deviceClientIdKey(accountId));
if (relay && device) await dropRelayRecord(relay, device);
localStorage.removeItem(subscriptionIdKey(accountId));
localStorage.removeItem(relayBaseKey(accountId));
localStorage.removeItem(endpointKey(accountId));
await forgetRelayRegistration(accountId);
}
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null);
const sub = await registration?.pushManager.getSubscription().catch(() => null);
await sub?.unsubscribe().catch(() => undefined);
}
export interface PushDevice {
// The JMAP PushSubscription id - what you destroy to revoke it.
id: string;
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/lib/web-push', async (original) => ({ ...(await original<typeof import('@/lib/web-push')>()), endWebPushForSignOut }));
const dropAllWebPushRegistrations = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/lib/web-push', async (original) => ({ ...(await original<typeof import('@/lib/web-push')>()), endWebPushForSignOut, dropAllWebPushRegistrations }));
import { setClientForTests, useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
@@ -78,6 +79,13 @@ describe('signing out ends the login\'s push registration', () => {
expect(useAuthStore.getState().client).toBeNull();
});
it('signing out of everything also covers logins with no connection', async () => {
dropAllWebPushRegistrations.mockClear();
useAuthStore.setState({ isDemoMode: false });
await useAuthStore.getState().logoutAll();
expect(dropAllWebPushRegistrations).toHaveBeenCalledTimes(1);
});
it('not in demo mode', async () => {
const client = fakeClient();
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true });
+9 -1
View File
@@ -2738,7 +2738,15 @@ export const useAuthStore = create<AuthState>()(
for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session');
}
await beforeLogoutHooks('all', { keepLocalStore: keepLocalData });
if (!get().isDemoMode && !keepLocalData) await endPushForSignOut(clients.values());
if (!get().isDemoMode && !keepLocalData) {
// Each connected login first, on a budget that one unreachable server can
// use up. What's left, and whoever had no connection, only needs the relay.
await endPushForSignOut(clients.values());
await Promise.race([
import('@/lib/web-push').then((push) => push.dropAllWebPushRegistrations()).catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
]);
}
const accountStore = useAccountStore.getState();
const allAccounts = [...accountStore.accounts];