mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
- 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.
187 lines
8.6 KiB
TypeScript
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');
|
|
});
|
|
});
|