mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
218 lines
11 KiB
TypeScript
218 lines
11 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() },
|
|
}));
|
|
|
|
const overview = vi.fn();
|
|
vi.mock('../plugin-sandbox/connect-dialog', () => ({
|
|
credentialOverview: (...a: unknown[]) => overview(...a),
|
|
}));
|
|
|
|
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();
|
|
overview.mockReset();
|
|
overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: [] });
|
|
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();
|
|
});
|
|
|
|
// Do not disturb: no system notification from the page, only the quiet toast.
|
|
it('shows a toast instead of a system notification with Do not disturb on', async () => {
|
|
const { useSettingsStore } = await import('@/stores/settings-store');
|
|
useSettingsStore.setState({ doNotDisturb: true });
|
|
try {
|
|
expect(await call(RSS, { title: 'Synced' })).toBe('toast');
|
|
expect(FakeNotification.shown).toHaveLength(0);
|
|
expect(toastInfo).toHaveBeenCalledWith('Synced', { message: 'RSS Reader' });
|
|
} finally {
|
|
useSettingsStore.setState({ doNotDisturb: false });
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('api.notifications.show with a link', () => {
|
|
const PAPERLESS = { ...plugin('paperless', ['ui:notifications', 'http:proxy']), name: 'Paperless-ngx' };
|
|
const link = (url: string) => ({ title: 'project-notes is in Paperless now', action: { label: 'Open in Paperless', url } });
|
|
const entries = async () => (await import('@/stores/notification-center-store')).useNotificationCenterStore.getState().entries;
|
|
|
|
beforeEach(async () => {
|
|
FakeNotification.permission = 'default';
|
|
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
|
|
useNotificationCenterStore.getState().clear();
|
|
});
|
|
|
|
it('puts a button on the toast that opens the link in a new tab without a handle on the app', async () => {
|
|
overview.mockResolvedValue({ credentials: [], admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
|
|
const open = vi.fn();
|
|
vi.stubGlobal('open', open);
|
|
expect(await call(PAPERLESS, link('https://paperless.example.com/documents/12/details'))).toBe('toast');
|
|
const options = toastInfo.mock.calls[0][1] as { action: { label: string; onClick: () => void } };
|
|
expect(options.action.label).toBe('Open in Paperless');
|
|
options.action.onClick();
|
|
expect(open).toHaveBeenCalledWith('https://paperless.example.com/documents/12/details', '_blank', 'noopener,noreferrer');
|
|
expect((await entries())[0].action).toEqual({ label: 'Open in Paperless', url: 'https://paperless.example.com/documents/12/details' });
|
|
});
|
|
|
|
it('accepts the server of a credential the user connected, and the manifest httpOrigins', async () => {
|
|
overview.mockResolvedValue({
|
|
credentials: [{ pluginId: 'paperless', credentialId: 'personal', label: 'P', origin: 'https://docs.alice.example', connected: true, updatedAt: '' }],
|
|
admin: [], adminOrigins: [],
|
|
});
|
|
await call(PAPERLESS, link('https://docs.alice.example/documents/?title__icontains=x'));
|
|
await call({ ...plugin('feeds'), httpOrigins: ['https://feeds.example.org'] }, link('https://feeds.example.org/item/1'));
|
|
expect((await entries()).map((e) => e.action?.url)).toEqual([
|
|
'https://feeds.example.org/item/1',
|
|
'https://docs.alice.example/documents/?title__icontains=x',
|
|
]);
|
|
});
|
|
|
|
it('refuses a link to a server the plugin does not talk to, and shows nothing', async () => {
|
|
overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: ['https://paperless.example.com'] });
|
|
await expect(call(PAPERLESS, link('https://paperless.example.com.evil.example/login'))).rejects.toThrow(/action.url must be on a server the plugin talks to/);
|
|
await expect(call({ ...plugin('feeds'), httpOrigins: ['https://feeds.example.org'] }, link('https://phish.example/'))).rejects.toThrow(/action.url must be on a server/);
|
|
expect(toastInfo).not.toHaveBeenCalled();
|
|
expect(await entries()).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses links that are not plain http(s)', async () => {
|
|
overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: ['https://paperless.example.com'] });
|
|
await expect(call(PAPERLESS, link('javascript:alert(1)'))).rejects.toThrow(/http or https/);
|
|
await expect(call(PAPERLESS, link('/documents/1'))).rejects.toThrow(/absolute URL/);
|
|
await expect(call(PAPERLESS, link('https://u:p@paperless.example.com/'))).rejects.toThrow(/user name or password/);
|
|
await expect(call(PAPERLESS, { title: 'x', action: { label: '', url: 'https://paperless.example.com/' } })).rejects.toThrow(/label/);
|
|
await expect(call(PAPERLESS, { title: 'x', action: { label: 'ok', url: 'https://paperless.example.com/', target: '_self' } })).rejects.toThrow(/unknown property "target"/);
|
|
expect(toastInfo).not.toHaveBeenCalled();
|
|
});
|
|
});
|