mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
139 lines
8.0 KiB
TypeScript
139 lines
8.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
|
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
|
import {
|
|
allCheckers, clearPluginCheckers, getChecker, registerCheckTarget, resetCheckers, MAX_DEBOUNCE_MS, MIN_DEBOUNCE_MS,
|
|
} from '../plugin-sandbox/checkers';
|
|
|
|
vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } }));
|
|
|
|
const { dispatchApiCall } = await import('../plugin-sandbox/host-api');
|
|
|
|
// api.composer.registerChecker and friends: compose:check only, from the
|
|
// background frame only, input checked, results routed to the composer.
|
|
|
|
function plugin(perms: string[], id = 'lt'): InstalledPlugin {
|
|
return {
|
|
id, name: 'LanguageTool', version: '1.0.0', author: 'test', description: '',
|
|
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
|
enabled: true, status: 'running', settings: {},
|
|
};
|
|
}
|
|
|
|
const CHECK = plugin(['compose:check']);
|
|
const READ_WRITE = plugin(['compose:read', 'compose:write']);
|
|
const call = (p: InstalledPlugin, method: string, args: unknown[], frame: 'background' | 'slot' = 'background') =>
|
|
dispatchApiCall(p, method, args, { frame });
|
|
const DATA = { id: 'lt', label: 'LanguageTool', kinds: ['spelling', 'grammar'] };
|
|
|
|
beforeEach(() => resetPluginRateLimits());
|
|
afterEach(() => resetCheckers());
|
|
|
|
describe('compose:check', () => {
|
|
it('is a high-risk permission, and compose:read/write do not grant checking while typing', async () => {
|
|
expect(HIGH_RISK_PERMISSIONS.has('compose:check')).toBe(true);
|
|
await expect(call(READ_WRITE, 'composer.registerChecker', [DATA])).rejects.toThrow('lacks permission "compose:check"');
|
|
await expect(call(plugin([]), 'composer.registerChecker', [{ ...DATA, mode: 'on-demand' }])).rejects.toThrow('lacks permission');
|
|
for (const method of ['composer.setAnnotations', 'composer.getCheckText', 'composer.recheck']) {
|
|
await expect(call(READ_WRITE, method, ['composer-1', 'lt'])).rejects.toThrow();
|
|
}
|
|
expect(allCheckers()).toHaveLength(0);
|
|
});
|
|
|
|
it('lets a plugin with compose:read check when the user asks, never while typing', async () => {
|
|
await call(READ_WRITE, 'composer.registerChecker', [{ ...DATA, mode: 'on-demand' }]);
|
|
expect(getChecker('lt', 'lt')).toMatchObject({ mode: 'on-demand' });
|
|
const target = { getCheckText: vi.fn(() => ({ revision: 'r1' })), setAnnotations: vi.fn(() => 1) };
|
|
const unregister = registerCheckTarget('composer-1', target);
|
|
expect(await call(READ_WRITE, 'composer.getCheckText', ['composer-1', 'lt'])).toEqual({ revision: 'r1' });
|
|
expect(await call(READ_WRITE, 'composer.setAnnotations', ['composer-1', 'lt', 'r1', []])).toBe(1);
|
|
unregister();
|
|
// Re-registering as a typing checker needs compose:check.
|
|
await expect(call(READ_WRITE, 'composer.registerChecker', [{ ...DATA, mode: 'typing' }])).rejects.toThrow('compose:check');
|
|
// A permission taken away stops the calls of a checker already registered.
|
|
await expect(call(plugin([]), 'composer.recheck', ['lt'])).rejects.toThrow('lacks permission');
|
|
});
|
|
|
|
it('registers a checker from the background frame, with defaults filled in', async () => {
|
|
await call(CHECK, 'composer.registerChecker', [DATA]);
|
|
expect(getChecker('lt', 'lt')).toMatchObject({ label: 'LanguageTool', kinds: ['spelling', 'grammar'], debounceMs: 800, mode: 'typing', warnBeforeSend: false, pluginName: 'LanguageTool' });
|
|
});
|
|
|
|
it('refuses a registration from a slot frame', async () => {
|
|
await expect(call(CHECK, 'composer.registerChecker', [DATA], 'slot')).rejects.toThrow('not from a slot');
|
|
expect(allCheckers()).toHaveLength(0);
|
|
});
|
|
|
|
it('checks what a plugin registers', async () => {
|
|
const bad = [
|
|
{ ...DATA, id: 'Bad Id' },
|
|
{ ...DATA, label: '' },
|
|
{ ...DATA, kinds: [] },
|
|
{ ...DATA, kinds: ['spelling', 'nonsense'] },
|
|
{ ...DATA, mode: 'always' },
|
|
{ ...DATA, languages: ['not a tag'] },
|
|
{ ...DATA, extra: true },
|
|
{ ...DATA, warnBeforeSend: 'yes' },
|
|
];
|
|
for (const data of bad) await expect(call(CHECK, 'composer.registerChecker', [data])).rejects.toThrow();
|
|
await call(CHECK, 'composer.registerChecker', [{ ...DATA, debounceMs: 5 }]);
|
|
expect(getChecker('lt', 'lt')!.debounceMs).toBe(MIN_DEBOUNCE_MS);
|
|
await call(CHECK, 'composer.registerChecker', [{ ...DATA, debounceMs: 1e9 }]);
|
|
expect(getChecker('lt', 'lt')!.debounceMs).toBe(MAX_DEBOUNCE_MS);
|
|
});
|
|
|
|
it('allows at most four checkers per plugin', async () => {
|
|
for (const id of ['a', 'b', 'c', 'd']) await call(CHECK, 'composer.registerChecker', [{ ...DATA, id }]);
|
|
await expect(call(CHECK, 'composer.registerChecker', [{ ...DATA, id: 'e' }])).rejects.toThrow('at most 4');
|
|
});
|
|
|
|
it('updates, rechecks and removes only its own checkers', async () => {
|
|
await call(CHECK, 'composer.registerChecker', [DATA]);
|
|
const epoch = getChecker('lt', 'lt')!.epoch;
|
|
await call(CHECK, 'composer.updateChecker', ['lt', { warnBeforeSend: true }]);
|
|
expect(getChecker('lt', 'lt')!.warnBeforeSend).toBe(true);
|
|
await expect(call(CHECK, 'composer.updateChecker', ['lt', { id: 'other' }])).rejects.toThrow("can't be changed");
|
|
await call(CHECK, 'composer.recheck', ['lt']);
|
|
expect(getChecker('lt', 'lt')!.epoch).toBe(epoch + 1);
|
|
const other = plugin(['compose:check'], 'other');
|
|
await expect(call(other, 'composer.recheck', ['lt'])).rejects.toThrow('no checker');
|
|
await expect(call(other, 'composer.unregisterChecker', ['lt'])).rejects.toThrow('no checker');
|
|
await call(CHECK, 'composer.unregisterChecker', ['lt']);
|
|
expect(allCheckers()).toHaveLength(0);
|
|
});
|
|
|
|
it('routes on-demand text and results to the composer it names', async () => {
|
|
await call(CHECK, 'composer.registerChecker', [{ ...DATA, mode: 'on-demand' }]);
|
|
const target = { getCheckText: vi.fn(() => ({ revision: 'r1' })), setAnnotations: vi.fn(() => 2) };
|
|
const unregister = registerCheckTarget('composer-1', target);
|
|
expect(await call(CHECK, 'composer.getCheckText', ['composer-1', 'lt'])).toEqual({ revision: 'r1' });
|
|
expect(await call(CHECK, 'composer.setAnnotations', ['composer-1', 'lt', 'r1', []])).toBe(2);
|
|
expect(target.setAnnotations).toHaveBeenCalledWith(expect.objectContaining({ id: 'lt' }), 'r1', []);
|
|
await expect(call(CHECK, 'composer.setAnnotations', ['composer-2', 'lt', 'r1', []])).rejects.toThrow('not open');
|
|
await expect(call(CHECK, 'composer.setAnnotations', ['composer-1', 'lt', 'r1', 'x'])).rejects.toThrow('must be a list');
|
|
target.getCheckText.mockReturnValueOnce(null as never);
|
|
await expect(call(CHECK, 'composer.getCheckText', ['composer-1', 'lt'])).rejects.toThrow('checking_off');
|
|
unregister();
|
|
await expect(call(CHECK, 'composer.getCheckText', ['composer-1', 'lt'])).rejects.toThrow('not open');
|
|
});
|
|
|
|
it('lets only a plugin that can sign say an Options entry signs or encrypts', async () => {
|
|
const entry = [{ id: 'secure', label: 'Encrypt', checked: true, protects: 'encrypt' }];
|
|
await expect(call(plugin(['ui:composer-toolbar'], 'poll'), 'ui.setActions', ['composer-options-menu', entry]))
|
|
.rejects.toThrow('protects needs');
|
|
await call(plugin(['ui:composer-toolbar', 'crypto:full'], 'smime'), 'ui.setActions', ['composer-options-menu', entry]);
|
|
const { actionsFor, clearPluginActions } = await import('../plugin-sandbox/actions');
|
|
expect(actionsFor('composer-options-menu')).toEqual([expect.objectContaining({ pluginId: 'smime', protects: 'encrypt' })]);
|
|
await expect(call(plugin(['ui:composer-toolbar', 'crypto:full'], 'smime'), 'ui.setActions', ['composer-options-menu', [{ ...entry[0], protects: 'both' }]]))
|
|
.rejects.toThrow('must be "sign" or "encrypt"');
|
|
clearPluginActions('smime');
|
|
});
|
|
|
|
it('forgets a plugin\'s checkers when it is turned off', async () => {
|
|
await call(CHECK, 'composer.registerChecker', [DATA]);
|
|
clearPluginCheckers('lt');
|
|
expect(allCheckers()).toHaveLength(0);
|
|
});
|
|
});
|