Files
webmail/lib/__tests__/plugin-host-features.test.ts
T
Linus Rath bda99e1df8 feat: let plugins read the text of attachments
api.email.getAttachmentText reads PDF (pdf.js), Word, Excel, PowerPoint
and OpenDocument files (own reader over fflate and DOMParser, unpacking
capped at 50 MB), text, CSV, HTML, calendar and address files, and
answers with a status for what it can't read. The extraction lives in
lib/attachment-text so Files can use it too.

The sandbox runtime now copies every flag the host sends into
api.host.features; before, it kept only the three core flags, so the
assistant's flags never reached a plugin. The host bridge assertions in
plugin-host-features.test.ts compare against the core flags plus the
assistant's (withAssistant), since the init payload and host-change now
carry attachmentText too.
2026-11-02 21:53:05 +01:00

170 lines
8.4 KiB
TypeScript

/**
* api.host.features tells plugins which of Bulwark's own features replace
* them (externalSenderLabel and externalRecipientWarning: the
* organisation's External label and recipient warning). The host sends it with init and again on every
* change; the runtime keeps one object up to date.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import {
EXTERNAL_MAIL_WARNING_PLUGIN_ID,
isHookHandedOver,
isSlotHandedOver,
snapshotHostFeatures,
} from '@/lib/plugin-sandbox/host-features';
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top; their own tests check them. */
const withAssistant = (core: Record<string, boolean>) => ({ ...core, ...assistantFeatures() });
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
beforeEach(() => setExternalMail({}));
describe('snapshotHostFeatures', () => {
it('has one flag per switch', () => {
expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
setExternalMail({ senderLabel: true });
expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
setExternalMail({ recipientWarning: true });
expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }));
});
// A link check switches Google Safe Browsing off in the mode instead of
// taking the refused requests for a service that is down.
it('says when third-party traffic is off, and while that is not known yet', () => {
setNoThirdPartyTraffic(true);
expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(true);
setNoThirdPartyTraffic('unknown');
expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(true);
setNoThirdPartyTraffic(false);
expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(false);
});
});
describe('hand-over of the external-mail-warning plugin', () => {
it('skips its send check with the recipient warning and its toast and banner with the label', () => {
const id = EXTERNAL_MAIL_WARNING_PLUGIN_ID;
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false);
expect(isSlotHandedOver(id, 'email-banner')).toBe(false);
setExternalMail({ recipientWarning: true });
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(true);
expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(false);
expect(isSlotHandedOver(id, 'email-banner')).toBe(false);
setExternalMail({ senderLabel: true });
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false);
expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(true);
expect(isSlotHandedOver(id, 'email-banner')).toBe(true);
// Its other hooks, and every other plugin, keep running.
expect(isHookHandedOver(id, 'onIdentitiesLoaded')).toBe(false);
expect(isHookHandedOver('reply-all-guardrail', 'onBeforeEmailSend')).toBe(false);
expect(isSlotHandedOver('spam-score', 'email-banner')).toBe(false);
});
});
// ─── Host bridge: init payload and pushes ────────────────────
describe('host bridge', () => {
afterEach(() => { document.body.innerHTML = ''; });
it('puts the features into the init payload and pushes changes to running frames', async () => {
const { createBackgroundInstance } = await import('@/lib/plugin-sandbox/host-bridge');
setExternalMail({ senderLabel: true });
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
// Before init has gone out a change lands in the payload ...
setExternalMail({});
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
// ... after it, as a host-change message.
const sent: unknown[] = [];
(instance as unknown as { pendingInit: unknown }).pendingInit = null;
(instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m);
setExternalMail({ recipientWarning: true });
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }) }]);
// The admin turns third-party traffic off: running plugins hear it.
setNoThirdPartyTraffic(true);
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }) });
setNoThirdPartyTraffic(false);
instance.destroy();
}, 30_000);
});
// ─── Sandbox runtime: api.host.features ──────────────────────
const HOST_ORIGIN = window.location.origin;
function post(source: unknown, origin: string, data: unknown): void {
const ev = new MessageEvent('message', { data, origin });
Object.defineProperty(ev, 'source', { value: source });
window.dispatchEvent(ev);
}
describe('api.host in the sandbox runtime', () => {
let hostWindow: { postMessage: ReturnType<typeof vi.fn> };
const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
const g = globalThis as {
__pluginApi?: { host: { features: { externalSenderLabel: boolean; externalRecipientWarning: boolean }; onFeaturesChange(fn: (f: unknown) => void): () => void } };
__seenAtActivate?: boolean;
};
beforeEach(() => {
vi.resetModules();
hostWindow = { postMessage: vi.fn() };
Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window });
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
delete g.__pluginApi;
delete g.__seenAtActivate;
if (realParent) Object.defineProperty(window, 'parent', realParent);
});
it('has the value from init in activate and follows host-change', async () => {
const bundle = `module.exports = { activate: function (api) {
globalThis.__seenAtActivate = api.host.features.externalRecipientWarning;
globalThis.__pluginApi = api;
} };`;
const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
render(React.createElement(SandboxRuntime));
post(hostWindow, HOST_ORIGIN, {
type: 'init',
payload: {
mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en',
manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} },
code: bundle,
hostFeatures: { externalSenderLabel: false, externalRecipientWarning: true },
},
});
await waitFor(() => expect(g.__pluginApi).toBeDefined());
expect(g.__seenAtActivate).toBe(true);
const api = g.__pluginApi!;
const kept = api.host.features;
const changes: unknown[] = [];
api.host.onFeaturesChange((f) => changes.push({ ...(f as object) }));
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } });
expect(kept.externalRecipientWarning).toBe(false);
expect(kept.externalSenderLabel).toBe(true);
expect((kept as { noThirdPartyTraffic?: boolean }).noThirdPartyTraffic).toBe(true);
expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true }]);
}, 30_000);
});