mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
147 lines
7.8 KiB
TypeScript
147 lines
7.8 KiB
TypeScript
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
|
|
const apiFetch = vi.hoisted(() => vi.fn());
|
|
vi.mock('@/lib/browser-navigation', async (orig) => ({
|
|
...(await orig<typeof import('@/lib/browser-navigation')>()),
|
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
|
}));
|
|
const registryEntries = vi.hoisted(() => [] as unknown[]);
|
|
vi.mock('../plugin-sandbox/registry', async (orig) => ({
|
|
...(await orig<typeof import('../plugin-sandbox/registry')>()),
|
|
all: () => registryEntries,
|
|
}));
|
|
|
|
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
|
import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions';
|
|
import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
|
|
import { allPages, resetPages } from '../plugin-sandbox/pages';
|
|
import type { StreamSink } from '../plugin-sandbox/proxy-client';
|
|
import '../plugin-sandbox/loader';
|
|
|
|
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
|
return {
|
|
id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
|
|
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
apiFetch.mockReset();
|
|
resetActions();
|
|
resetDayLabels();
|
|
resetPages();
|
|
registryEntries.length = 0;
|
|
});
|
|
|
|
describe('ui.setActions', () => {
|
|
it('puts a plugin\'s buttons on a surface it may use', async () => {
|
|
await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [
|
|
{ id: 'b', label: 'Second', order: 2 }, { id: 'a', label: 'First', order: 1, icon: 'archive' },
|
|
]]);
|
|
|
|
expect(actionsFor('message-row-actions').map((a) => [a.pluginId, a.id])).toEqual([['p1', 'a'], ['p1', 'b']]);
|
|
});
|
|
|
|
it('refuses a surface the plugin has no permission for, and frame slots', async () => {
|
|
await expect(dispatchApiCall(plugin({ permissions: ['ui:toolbar'] }), 'ui.setActions', ['message-row-actions', []]))
|
|
.rejects.toThrow(/may not add actions/);
|
|
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-banner'] }), 'ui.setActions', ['email-banner', []]))
|
|
.rejects.toThrow(/not a surface that takes actions/);
|
|
expect(actionsFor('message-row-actions')).toEqual([]);
|
|
});
|
|
|
|
it('checks what it is given', () => {
|
|
expect(() => checkActions([{ id: 'x', label: '' }])).toThrow(/label/);
|
|
expect(() => checkActions([{ id: 'x', label: 'X' }, { id: 'x', label: 'Y' }])).toThrow(/twice/);
|
|
expect(() => checkActions(Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A' })))).toThrow(/At most/);
|
|
expect(() => checkActions([{ id: 'x', label: 'X', icon: '<svg>' }])).toThrow(/icon/);
|
|
});
|
|
|
|
it('sends a click to the plugin with only the context it may see', async () => {
|
|
const invokeHook = vi.fn().mockResolvedValue(undefined);
|
|
const p = plugin({ permissions: ['ui:message-list'] });
|
|
registryEntries.push({ plugin: p, background: { invokeHook } });
|
|
await dispatchApiCall(p, 'ui.setActions', ['message-row-actions', [{ id: 'task', label: 'Make a task' }]]);
|
|
|
|
await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 });
|
|
|
|
// emailId needs email:read, which this plugin lacks.
|
|
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: {} }]);
|
|
|
|
p.permissions.push('email:read');
|
|
await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 });
|
|
expect(invokeHook).toHaveBeenLastCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: { emailId: 'e1' } }]);
|
|
});
|
|
});
|
|
|
|
describe('day labels and pages', () => {
|
|
it('stores labels by date, behind ui:calendar-action', async () => {
|
|
await expect(dispatchApiCall(plugin(), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday' }]])).rejects.toThrow(/lacks permission/);
|
|
await dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday', dot: true }]]);
|
|
|
|
expect(dayLabelsByDate().get('2026-12-24')).toEqual([expect.objectContaining({ text: 'Holiday', dot: true, pluginId: 'p1' })]);
|
|
await expect(dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '24.12.2026', text: 'x' }]])).rejects.toThrow(/YYYY-MM-DD/);
|
|
});
|
|
|
|
it('registers pages behind ui:app-page', async () => {
|
|
await dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'files', title: 'Nextcloud', icon: 'cloud' }]]);
|
|
|
|
expect(allPages()).toEqual([expect.objectContaining({ pluginId: 'p1', id: 'files', title: 'Nextcloud' })]);
|
|
await expect(dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'A B', title: 'x' }]])).rejects.toThrow(/id/);
|
|
});
|
|
});
|
|
|
|
describe('proxy.fetch', () => {
|
|
const proxied = plugin({
|
|
permissions: ['http:proxy'],
|
|
credentials: { cloud: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } } },
|
|
});
|
|
|
|
it('sends the request to the server proxy and returns the answer', async () => {
|
|
apiFetch.mockResolvedValue(new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
|
|
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/ocs/v2.php/cloud/user', {
|
|
method: 'GET', headers: { 'OCS-APIRequest': 'true' }, responseType: 'json',
|
|
}]);
|
|
|
|
expect(res).toMatchObject({ status: 200, ok: true, body: { ok: 1 } });
|
|
const [url, init] = apiFetch.mock.calls[0];
|
|
expect(url).toBe('/api/plugin-proxy?plugin=p1&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Focs%2Fv2.php%2Fcloud%2Fuser');
|
|
expect(init.headers).toMatchObject({ 'OCS-APIRequest': 'true', 'x-bulwark-method': 'GET' });
|
|
});
|
|
|
|
it('refuses a credential the manifest does not declare, and plugins without http:proxy', async () => {
|
|
await expect(dispatchApiCall(proxied, 'proxy.fetch', ['other', 'https://a.example', {}])).rejects.toThrow(/declares no credential/);
|
|
await expect(dispatchApiCall({ ...proxied, permissions: [] }, 'proxy.fetch', ['cloud', 'https://a.example', {}])).rejects.toThrow(/lacks permission/);
|
|
expect(apiFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('tells the plugin when the proxy itself refused', async () => {
|
|
apiFetch.mockResolvedValue(new Response('{"error":"Too many requests","code":"rate_limited"}', {
|
|
status: 429, headers: { 'content-type': 'application/json', 'x-bulwark-proxy-error': 'rate_limited' },
|
|
}));
|
|
|
|
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', {}]);
|
|
|
|
expect(res).toMatchObject({ status: 429, ok: false, proxyError: 'rate_limited', body: 'Too many requests' });
|
|
});
|
|
|
|
it('streams a large answer to the iframe in chunks', async () => {
|
|
const encoder = new TextEncoder();
|
|
apiFetch.mockResolvedValue(new Response(new ReadableStream({
|
|
start(c) { c.enqueue(encoder.encode('data: one\n\n')); c.enqueue(encoder.encode('data: two\n\n')); c.close(); },
|
|
}), { status: 200, headers: { 'content-type': 'text/event-stream' } }));
|
|
const sent: Parameters<StreamSink['sendStream']>[0][] = [];
|
|
const sink: StreamSink = { sendStream: (m) => { sent.push(m); } };
|
|
|
|
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', { responseType: 'stream', streamId: 'abc123' }], { sink });
|
|
await vi.waitFor(() => expect(sent.at(-1)?.type).toBe('stream-end'));
|
|
|
|
expect(res).toMatchObject({ status: 200, ok: true });
|
|
const text = sent.filter((m) => m.type === 'stream-chunk').map((m) => new TextDecoder().decode((m as { chunk: ArrayBuffer }).chunk)).join('');
|
|
expect(text).toBe('data: one\n\ndata: two\n\n');
|
|
});
|
|
});
|