Files
webmail/lib/__tests__/plugin-send-guards.test.ts
T
Linus Rath 0c8d4ad93e fix: hold sends until required send checks have started, and check what really goes out
- The message for send checks is built after waiting for required
  checks that are still starting: a send right after sign-in gave them
  no external recipients and no file types.
- A plugin's forward is held too while a required check is down, also
  with no plugin listening.
- A plugin the admin forces on that hasn't reached this browser yet (a
  new device, a failed plugin sync) counts as starting.
- A check that stopped working says to reload or tell the admin.
- The files that were checked are the ones sent, and recipients a
  plugin adds after the check are checked again.
- A file Bulwark couldn't read is marked unreadable; a text file
  starting with "MZ" is text, Java and Android apps are told from ZIPs.

Changed assertions: runPluginSendCheck takes a builder and reports
guard-loading or guard-failed instead of guard-down; the composer test
of an auto-disabled guard expects the stopped_working text; the sniff
tests give programs a zero byte, as real ones have, and expect
unreadable on a file that can't be read.
2026-11-01 10:02:58 +01:00

187 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
awaitSendGuards,
downSendGuards,
getSendGuardSource,
guardsSending,
rememberDeclaredHooks,
setSendGuardSource,
} from '@/lib/plugin-send-guards';
import { runPluginSendCheck } from '@/lib/outgoing/plugin-send-check';
import { emailHooks } from '@/lib/plugin-hooks';
import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types';
import { usePluginStore } from '@/stores/plugin-store';
import { usePolicyStore } from '@/stores/policy-store';
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
// A send check the admin force-enabled that is starting, failed to start or
// was turned off after errors used to let every message through.
const guard = (patch: Partial<InstalledPlugin> = {}): InstalledPlugin => ({
id: 'send-checks', name: 'Send checks', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'running',
settings: {}, managed: true, forceEnabled: true, bundleHash: 'abc', ...patch,
});
const email: OutgoingEmail = { to: ['x@example.org'], cc: [], bcc: [], subject: 's', htmlBody: '', textBody: 't', identityId: 'i', attachments: [] };
beforeEach(() => {
localStorage.clear();
// Plugins are on (the default policy has them off).
const state = usePolicyStore.getState();
usePolicyStore.setState({ policy: { ...state.policy, features: { ...state.policy.features, pluginsEnabled: true } } });
});
afterEach(() => {
usePluginStore.setState({ plugins: [], initialized: false, serverPluginsSynced: false });
emailHooks.onBeforeEmailSend.clear();
vi.useRealTimers();
});
describe('downSendGuards', () => {
it('counts a required guard that is starting, failed to start or was turned off after errors', () => {
usePluginStore.setState({
plugins: [
guard({ id: 'a', status: 'enabled' }),
guard({ id: 'b', status: 'error', error: 'Bundle integrity mismatch: expected x, got y' }),
guard({ id: 'c', status: 'error', error: 'auto-disabled', enabled: false }),
guard({ id: 'd', status: 'running' }),
],
});
expect(downSendGuards().map((g) => [g.id, g.state])).toEqual([['a', 'loading'], ['b', 'failed'], ['c', 'failed']]);
});
it("doesn't count a plugin Bulwark refused on purpose, nor one the user turned on", () => {
usePluginStore.setState({
plugins: [
guard({ id: 'a', status: 'error', error: 'third-party-off' }),
guard({ id: 'b', status: 'error', error: 'approval-denied' }),
guard({ id: 'c', status: 'error', error: 'Privileged tier requires the "crypto:full" permission' }),
guard({ id: 'd', status: 'enabled', forceEnabled: false, managed: false }),
],
});
expect(downSendGuards()).toEqual([]);
});
it('counts a plugin the policy forces on', () => {
const spy = vi.spyOn(usePolicyStore.getState(), 'isPluginForceEnabled').mockImplementation((id: string) => id === 'p');
usePluginStore.setState({ plugins: [guard({ id: 'p', forceEnabled: false, status: 'enabled' })] });
expect(downSendGuards().map((g) => g.id)).toEqual(['p']);
spy.mockRestore();
});
it('knows a guard by the hooks of its last start, else by the email:send permission', () => {
expect(guardsSending(guard())).toBe(true);
expect(guardsSending(guard({ permissions: ['email:read'] }))).toBe(false);
rememberDeclaredHooks(guard(), 'abc', ['onComposeSend']);
expect(guardsSending(guard())).toBe(false);
// Another bundle: what the old one did no longer says anything.
expect(guardsSending(guard({ bundleHash: '"DEF"' }))).toBe(true);
rememberDeclaredHooks(guard({ bundleHash: 'def' }), 'def', ['onBeforeEmailSend']);
expect(guardsSending(guard({ bundleHash: '"DEF"' }))).toBe(true);
});
});
describe('awaitSendGuards', () => {
it('waits for a guard that is starting and goes on once it runs', async () => {
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const waiting = awaitSendGuards(5000);
usePluginStore.setState({ plugins: [guard({ status: 'running' })] });
expect(await waiting).toEqual([]);
});
it('gives up after the wait and reports it down', async () => {
vi.useFakeTimers();
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const waiting = awaitSendGuards(5000);
await vi.advanceTimersByTimeAsync(5000);
expect(await waiting).toEqual([{ id: 'send-checks', name: 'Send checks', state: 'loading' }]);
});
it('sees nothing without a source (no plugin store, as in Lite)', async () => {
const storeSource = getSendGuardSource();
setSendGuardSource(null);
try {
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled' })] });
expect(await awaitSendGuards(10)).toEqual([]);
} finally {
setSendGuardSource(storeSource);
}
});
});
describe('runPluginSendCheck', () => {
it('holds the send while a required guard is down, without asking the others', async () => {
const handler = vi.fn(() => undefined);
emailHooks.onBeforeEmailSend.register('other', handler);
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled', enabled: false })] });
expect(await runPluginSendCheck(() => email)).toBe('guard-failed');
expect(handler).not.toHaveBeenCalled();
usePluginStore.setState({ plugins: [guard()] });
expect(await runPluginSendCheck(() => email)).toBe('allowed');
expect(handler).toHaveBeenCalledTimes(1);
});
});
// A plugin the admin forces on that hasn't reached this browser (a new
// device, a failed plugin sync) used to count for nothing: mail went out
// unchecked until it arrived.
describe('a forced plugin this browser has not got yet', () => {
const forceInPolicy = (ids: string[]) => {
const state = usePolicyStore.getState();
usePolicyStore.setState({ policy: { ...state.policy, forceEnabledPlugins: ids } });
};
afterEach(() => forceInPolicy([]));
it('counts as starting until the admin list arrived in full, then not', () => {
forceInPolicy(['send-checks']);
usePluginStore.setState({ plugins: [], initialized: true, serverPluginsSynced: false });
expect(downSendGuards()).toEqual([{ id: 'send-checks', name: 'send-checks', state: 'loading' }]);
usePluginStore.setState({ initialized: false, serverPluginsSynced: true });
expect(downSendGuards().map((g) => g.id)).toEqual(['send-checks']);
// The server left it out on purpose (refused in the no-third-party mode, removed).
usePluginStore.setState({ initialized: true, serverPluginsSynced: true });
expect(downSendGuards()).toEqual([]);
});
it('counts nothing while the admin turned plugins off', () => {
forceInPolicy(['send-checks']);
const state = usePolicyStore.getState();
usePolicyStore.setState({ policy: { ...state.policy, features: { ...state.policy.features, pluginsEnabled: false } } });
try {
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled' })], initialized: false });
expect(downSendGuards()).toEqual([]);
} finally {
usePolicyStore.setState({ policy: { ...state.policy } });
}
});
});
// A send right after sign-in, while the check was still starting, gave it a
// message built for nobody: no external recipients, no file types.
describe('runPluginSendCheck builds the message after the wait', () => {
it('asks a check that finished starting with the message built then', async () => {
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const seen: unknown[] = [];
let built = 0;
const pending = runPluginSendCheck(() => { built++; return { ...email, externalRecipients: ['x@outside.test'] }; });
expect(built).toBe(0);
emailHooks.onBeforeEmailSend.register('send-checks', (m: OutgoingEmail) => { seen.push(m); return false; });
usePluginStore.setState({ plugins: [guard({ status: 'running' })] });
expect(await pending).toBe('vetoed');
expect(built).toBe(1);
expect(seen).toEqual([expect.objectContaining({ externalRecipients: ['x@outside.test'] })]);
});
it('says whether a guard is starting or has stopped working', async () => {
vi.useFakeTimers();
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const waiting = runPluginSendCheck(() => email);
await vi.advanceTimersByTimeAsync(8000);
expect(await waiting).toBe('guard-loading');
vi.useRealTimers();
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'Bundle integrity mismatch' })] });
expect(await runPluginSendCheck(() => email)).toBe('guard-failed');
});
});