Files
webmail/lib/__tests__/plugin-hooks.test.ts

463 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult, setHookTimeoutListener } from '../plugin-hooks';
import type { CalendarEventFormView } from '../plugin-types';
beforeEach(() => {
pluginErrorTracker.resetAll();
clearAllHooks();
});
describe('HookBus', () => {
describe('register / size / dispose', () => {
it('registers a handler', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
expect(bus.size).toBe(1);
});
it('dispose removes the handler', () => {
const bus = new HookBus();
const d = bus.register('p1', vi.fn());
d.dispose();
expect(bus.size).toBe(0);
});
it('registering multiple handlers', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
bus.register('p2', vi.fn());
expect(bus.size).toBe(2);
});
it('removePlugin removes all handlers for that plugin', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
bus.register('p1', vi.fn());
bus.register('p2', vi.fn());
bus.removePlugin('p1');
expect(bus.size).toBe(1);
});
it('clear removes all handlers', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
bus.register('p2', vi.fn());
bus.clear();
expect(bus.size).toBe(0);
});
});
describe('emit (observer)', () => {
it('calls all handlers with args', async () => {
const bus = new HookBus<(x: number) => void>();
const fn1 = vi.fn();
const fn2 = vi.fn();
bus.register('p1', fn1);
bus.register('p2', fn2);
await bus.emit(42);
expect(fn1).toHaveBeenCalledWith(42);
expect(fn2).toHaveBeenCalledWith(42);
});
it('calls handlers in order', async () => {
const bus = new HookBus<() => void>();
const order: number[] = [];
bus.register('p1', () => order.push(200), 200);
bus.register('p2', () => order.push(50), 50);
bus.register('p3', () => order.push(100), 100);
await bus.emit();
expect(order).toEqual([50, 100, 200]);
});
it('catches handler errors and records them', async () => {
const bus = new HookBus<() => void>();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
bus.register('p1', () => { throw new Error('fail'); });
await bus.emit();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('skips disabled plugins', async () => {
const bus = new HookBus<() => void>();
const fn = vi.fn();
bus.register('p1', fn);
// Manually trigger circuit breaker
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
for (let i = 0; i < 3; i++) {
pluginErrorTracker.record('p1', new Error('test'));
}
consoleSpy.mockRestore();
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
await bus.emit();
expect(fn).not.toHaveBeenCalled();
});
});
describe('emitSync', () => {
it('calls handlers synchronously', () => {
const bus = new HookBus<(x: string) => void>();
const fn = vi.fn();
bus.register('p1', fn);
bus.emitSync('hello');
expect(fn).toHaveBeenCalledWith('hello');
});
it('catches errors without throwing', () => {
const bus = new HookBus<() => void>();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
bus.register('p1', () => { throw new Error('boom'); });
expect(() => bus.emitSync()).not.toThrow();
consoleSpy.mockRestore();
});
});
describe('intercept', () => {
it('returns true when all handlers pass', async () => {
const bus = new HookBus<() => boolean>();
bus.register('p1', () => true);
bus.register('p2', () => true);
const result = await bus.intercept();
expect(result).toBe(true);
});
it('returns false when any handler returns false', async () => {
const bus = new HookBus<() => boolean>();
bus.register('p1', () => true);
bus.register('p2', () => false);
const result = await bus.intercept();
expect(result).toBe(false);
});
it('stops early on false (short-circuits)', async () => {
const bus = new HookBus<() => boolean>();
const fn3 = vi.fn(() => true);
bus.register('p1', () => true, 10);
bus.register('p2', () => false, 20);
bus.register('p3', fn3, 30);
await bus.intercept();
expect(fn3).not.toHaveBeenCalled();
});
it('returns true when no handlers registered', async () => {
const bus = new HookBus<() => boolean>();
expect(await bus.intercept()).toBe(true);
});
});
describe('transform', () => {
it('chains values through handlers', async () => {
const bus = new HookBus<(val: number) => number>();
bus.register('p1', (val: number) => val * 2);
bus.register('p2', (val: number) => val + 1);
const result = await bus.transform(5);
expect(result).toBe(11); // (5 * 2) + 1
});
it('returns initial value when no handlers', async () => {
const bus = new HookBus<(val: string) => string>();
const result = await bus.transform('hello');
expect(result).toBe('hello');
});
it('skips handler that returns undefined', async () => {
const bus = new HookBus<(val: number) => number | undefined>();
bus.register('p1', () => undefined);
bus.register('p2', (val: number) => val + 10);
const result = await bus.transform(5);
expect(result).toBe(15);
});
it('drops a handler that overruns the bus timeout', async () => {
vi.useFakeTimers();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const bus = new HookBus<(val: number) => Promise<number>>(50);
bus.register('slow', () => new Promise<number>(resolve => setTimeout(() => resolve(99), 500)));
const pending = bus.transform(1);
await vi.advanceTimersByTimeAsync(60);
expect(await pending).toBe(1); // initial value kept, handler timed out
} finally {
consoleSpy.mockRestore();
vi.useRealTimers();
}
});
it('honours a per-bus timeout longer than the 5s default', async () => {
vi.useFakeTimers();
try {
const bus = new HookBus<(val: number) => Promise<number>>(120_000);
bus.register('slow', () => new Promise<number>(resolve => setTimeout(() => resolve(99), 30_000)));
const pending = bus.transform(1);
await vi.advanceTimersByTimeAsync(30_100);
expect(await pending).toBe(99);
} finally {
vi.useRealTimers();
}
});
});
});
// #1207: a send-takeover handler that failed or was cancelled let the host
// delete the draft, and one that threw let the host send the message unsigned.
describe('onComposeSend takeOver', () => {
it('reports a send the plugin made itself', async () => {
emailHooks.onComposeSend.register('smime', () => false);
expect(await emailHooks.onComposeSend.takeOver({})).toBe('handled');
});
it('reports an abort the plugin asked for', async () => {
emailHooks.onComposeSend.register('smime', () => ({ abort: true }));
expect(await emailHooks.onComposeSend.takeOver({})).toBe('aborted');
});
it('leaves the send to the host when no plugin takes it', async () => {
emailHooks.onComposeSend.register('other', () => undefined);
emailHooks.onComposeSend.register('guard', () => true);
expect(await emailHooks.onComposeSend.takeOver({})).toBe('default');
});
it('fails closed when a handler throws', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const later = vi.fn(() => false);
emailHooks.onComposeSend.register('smime', () => { throw new Error('no key'); }, 10);
emailHooks.onComposeSend.register('later', later, 20);
expect(await emailHooks.onComposeSend.takeOver({})).toBe('failed');
expect(later).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('waits for a slow passphrase prompt and fails closed after the budget', async () => {
vi.useFakeTimers();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
emailHooks.onComposeSend.register('smime', () => new Promise(resolve => setTimeout(() => resolve(false), 5 * 60_000)));
const slow = emailHooks.onComposeSend.takeOver({});
await vi.advanceTimersByTimeAsync(5 * 60_000 + 100);
expect(await slow).toBe('handled');
emailHooks.onComposeSend.clear();
emailHooks.onComposeSend.register('stuck', () => new Promise(() => {}));
const stuck = emailHooks.onComposeSend.takeOver({});
await vi.advanceTimersByTimeAsync(10 * 60_000 + 100);
expect(await stuck).toBe('failed');
} finally {
consoleSpy.mockRestore();
vi.useRealTimers();
}
});
});
describe('onBeforeBlobUpload offload budget', () => {
it('gives the hook room for a real upload, not the 5s default', async () => {
vi.useFakeTimers();
try {
emailHooks.onBeforeBlobUpload.register('offloader', () =>
new Promise(resolve => setTimeout(() => resolve({
externalAttachment: true, html: '<a href="https://drop.example/x">x</a>', text: 'https://drop.example/x',
}), 20_000)),
);
const pending = emailHooks.onBeforeBlobUpload.transform<unknown>('file-id');
await vi.advanceTimersByTimeAsync(20_100);
expect(isExternalAttachmentResult(await pending)).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
describe('isExternalAttachmentResult', () => {
const valid = { externalAttachment: true, html: '<a href="https://x/y">y</a>', text: 'https://x/y' };
it('accepts a well-formed result', () => {
expect(isExternalAttachmentResult(valid)).toBe(true);
});
it('accepts an optional fileId', () => {
expect(isExternalAttachmentResult({ ...valid, fileId: 'abc' })).toBe(true);
});
it.each([
['a plain file id', 'some-uuid'],
['null', null],
['undefined', undefined],
['missing marker', { html: 'x', text: 'x' }],
['falsy marker', { ...valid, externalAttachment: false }],
['missing text', { externalAttachment: true, html: 'x' }],
['non-string html', { externalAttachment: true, html: 1, text: 'x' }],
['non-string fileId', { ...valid, fileId: 42 }],
])('rejects %s', (_label, value) => {
expect(isExternalAttachmentResult(value)).toBe(false);
});
});
describe('PluginErrorTracker', () => {
it('is not disabled initially', () => {
expect(pluginErrorTracker.isDisabled('some-plugin')).toBe(false);
});
it('disables after threshold errors', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
pluginErrorTracker.record('p1', new Error('1'));
pluginErrorTracker.record('p1', new Error('2'));
expect(pluginErrorTracker.isDisabled('p1')).toBe(false);
pluginErrorTracker.record('p1', new Error('3'));
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
consoleSpy.mockRestore();
});
it('calls auto-disable callback', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const cb = vi.fn();
pluginErrorTracker.setAutoDisableCallback(cb);
for (let i = 0; i < 3; i++) {
pluginErrorTracker.record('p2', new Error(`err-${i}`));
}
expect(cb).toHaveBeenCalledWith('p2', expect.any(Error));
consoleSpy.mockRestore();
pluginErrorTracker.setAutoDisableCallback(() => {});
});
it('reset re-enables a plugin', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
for (let i = 0; i < 3; i++) {
pluginErrorTracker.record('p3', new Error(`err-${i}`));
}
expect(pluginErrorTracker.isDisabled('p3')).toBe(true);
pluginErrorTracker.reset('p3');
expect(pluginErrorTracker.isDisabled('p3')).toBe(false);
consoleSpy.mockRestore();
});
});
describe('Hook domain instances', () => {
it('emailHooks has expected buses', () => {
expect(emailHooks.onEmailOpen).toBeInstanceOf(HookBus);
expect(emailHooks.onBeforeEmailSend).toBeInstanceOf(HookBus);
expect(emailHooks.onAfterEmailDelete).toBeInstanceOf(HookBus);
expect(emailHooks.onNewEmailReceived).toBeInstanceOf(HookBus);
});
it('calendarHooks has expected buses', () => {
expect(calendarHooks.onCalendarEventOpen).toBeInstanceOf(HookBus);
expect(calendarHooks.onBeforeEventCreate).toBeInstanceOf(HookBus);
expect(calendarHooks.onEventRsvp).toBeInstanceOf(HookBus);
});
it('onCalendarEventFormSave pipes a returned patch through transform with the form view', async () => {
const { calendarFormHooks } = await import('../plugin-hooks');
const views: CalendarEventFormView[] = [];
const disposable = calendarFormHooks.onCalendarEventFormSave.register(
'test-p',
async (_patch, view) => {
views.push(view);
return { virtualLocation: 'https://meet.example.com/rooms/x?role=host' };
},
);
const patch = await calendarFormHooks.onCalendarEventFormSave.transform(
{},
{
title: 'Team Sync',
description: '',
start: '2026-10-09T10:00:00',
end: '2026-10-09T11:00:00',
isAllDay: false,
location: '',
virtualLocation: '',
calendarId: 'cal-1',
uid: 'uid-1',
attendees: ['a@sunbeam.pt'],
},
);
expect(patch).toEqual({ virtualLocation: 'https://meet.example.com/rooms/x?role=host' });
expect(views[0]).toMatchObject({ title: 'Team Sync', uid: 'uid-1', attendees: ['a@sunbeam.pt'] });
disposable.dispose();
});
it('onCalendarEventFormSave keeps the form unchanged when handlers return undefined', async () => {
const { calendarFormHooks } = await import('../plugin-hooks');
const disposable = calendarFormHooks.onCalendarEventFormSave.register('test-p', async () => undefined);
const patch = await calendarFormHooks.onCalendarEventFormSave.transform(
{ virtualLocation: 'keep-me' },
{
title: 'T', description: '', start: '', end: '', isAllDay: false,
location: '', virtualLocation: '', calendarId: 'cal-1', attendees: [],
},
);
expect(patch).toEqual({ virtualLocation: 'keep-me' });
disposable.dispose();
});
});
describe('removeAllPluginHooks', () => {
it('removes handlers from all buses for a plugin', () => {
emailHooks.onEmailOpen.register('test-p', vi.fn());
calendarHooks.onCalendarEventOpen.register('test-p', vi.fn());
emailHooks.onEmailOpen.register('other-p', vi.fn());
removeAllPluginHooks('test-p');
expect(emailHooks.onEmailOpen.size).toBe(1); // other-p remains
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
});
});
describe('clearAllHooks', () => {
it('removes all handlers from all buses', () => {
emailHooks.onEmailOpen.register('p1', vi.fn());
calendarHooks.onCalendarEventOpen.register('p2', vi.fn());
clearAllHooks();
expect(emailHooks.onEmailOpen.size).toBe(0);
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
});
});
describe('onBeforeEmailSend fails closed', () => {
it('does not send when the plugin never answers', async () => {
// A plugin that shows a confirm and waits for the user used to let the
// mail go out after 60 s, with the dialog still on screen.
vi.useFakeTimers();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
emailHooks.onBeforeEmailSend.register('guard', () => new Promise(() => {}));
const result = emailHooks.onBeforeEmailSend.intercept({});
await vi.advanceTimersByTimeAsync(10 * 60_000 + 100);
expect(await result).toBe(false);
} finally {
consoleSpy.mockRestore();
vi.useRealTimers();
}
});
it('does not send when the plugin throws', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); });
expect(await emailHooks.onBeforeEmailSend.intercept({})).toBe(false);
consoleSpy.mockRestore();
});
it('closes the dialogs of a plugin whose hook ran out of time', async () => {
vi.useFakeTimers();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const onTimeout = vi.fn();
setHookTimeoutListener(onTimeout);
try {
emailHooks.onBeforeReply.register('slow', () => new Promise(() => {}));
const result = emailHooks.onBeforeReply.intercept({});
await vi.advanceTimersByTimeAsync(60_000 + 100);
expect(await result).toBe(true);
expect(onTimeout).toHaveBeenCalledWith('slow');
} finally {
setHookTimeoutListener(null);
consoleSpy.mockRestore();
vi.useRealTimers();
}
});
});