mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
143 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|