mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: take down push for logins with no connection when signing out of everything
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user