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

95 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
const fetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', async (orig) => ({
...(await orig<typeof import('@/lib/browser-navigation')>()),
apiFetch: (...args: unknown[]) => fetchMock(...args),
}));
import { dispatchApiCall, PLUGIN_STORAGE_QUOTA_BYTES, safeDownloadName, doGetPublicKeyFromWKD } from '../plugin-sandbox/host-api';
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'limits', name: 'Limits', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
...overrides,
};
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('plugin storage quota', () => {
it('refuses a write that would take the plugin past its share', async () => {
// Without a cap one plugin could fill localStorage and break sign-in.
const half = 'x'.repeat(PLUGIN_STORAGE_QUOTA_BYTES / 2);
await dispatchApiCall(plugin(), 'storage.set', ['a', half]);
await expect(dispatchApiCall(plugin(), 'storage.set', ['b', half])).rejects.toThrow(/storage is full/);
});
it('lets a plugin overwrite a key within its share', async () => {
const big = 'x'.repeat(PLUGIN_STORAGE_QUOTA_BYTES * 0.8);
await dispatchApiCall(plugin(), 'storage.set', ['a', big]);
await expect(dispatchApiCall(plugin(), 'storage.set', ['a', big])).resolves.toBeUndefined();
});
});
describe('ui.downloadFile', () => {
const p = plugin({ permissions: ['ui:download-file'], grantedPermissions: ['ui:download-file'] });
it.each([
['invoice.pdf.exe', 'application/pdf'],
['page.html', 'text/plain'],
['report.txt', 'text/html'],
['logo.svg', 'image/svg+xml'],
])('refuses %s (%s)', async (filename, contentType) => {
await expect(dispatchApiCall(p, 'ui.downloadFile', [{ content: 'x', filename, contentType }]))
.rejects.toThrow(/can't be downloaded/);
});
it('strips paths and direction tricks from the name', () => {
expect(safeDownloadName('../../etc/passwd')).toBe('_.._etc_passwd');
expect(safeDownloadName('invoice‮fdp.exe')).toBe('invoicefdp.exe');
expect(safeDownloadName('')).toBe('download');
});
});
describe('toasts', () => {
it('caps how many toasts a plugin shows in a burst', async () => {
for (let i = 0; i < 20; i++) await dispatchApiCall(plugin(), 'toast.info', [`n${i}`]);
expect(toast.info).toHaveBeenCalledTimes(5);
});
it('shortens very long messages', async () => {
await dispatchApiCall(plugin({ id: 'long' }), 'toast.error', ['x'.repeat(5000)]);
expect((toast.error.mock.calls[0][0] as string).length).toBeLessThanOrEqual(300);
});
});
describe('admin config writes', () => {
it('tells the plugin when the server refused the write', async () => {
// A 403 used to look like success.
fetchMock.mockResolvedValue(new Response('{}', { status: 403 }));
const p = plugin({ permissions: ['admin:config'], grantedPermissions: ['admin:config'] });
await expect(dispatchApiCall(p, 'admin.setConfig', ['k', 'v'])).rejects.toThrow(/only an administrator/);
});
});
describe('WKD lookup', () => {
it('refuses an address whose domain is not a host name', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
expect(await doGetPublicKeyFromWKD('a@evil.example/x?')).toMatchObject({ status: 'ERROR' });
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});