Files
webmail/lib/__tests__/plugin-host-bridge-origin.test.ts
T

383 lines
18 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
vi.mock('../plugin-sandbox/host-api', () => ({
dispatchApiCall: vi.fn(async () => 'ok'),
}));
import { createBackgroundInstance, createSlotInstance } from '../plugin-sandbox/host-bridge';
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(): InstalledPlugin {
return {
id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
};
}
function post(source: Window | null, origin: string, data: unknown) {
window.dispatchEvent(new MessageEvent('message', { data, origin, source }));
}
const instances: Array<{ destroy(): void }> = [];
/** The frame answers init: its hooks and slots are in place. */
function booted<T extends { iframe: HTMLIFrameElement }>(inst: T): T {
post(inst.iframe.contentWindow, 'null', { type: 'init-done', hooks: [], slots: [], shortcuts: [] });
return inst;
}
afterEach(() => {
for (const i of instances.splice(0)) i.destroy();
vi.clearAllMocks();
});
describe('host bridge origin pin for untrusted sandboxes', () => {
it('answers API requests from the null-origin sandbox document', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
post(inst.iframe.contentWindow, 'null', { type: 'api-request', id: '1', method: 'storage.keys', args: [] });
await new Promise((r) => setTimeout(r, 0));
expect(dispatchApiCall).toHaveBeenCalledTimes(1);
});
it('ignores the same frame once it has navigated to another origin', async () => {
// A sandboxed frame may navigate itself. The page it lands on is no
// longer under the sandbox CSP, so it must not inherit the RPC channel.
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
post(inst.iframe.contentWindow, 'https://attacker.example', { type: 'api-request', id: '1', method: 'storage.keys', args: [] });
await new Promise((r) => setTimeout(r, 0));
expect(dispatchApiCall).not.toHaveBeenCalled();
});
it('tears the frame down once it loads a second document', async () => {
// The host posts hook payloads with targetOrigin '*'; after the sandbox
// navigates itself, whatever page it shows must not keep receiving them.
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
const frame = inst.iframe;
frame.dispatchEvent(new Event('load')); // the sandbox document
expect(frame.isConnected).toBe(true);
frame.dispatchEvent(new Event('load')); // some other page
expect(frame.isConnected).toBe(false);
await expect(inst.initPromise).rejects.toThrow(/navigated away/);
post(frame.contentWindow, 'null', { type: 'api-request', id: '1', method: 'storage.keys', args: [] });
await new Promise((r) => setTimeout(r, 0));
expect(dispatchApiCall).not.toHaveBeenCalled();
});
});
describe('init payload settings', () => {
it('hands a new iframe the settings with defaults filled in', async () => {
const p = {
...plugin(),
settings: { size: 64 },
settingsSchema: {
size: { type: 'number' as const, label: 'Size', default: 160 },
mode: { type: 'select' as const, label: 'Mode', default: 'auto', options: ['auto', 'manual'] },
},
};
const inst = createBackgroundInstance({ plugin: p, code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage');
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
await new Promise((r) => setTimeout(r, 0));
const init = sent.mock.calls.map(([msg]) => msg as { type: string; payload?: { manifest: { settings: unknown } } }).find((m) => m.type === 'init');
expect(init?.payload?.manifest.settings).toEqual({ size: 64, mode: 'auto' });
});
});
describe('slot sizing', () => {
function slot(name: 'email-banner' | 'composer-toolbar') {
const onResize = vi.fn();
const inst = createSlotInstance({
plugin: plugin(), slot: name, code: '', locale: 'en', tier: 'untrusted', extraProps: {},
hostContainer: document.body, onResize,
});
instances.push(inst);
return { inst, onResize };
}
it('ignores a height that is not a number and caps a huge one', async () => {
const { inst, onResize } = slot('email-banner');
post(inst.iframe.contentWindow, 'null', { type: 'slot-resize', height: Number.NaN });
post(inst.iframe.contentWindow, 'null', { type: 'slot-resize', height: 1e9 });
await new Promise((r) => setTimeout(r, 0));
expect(onResize).toHaveBeenCalledTimes(1);
expect(inst.iframe.style.height).toBe('2000px');
});
it('sizes a toolbar slot to the width of its content', async () => {
// A toolbar slot's iframe used to get no width in the composer's
// button row and squeezed its content to one letter per line.
const { inst, onResize } = slot('composer-toolbar');
post(inst.iframe.contentWindow, 'null', { type: 'slot-resize', height: 32, width: 120 });
await new Promise((r) => setTimeout(r, 0));
expect(inst.iframe.style.width).toBe('120px');
expect(onResize).toHaveBeenCalledWith(32, 120);
});
it('keeps a block slot at full width whatever it reports', async () => {
const { inst } = slot('email-banner');
post(inst.iframe.contentWindow, 'null', { type: 'slot-resize', height: 40, width: 120 });
await new Promise((r) => setTimeout(r, 0));
expect(inst.iframe.style.width).toBe('100%');
});
});
describe('shouldShow context', () => {
it('leaves host callbacks out instead of failing to post them', async () => {
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
instances.push(inst);
const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage');
void inst.evaluateShouldShow('composer-attachment-source', { onAttach: () => {}, nested: { cb: () => {}, n: 1 } });
const msg = sent.mock.calls.map(([m]) => m as { type: string; context?: unknown }).find((m) => m.type === 'slot-should-show');
expect(msg?.context).toEqual({ nested: { n: 1 } });
});
});
describe('host bookkeeping', () => {
it('fails a hook call whose arguments cannot be sent, without keeping it pending', async () => {
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
instances.push(inst);
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation(() => { throw new DOMException('no clone', 'DataCloneError'); });
await expect(inst.invokeHook('onEmailOpen', [() => {}])).rejects.toThrow(/no clone/);
expect((inst as unknown as { pendingHookInvokes: Map<string, unknown> }).pendingHookInvokes.size).toBe(0);
});
it('drops callbacks of props that were replaced', () => {
const inst = createSlotInstance({
plugin: plugin(), slot: 'email-banner', code: '', locale: 'en', tier: 'untrusted', extraProps: { a: () => 1 },
hostContainer: document.body, onResize: () => {},
});
instances.push(inst);
const table = (inst as unknown as { callbackTable: Map<string, unknown> }).callbackTable;
for (let i = 0; i < 50; i++) inst.updateProps({ onAttach: () => i });
expect(table.size).toBe(1);
});
it('boots a slot with the props updated before init, whose callbacks still answer', async () => {
// The update used to drop the callbacks init was still going to carry,
// so the slot's first call got "unknown callback" (#1266).
const inst = createSlotInstance({
plugin: plugin(), slot: 'calendar-event-actions', code: '', locale: 'en', tier: 'untrusted',
extraProps: { setVirtualLocation: () => 'old' }, hostContainer: document.body, onResize: () => {},
});
instances.push(inst);
const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage');
inst.updateProps({ setVirtualLocation: () => 'new' });
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
await new Promise((r) => setTimeout(r, 0));
type Msg = { type: string; payload?: { extraProps: { setVirtualLocation: { __pluginCallback: string } } } };
const messages = () => sent.mock.calls.map(([m]) => m as Msg);
const callbackId = messages().find((m) => m.type === 'init')!.payload!.extraProps.setVirtualLocation.__pluginCallback;
post(inst.iframe.contentWindow, 'null', { type: 'callback-invoke', id: 'c1', callbackId, args: [] });
await new Promise((r) => setTimeout(r, 0));
expect(messages().find((m) => m.type === 'callback-response')).toEqual({ type: 'callback-response', id: 'c1', ok: true, result: 'new' });
expect(messages().map((m) => m.type)).not.toContain('props-update');
});
});
describe('plugin frame permissions', () => {
it('may write to the clipboard and use forms, nothing more', () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
try {
expect(inst.iframe.getAttribute('allow')).toBe('clipboard-write');
expect(inst.iframe.getAttribute('sandbox')?.split(' ').sort()).toEqual(
process.env.NODE_ENV === 'development' ? ['allow-same-origin', 'allow-scripts'] : ['allow-forms', 'allow-scripts'],
);
} finally {
inst.destroy();
}
});
});
describe('plugin frame color scheme', () => {
// A frame whose document says dark while the iframe element says light (or
// the other way round) gets an opaque canvas from the browser: a white
// panel in a dark page.
function slot() {
const inst = createSlotInstance({
plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {},
hostContainer: document.body, onResize: () => {},
});
instances.push(inst);
return inst;
}
it('starts with the color-scheme of the theme the frame boots with', async () => {
document.documentElement.classList.add('dark');
try {
expect(slot().iframe.style.colorScheme).toBe('dark');
} finally {
document.documentElement.classList.remove('dark');
// Let the page-theme watcher settle before the next test's frames.
await new Promise((r) => setTimeout(r, 50));
}
});
it('keeps a slot frame hidden until it has booted with the theme', () => {
// Before init the document (about:blank, then the bare sandbox page)
// declares no color-scheme, so a dark-theme panel showed white.
const inst = slot();
expect(inst.iframe.style.visibility).toBe('hidden');
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
expect(inst.iframe.style.visibility).toBe('hidden');
post(inst.iframe.contentWindow, 'null', { type: 'init-done', hooks: [], slots: [], shortcuts: [] });
expect(inst.iframe.style.visibility).toBe('');
});
it('does not hide background frames', () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
expect(inst.iframe.style.visibility).toBe('');
});
it('boots with a theme that changed before the frame was ready', async () => {
const inst = slot();
const sent: unknown[] = [];
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation((msg: unknown) => { sent.push(msg); });
inst.setTheme({ dark: true, fontFamily: 'sans-serif', vars: {} });
expect(inst.iframe.style.colorScheme).toBe('dark');
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
await new Promise((r) => setTimeout(r, 0));
const init = sent.find((m) => (m as { type: string }).type === 'init') as { payload: { theme: { dark: boolean } } };
expect(init.payload.theme.dark).toBe(true);
expect(sent.some((m) => (m as { type: string }).type === 'theme-change')).toBe(false);
});
it('switches the element only once the frame has applied a theme change', async () => {
const inst = slot();
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation(() => {});
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
await new Promise((r) => setTimeout(r, 0));
post(inst.iframe.contentWindow, 'null', { type: 'init-done', hooks: [], slots: [], shortcuts: [] });
expect(inst.iframe.style.colorScheme).toBe('light');
inst.setTheme({ dark: true, fontFamily: 'sans-serif', vars: {} });
// The frame's document is still light: a dark element now means white.
expect(inst.iframe.style.colorScheme).toBe('light');
post(inst.iframe.contentWindow, 'null', { type: 'theme-applied', dark: true });
expect(inst.iframe.style.colorScheme).toBe('dark');
});
});
describe('plugin frames follow the page theme', () => {
// The theme can change without the theme store: settings synced from the
// server after load, a custom theme's stylesheet. Frames must follow what
// the page shows, or a dark page gets light frames.
it('pushes the new theme to open frames when the page root turns dark', async () => {
const inst = createSlotInstance({
plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {},
hostContainer: document.body, onResize: () => {},
});
instances.push(inst);
expect(inst.iframe.style.colorScheme).toBe('light');
document.documentElement.classList.add('dark');
try {
await new Promise((r) => setTimeout(r, 50));
expect(inst.iframe.style.colorScheme).toBe('dark');
} finally {
document.documentElement.classList.remove('dark');
await new Promise((r) => setTimeout(r, 50));
}
expect(inst.iframe.style.colorScheme).toBe('light');
});
});
describe('plugin translations in frames', () => {
const locales = {
en: { hello: 'Hello' },
de: { hello: 'Hallo' },
fr: { hello: 'Bonjour' },
ja: { hello: 'こんにちは' },
};
it('sends a frame only the tables of its locale, and the next ones with a locale change', async () => {
const inst = createSlotInstance({
plugin: { ...plugin(), locales }, slot: 'plugin-dialog', code: '', locale: 'de', tier: 'untrusted', extraProps: {},
hostContainer: document.body, onResize: () => {},
});
instances.push(inst);
const sent: Array<Record<string, unknown>> = [];
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation((msg: unknown) => { sent.push(msg as Record<string, unknown>); });
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
await new Promise((r) => setTimeout(r, 0));
const init = sent.find((m) => m.type === 'init') as { payload: { manifest: { locales: Record<string, unknown> } } };
expect(Object.keys(init.payload.manifest.locales).sort()).toEqual(['de', 'en']);
inst.setLocale('fr');
const change = sent.find((m) => m.type === 'locale-change') as { locale: string; locales: Record<string, unknown> };
expect(change.locale).toBe('fr');
expect(change.locales).toEqual({ fr: { hello: 'Bonjour' }, en: { hello: 'Hello' } });
});
it('does the same for background frames', async () => {
const inst = createBackgroundInstance({ plugin: { ...plugin(), locales }, code: '', locale: 'ja', tier: 'untrusted' });
instances.push(inst);
const sent: Array<Record<string, unknown>> = [];
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation((msg: unknown) => { sent.push(msg as Record<string, unknown>); });
post(inst.iframe.contentWindow, 'null', { type: 'sandbox-ready' });
await new Promise((r) => setTimeout(r, 0));
const init = sent.find((m) => m.type === 'init') as { payload: { manifest: { locales: Record<string, unknown> } } };
expect(Object.keys(init.payload.manifest.locales).sort()).toEqual(['en', 'ja']);
});
});
describe('calls before the background frame has booted', () => {
// A plugin offering the slots remembered from its last start is in the
// registry while its frame boots; a message posted before init is lost.
it('holds shouldShow and hook calls until init-done', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
const sent: Array<{ type: string; id?: string }> = [];
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation((m: unknown) => { sent.push(m as { type: string }); });
const show = inst.evaluateShouldShow('email-banner', {});
const hook = inst.invokeHook('onAction', [{ actionId: 'x' }]);
await new Promise((r) => setTimeout(r, 0));
expect(sent.filter((m) => m.type === 'slot-should-show' || m.type === 'hook-invoke')).toEqual([]);
booted(inst);
await new Promise((r) => setTimeout(r, 0));
const ask = sent.find((m) => m.type === 'slot-should-show')!;
const call = sent.find((m) => m.type === 'hook-invoke')!;
post(inst.iframe.contentWindow, 'null', { type: 'slot-should-show-result', id: ask.id, show: true });
post(inst.iframe.contentWindow, 'null', { type: 'hook-result', id: call.id, ok: true, result: 'done' });
await expect(show).resolves.toBe(true);
await expect(hook).resolves.toBe('done');
});
it('gives up on them when the frame is destroyed first', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
const show = inst.evaluateShouldShow('email-banner', {});
const hook = inst.invokeHook('onAction', []);
inst.destroy();
await expect(show).resolves.toBe(false);
await expect(hook).rejects.toThrow(/destroyed/);
await expect(inst.initPromise).rejects.toThrow(/destroyed/);
expect(inst.closed).toBe(true);
});
});