feat: merge decrypting once per message and keeping external content off unverified mail

This commit is contained in:
Linus Rath
2026-11-03 13:38:40 +01:00
11 changed files with 357 additions and 10 deletions
@@ -0,0 +1,224 @@
// A crypto plugin's render can be a key operation: with a smart card a dialog, and two
// PIN cancels in a row lock the key. So the render hook runs once per message, not once
// per object: mark-read replaces the object and changes nothing the hook gets to see.
// And a plugin can keep external content off for a body it can't vouch for (EFAIL).
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { renderHooks, clearAllHooks } = await import('@/lib/plugin-hooks');
const { usePluginRenderedBody, pluginRenderKey } = await import('@/hooks/use-plugin-rendered-body');
const { EmailViewer } = await import('../email-viewer');
const { ConversationEmailBody } = await import('../thread-conversation-view');
const REMOTE = 'https://tracker.example/x.png';
// The blocker keeps the address in an inert attribute, for "Load images". What matters is that it isn't a live one.
const LOADS_REMOTE = /<img[^>]*\ssrc="https:\/\/tracker/;
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
const message = (over: Partial<Email> = {}) => ({
id: 'e1',
blobId: 'b1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
from: [{ name: 'Alice', email: 'alice@example.com' }],
to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
preview: 'Hello',
hasAttachment: false,
size: 100,
textBody: [{ partId: '1', type: 'text/plain' }],
htmlBody: [],
attachments: [],
bodyStructure: { type: 'application/pkcs7-mime' },
headers: { 'content-type': 'application/pkcs7-mime; smime-type=enveloped-data' },
bodyValues: { '1': { value: 'Hello Bob', isTruncated: false } },
...over,
}) as unknown as Email;
const renders = () => {
const handler = vi.fn(async () => ({ html: '', text: 'Decrypted', attachments: [], handledBy: 'smime' }));
renderHooks.onRenderEmailBody.register('smime', handler as never);
return handler;
};
const openViewer = (email: Email) => <EmailViewer email={email} mailboxes={mailboxes} />;
const originalDelay = useSettingsStore.getState().markAsReadDelay;
const originalPolicy = useSettingsStore.getState().externalContentPolicy;
beforeEach(() => {
useSettingsStore.setState({ markAsReadDelay: -1 });
});
afterEach(() => {
clearAllHooks();
useSettingsStore.setState({ markAsReadDelay: originalDelay, externalContentPolicy: originalPolicy });
});
describe('pluginRenderKey', () => {
it('ignores what the hook does not get: keywords and mailboxes', () => {
expect(pluginRenderKey(message({ keywords: { $seen: true } }))).toBe(pluginRenderKey(message()));
expect(pluginRenderKey(message({ mailboxIds: { archive: true } }))).toBe(pluginRenderKey(message()));
});
it('changes with the message and its blob', () => {
expect(pluginRenderKey(message({ id: 'e2' }))).not.toBe(pluginRenderKey(message()));
expect(pluginRenderKey(message({ blobId: 'b2' }))).not.toBe(pluginRenderKey(message()));
});
it('changes when the list row becomes the full message, which is what the hook decides on', () => {
const row = message({ headers: undefined, bodyStructure: undefined, bodyValues: undefined });
expect(pluginRenderKey(message())).not.toBe(pluginRenderKey(row));
});
it('changes when a truncated body arrives whole', () => {
const whole = message({ bodyValues: { '1': { value: 'Hello Bob, and the rest', isTruncated: false } } as never });
expect(pluginRenderKey(whole)).not.toBe(pluginRenderKey(message()));
});
});
describe('usePluginRenderedBody', () => {
it('runs the hook once when mark-read replaces the message object', async () => {
const handler = renders();
const { result, rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } });
await waitFor(() => expect(result.current.text).toBe('Decrypted'));
rerender({ email: message({ keywords: { $seen: true } }) });
await act(async () => {});
expect(handler).toHaveBeenCalledTimes(1);
expect(result.current.text).toBe('Decrypted');
});
it('runs it again for an asked-for render, and for another message', async () => {
const handler = renders();
const { rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
rerender({ email: message({ id: 'e2', blobId: 'b2' }) });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(3));
});
it('says when the plugin wants external content off', async () => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: '<p>Body</p>', text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
})) as never);
const { result } = renderHook(() => usePluginRenderedBody(message()));
await waitFor(() => expect(result.current.blockExternal).toBe(true));
});
it('leaves it to the settings otherwise', async () => {
const handler = renders();
const { result } = renderHook(() => usePluginRenderedBody(message()));
await waitFor(() => expect(handler).toHaveBeenCalled());
await waitFor(() => expect(result.current.text).toBe('Decrypted'));
expect(result.current.blockExternal).toBe(false);
});
});
describe('EmailViewer', () => {
it('runs the hook once when mark-read replaces the message object', async () => {
const handler = renders();
const { rerender } = render(openViewer(message()));
await screen.findByText('Decrypted');
rerender(openViewer(message({ keywords: { $seen: true } })));
await act(async () => {});
expect(handler).toHaveBeenCalledTimes(1);
expect(screen.getByText('Decrypted')).toBeTruthy();
}, 30_000);
it('runs it again when the list row becomes the full message', async () => {
const handler = renders();
const { rerender } = render(openViewer(message({ headers: undefined, bodyStructure: undefined })));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
rerender(openViewer(message()));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
}, 30_000);
it('runs it again for an asked-for render', async () => {
const handler = renders();
render(openViewer(message()));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
}, 30_000);
const remoteBody = (blockExternalContent?: boolean) => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime',
...(blockExternalContent ? { blockExternalContent } : {}),
})) as never);
};
const frameDoc = (container: HTMLElement) => container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
it('keeps external content off when the plugin says so, whatever the settings allow', async () => {
useSettingsStore.setState({ externalContentPolicy: 'allow' });
remoteBody(true);
const { container } = render(openViewer(message()));
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
expect(frameDoc(container)).not.toMatch(LOADS_REMOTE);
}, 30_000);
it('offers no way to load it then', async () => {
useSettingsStore.setState({ externalContentPolicy: 'ask' });
remoteBody(true);
const { container } = render(openViewer(message()));
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
await act(async () => {});
expect(screen.queryByTestId('external-content-notice')).toBeNull();
}, 30_000);
it('follows the settings for any other plugin body', async () => {
useSettingsStore.setState({ externalContentPolicy: 'allow' });
remoteBody();
const { container } = render(openViewer(message()));
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
expect(frameDoc(container)).toMatch(LOADS_REMOTE);
}, 30_000);
});
describe('ConversationEmailBody', () => {
const body = (allowExternal: boolean, onAllowExternal = () => {}) => (
<ConversationEmailBody email={message()} allowExternal={allowExternal} onAllowExternal={onAllowExternal} />
);
it('keeps external content off when the plugin says so, even with it allowed', async () => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
})) as never);
const { container } = render(body(true));
await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body'));
expect(container.querySelector('iframe')?.getAttribute('srcdoc')).not.toMatch(LOADS_REMOTE);
}, 30_000);
it('has no button to load it', async () => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
})) as never);
const { container } = render(body(false));
await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body'));
await act(async () => {});
expect(screen.queryByRole('button', { name: /load_external_content|load/i })).toBeNull();
}, 30_000);
});
+19 -5
View File
@@ -127,6 +127,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { pluginRenderKey } from "@/hooks/use-plugin-rendered-body";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
import { useUIStore } from "@/stores/ui-store";
@@ -1784,6 +1785,9 @@ export function EmailViewer({
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
// The plugin can't vouch for its body (mail with no integrity protection): external
// content stays off, whatever the settings or a trusted sender allow.
const [pluginBlocksExternal, setPluginBlocksExternal] = useState(false);
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
// after the user unlocks a key from the banner) to force the onRenderEmailBody
// hook to run again for the open message so the body re-decrypts.
@@ -2256,6 +2260,7 @@ export function EmailViewer({
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
setTnefHtml(null);
setTnefText(null);
setTnefAttachments(NO_TNEF_ATTACHMENTS);
@@ -2277,6 +2282,7 @@ export function EmailViewer({
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
return;
}
let cancelled = false;
@@ -2318,14 +2324,17 @@ export function EmailViewer({
text?: string;
attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>;
handledBy?: string;
blockExternalContent?: boolean;
};
if (cancelled) return;
if (!result || !result.handledBy) {
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
return;
}
setPluginBlocksExternal(result.blockExternalContent === true);
setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null);
setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null);
// Normalise the plugin's attachment shape into the PostalMime-like shape
@@ -2344,11 +2353,15 @@ export function EmailViewer({
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
}
})();
return () => { cancelled = true; };
}, [email, pluginRenderNonce]);
// Once per message, not per object: a render can be a key operation, and mark-read
// replaces the object. pluginRenderKey says what should run this again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginRenderKey(email), pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing
useEffect(() => {
@@ -2694,7 +2707,7 @@ export function EmailViewer({
// 'ask' = block until the user allows this message or trusts the sender.
const shouldBlockExternal = useMemo(() => {
if (!email) return false;
if (remoteHeldBack) return true;
if (remoteHeldBack || pluginBlocksExternal) return true;
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
@@ -2707,7 +2720,7 @@ export function EmailViewer({
// permission changes, so they're deliberately omitted from deps (matches
// the srcDoc rebuild contract described on `emailContent`).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack]);
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack, pluginBlocksExternal]);
// Reported as phishing ($phishing): a banner, its links turned off and
// no cards (a phishing mail's "booking" is the lure).
@@ -5674,7 +5687,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{(senderVerification ||
(!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
(!plainView && !pluginBlocksExternal && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt) ||
structuredCards.length > 0) && (
@@ -5707,7 +5720,8 @@ ${printWatermark ? printWatermark.html : ''}
{/* External Content Controls */}
{/* shouldBlockExternal: gone once trust applies (e.g. the
policy finished loading after the first paint). */}
{!plainView && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
{/* A plugin's own banner says why it keeps external content off, and there's nothing to load. */}
{!plainView && !pluginBlocksExternal && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
@@ -549,7 +549,7 @@ interface ConversationEmailBodyProps {
*/
export function ConversationEmailBody({
email,
allowExternal,
allowExternal: externalAllowed,
onAllowExternal,
onTrustSender,
onDownloadAttachment,
@@ -602,6 +602,8 @@ export function ConversationEmailBody({
const cidBlobUrls = useMemo(() => cidResult?.request === cidRequest ? cidResult.urls : {}, [cidResult, cidRequest]);
// A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
const pluginBody = usePluginRenderedBody(email);
// The plugin can't vouch for its body: external content stays off, whatever was allowed.
const allowExternal = externalAllowed && !pluginBody.blockExternal;
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
@@ -930,7 +932,7 @@ export function ConversationEmailBody({
<div className={className}>
{phishingMarked && <PhishingMarkBanner className="px-4" />}
{/* External content warning */}
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
{hasBlockedContent && !allowExternal && !pluginBody.blockExternal && emailContent.isHtml && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
<span className="text-muted-foreground">
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
+33 -3
View File
@@ -13,9 +13,34 @@ export interface PluginRenderedBody {
text: string | null;
/** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */
cidUrls: Record<string, string>;
/** The plugin can't vouch for this body: external content stays off, whatever the settings allow. */
blockExternal: boolean;
}
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} };
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {}, blockExternal: false };
/**
* What onRenderEmailBody gets to see, for its effect's dependencies. A render can be a
* key operation (a smart card's PIN dialog, and two cancels in a row lock the key), so
* it runs once per message, not per object: mark-read replaces the object and changes
* only keywords. The list row and then the full message do differ here: a row has no
* headers or body structure, which is what a plugin recognises its mail by. A plugin
* that wants to render again for another reason has api.ui.rerenderEmail.
*/
export function pluginRenderKey(email: Email | null | undefined): string | null {
if (!email) return null;
const contentType = email.headers?.['content-type'] ?? email.headers?.['Content-Type'] ?? '';
const bodyLengths = Object.values(email.bodyValues ?? {}).map((part) => part.value?.length ?? 0);
return JSON.stringify([
email.sourceClientAccountId ?? '',
email.id,
email.blobId ?? '',
contentType,
email.bodyStructure ? 1 : 0,
email.bodyValues ? bodyLengths : null,
email.attachments?.length ?? null,
]);
}
function dataUrlToBytes(dataUrl: string): Uint8Array | null {
try {
@@ -39,7 +64,8 @@ function dataUrlToBytes(dataUrl: string): Uint8Array | null {
* Asks the onRenderEmailBody hook for a replacement body, the way the single
* reader does, so a message a PGP or S/MIME plugin decrypts also shows
* decrypted inside a conversation (#902). Re-runs when a plugin calls
* api.ui.rerenderEmail, e.g. after the user unlocked a key.
* api.ui.rerenderEmail, e.g. after the user unlocked a key, and not when only the
* object changes (see pluginRenderKey).
*/
export function usePluginRenderedBody(email: Email): PluginRenderedBody {
const [body, setBody] = useState<PluginRenderedBody>(EMPTY);
@@ -79,6 +105,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
text?: string;
attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>;
handledBy?: string;
blockExternalContent?: boolean;
};
if (cancelled) return;
if (!result || !result.handledBy) {
@@ -100,6 +127,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
html: typeof result.html === 'string' && result.html ? result.html : null,
text: typeof result.text === 'string' && result.text ? result.text : null,
cidUrls,
blockExternal: result.blockExternalContent === true,
});
} catch (err) {
if (cancelled) return;
@@ -112,7 +140,9 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
cancelled = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [email, nonce]);
// The latest message is read inside; see pluginRenderKey for what should run this again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginRenderKey(email), nonce]);
return body;
}
+11
View File
@@ -196,6 +196,17 @@ describe('api.host.features.keystore', () => {
});
});
describe('api.host.features.blockExternalContent', () => {
it('is there for a plugin that takes over rendering', () => {
expect(snapshotHostFeatures(plugin({ permissions: ['email:render-takeover'] })).blockExternalContent).toBe(true);
});
it('is absent for any other, and without a plugin', () => {
expect(snapshotHostFeatures(plugin({ permissions: ['email:read'] }))).not.toHaveProperty('blockExternalContent');
expect(snapshotHostFeatures()).not.toHaveProperty('blockExternalContent');
});
});
describe('the manifest', () => {
const BASE = { id: 'keystore-test', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook' };
+11
View File
@@ -0,0 +1,11 @@
import { getDesktopBridge } from './bridge';
/**
* Takes back what the user allowed in this session's certificate dialogs: the next
* account at this computer gets asked. A refusal stays (it lasts until the app quits),
* and so does a stored yes for an instance, which is about the instance and not the
* person. Nothing happens in a browser or on a shell without the call.
*/
export function clearKeystoreGrants(): void {
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
}
+2
View File
@@ -180,6 +180,8 @@ export interface DesktopKeystoreBridge {
accessState: () => Promise<DesktopKeystoreAccessState>;
/** Takes back a stored yes and this session's grants. Only ever narrows. */
revokeAccess: () => Promise<void>;
/** Takes back this session's grants, for the next account. Absent on older shells. */
clearGrants?: () => Promise<void>;
}
export interface BulwarkDesktopBridge {
+2
View File
@@ -15,6 +15,7 @@ import type { HostFeaturesView, InstalledPlugin } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { assistantFeatures } from './assistant';
import { keystoreFeature } from './host-api-keystore';
import { pluginHasPermission } from './permissions';
/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */
export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning';
@@ -34,6 +35,7 @@ export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
...assistantFeatures(plugin),
...keystoreFeature(plugin),
...(plugin && pluginHasPermission(plugin, 'email:render-takeover') ? { blockExternalContent: true } : {}),
};
}
+6
View File
@@ -986,6 +986,12 @@ export interface HostFeaturesView extends AssistantHostFeatures {
* Absent otherwise. See `api.keystore`.
*/
keystore?: boolean;
/**
* onRenderEmailBody may return `blockExternalContent: true` to keep external content
* off for that body, whatever the settings and trusted senders allow. Per plugin, for
* those with `email:render-takeover`. Absent on webmails that ignore the field.
*/
blockExternalContent?: boolean;
}
/** One setting `api.app.searchSettings` found, in the user's language. */
@@ -33,6 +33,47 @@ describe('sign-out with plugins', () => {
expect(after).toHaveBeenCalledWith({ accountId: 'all' });
});
describe('keys in the OS store', () => {
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
const withBridge = (clearGrants?: () => Promise<void>) => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: { clearGrants } };
};
it('takes back this session\'s certificate grants at sign-out', async () => {
const clearGrants = vi.fn().mockResolvedValue(undefined);
withBridge(clearGrants);
await useAuthStore.getState().logout();
expect(clearGrants).toHaveBeenCalledTimes(1);
});
it('does so when all accounts sign out too', async () => {
const clearGrants = vi.fn().mockResolvedValue(undefined);
withBridge(clearGrants);
await useAuthStore.getState().logoutAll();
expect(clearGrants).toHaveBeenCalled();
});
it('and when switching to another account', () => {
const clearGrants = vi.fn().mockResolvedValue(undefined);
withBridge(clearGrants);
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
expect(clearGrants).not.toHaveBeenCalled();
useAuthStore.setState({ activeAccountId: 'a2' });
expect(clearGrants).toHaveBeenCalledTimes(1);
});
it('signs out all the same on a shell without the call, or one that fails', async () => {
withBridge(undefined);
await useAuthStore.getState().logout();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
useAuthStore.setState({ isAuthenticated: true });
withBridge(vi.fn().mockRejectedValue(new Error('gone')));
await useAuthStore.getState().logout();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
it('does not wait on a plugin that never answers', async () => {
vi.useFakeTimers();
authHooks.onBeforeLogout.register('p', () => new Promise(() => {}));
+4
View File
@@ -18,6 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { clearKeystoreGrants } from '@/lib/desktop/keystore-grants';
import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in';
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
@@ -2461,6 +2462,7 @@ export const useAuthStore = create<AuthState>()(
await useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
clearKeystoreGrants();
void authHooks.onAfterLogout.emit({
accountId: accountId ?? 'all',
});
@@ -2635,6 +2637,7 @@ export const useAuthStore = create<AuthState>()(
accountStore.removeAccount(account.id);
}
clearKeystoreGrants();
void authHooks.onAfterLogout.emit({
accountId: 'all'
});
@@ -3688,6 +3691,7 @@ useAuthStore.subscribe((state, prev) => {
const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId;
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
clearKeystoreGrants();
void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId });
}
});