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

143 lines
6.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import { ALL_PERMISSIONS } from '../plugin-types';
import { toastHooks } from '../plugin-hooks';
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
const toastInfo = vi.fn();
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: (...a: unknown[]) => toastInfo(...a), warning: vi.fn() },
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(id: string, perms = ['ui:notifications']): InstalledPlugin {
return {
id, name: id === 'rss' ? 'RSS Reader' : 'Other', version: '1.0.0', author: 'test', description: '',
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const RSS = plugin('rss');
const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'notifications.show', args);
class FakeNotification {
static permission: NotificationPermission = 'granted';
static shown: FakeNotification[] = [];
onclick: (() => void) | null = null;
close = vi.fn();
constructor(public title: string, public options: NotificationOptions) {
FakeNotification.shown.push(this);
}
}
beforeEach(() => {
resetPluginRateLimits();
toastInfo.mockClear();
FakeNotification.shown = [];
FakeNotification.permission = 'granted';
vi.stubGlobal('Notification', FakeNotification);
});
afterEach(() => {
vi.unstubAllGlobals();
toastHooks.onNotificationClick.removePlugin('rss');
toastHooks.onNotificationClick.removePlugin('other');
});
describe('api.notifications.show', () => {
it('shows a system notification naming the plugin, with a tag scoped to it', async () => {
expect(await call(RSS, { title: '3 new posts', body: 'From Hacker News', tag: 'feed:hn' })).toBe('system');
const [n] = FakeNotification.shown;
expect(n.title).toBe('3 new posts');
expect(n.options.body).toBe('From Hacker News\nRSS Reader');
expect(n.options.tag).toBe('plugin:rss:feed:hn');
expect(toastInfo).not.toHaveBeenCalled();
});
it('falls back to a toast without notification permission', async () => {
FakeNotification.permission = 'default';
expect(await call(RSS, { title: 'Synced' })).toBe('toast');
expect(FakeNotification.shown).toHaveLength(0);
expect(toastInfo).toHaveBeenCalledWith('Synced', { message: 'RSS Reader' });
});
it('falls back to a toast when the browser refuses page notifications', async () => {
vi.stubGlobal('Notification', class extends FakeNotification {
constructor(title: string, options: NotificationOptions) {
super(title, options);
throw new TypeError('Illegal constructor');
}
});
expect(await call(RSS, { title: 'Synced', body: 'All feeds' })).toBe('toast');
expect(toastInfo).toHaveBeenCalledWith('Synced', { message: 'All feeds (RSS Reader)' });
});
it('tells only the sending plugin about a click', async () => {
const mine = vi.fn();
const theirs = vi.fn();
toastHooks.onNotificationClick.register('rss', mine);
toastHooks.onNotificationClick.register('other', theirs);
await call(RSS, { title: 'Hi', tag: 'x' });
FakeNotification.shown[0].onclick?.();
await Promise.resolve();
expect(mine).toHaveBeenCalledWith({ tag: 'x' });
expect(theirs).not.toHaveBeenCalled();
expect(FakeNotification.shown[0].close).toHaveBeenCalled();
});
it('allows five a minute per plugin', async () => {
for (let i = 0; i < 5; i++) await call(RSS, { title: `n${i}` });
await expect(call(RSS, { title: 'too many' })).rejects.toThrow(/at most 5 notifications a minute/);
expect(await call(plugin('other'), { title: 'mine' })).toBe('system');
expect(FakeNotification.shown).toHaveLength(6);
});
it('caps title and body, refuses odd tags and needs ui:notifications', async () => {
await expect(call(RSS, { title: 'x'.repeat(121) })).rejects.toThrow(/title must be 1-120 characters/);
await expect(call(RSS, { title: '' })).rejects.toThrow(/title must be 1-120/);
await expect(call(RSS, { title: 'ok', body: 'x'.repeat(301) })).rejects.toThrow(/body must be at most 300/);
await expect(call(RSS, { title: 'ok', tag: 'has space' })).rejects.toThrow(/tag must be/);
await expect(call(RSS, { title: 'ok', icon: 'https://evil.example/x.png' })).rejects.toThrow(/unknown property "icon"/);
await expect(call(plugin('rss', []), { title: 'ok' })).rejects.toThrow(/lacks permission "ui:notifications"/);
expect(FakeNotification.shown).toHaveLength(0);
expect(ALL_PERMISSIONS).toContain('ui:notifications');
expect(HIGH_RISK_PERMISSIONS.has('ui:notifications')).toBe(false);
});
it('keeps a notification to one line', async () => {
await call(RSS, { title: 'Line 1\nLine 2', body: 'a\r\nb' });
expect(FakeNotification.shown[0].title).toBe('Line 1 Line 2');
expect(FakeNotification.shown[0].options.body).toBe('a b\nRSS Reader');
});
});
describe('api.notifications.show in the notification panel', () => {
beforeEach(async () => {
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
useNotificationCenterStore.getState().clear();
});
const entries = async () => (await import('@/stores/notification-center-store')).useNotificationCenterStore.getState().entries;
it('lists each one with the plugin and its tag, shown as system notification or toast', async () => {
await call(RSS, { title: '3 new posts', body: 'From Hacker News', tag: 'feed:hn' });
FakeNotification.permission = 'default';
await call(RSS, { title: 'Synced' });
expect((await entries()).map((e) => [e.title, e.body, e.pluginId, e.pluginName, e.tag])).toEqual([
['Synced', '', 'rss', 'RSS Reader', undefined],
['3 new posts', 'From Hacker News', 'rss', 'RSS Reader', 'feed:hn'],
]);
});
it('lists nothing the plugin was refused', async () => {
await expect(call(RSS, { title: 'ok', tag: 'has space' })).rejects.toThrow();
for (let i = 0; i < 5; i++) await call(RSS, { title: `n${i}` });
await expect(call(RSS, { title: 'too many' })).rejects.toThrow();
expect((await entries()).map((e) => e.title)).not.toContain('too many');
expect(await entries()).toHaveLength(5);
});
});