mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
The hook buses, the permission each hook needs and the docs were three separate lists that had drifted: 145 of 198 hooks were never fired and nothing noticed. `lib/plugin-hook-catalog.ts` now names each hook with its kind, permission, payload and when it fires; the permission check reads it, and a test fails for any catalogued hook without a call site. Groups still being wired in this branch are listed in that test. Also: - new before-hooks for archive, spam, read state, star, keywords, folder changes, RSVP, event import, tasks, file moves, settings, identities, filters and out-of-office, plus onComposerClose and onSyncStateChange - removed hooks that can never fire: the S/MIME group (S/MIME is a plugin now), onMobileViewSwitch, onSidebarAppChange and the registerShortcut pseudo-hook - smime:read no longer grants anything; manifests that name it still install, with a warning
145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import JSZip from 'jszip';
|
|
import {
|
|
checkPluginManifest,
|
|
compareVersions,
|
|
readPluginPackage,
|
|
MAX_ENTRY_BYTES,
|
|
MAX_ZIP_ENTRIES,
|
|
} from '../extension-package';
|
|
|
|
const BASE = {
|
|
id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd',
|
|
type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
|
|
};
|
|
|
|
async function zipOf(files: Record<string, string | Uint8Array>): Promise<Uint8Array> {
|
|
const zip = new JSZip();
|
|
for (const [name, data] of Object.entries(files)) zip.file(name, data);
|
|
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
|
}
|
|
|
|
describe('checkPluginManifest', () => {
|
|
it('accepts a normal manifest and keeps only checked fields', () => {
|
|
const { manifest, errors } = checkPluginManifest({ ...BASE, somethingElse: 1, locales: { de: { a: 'b' } } });
|
|
|
|
expect(errors).toEqual([]);
|
|
expect(manifest).toMatchObject({ id: 'demo-plugin', permissions: ['email:read'], locales: { de: { a: 'b' } } });
|
|
expect(manifest).not.toHaveProperty('somethingElse');
|
|
});
|
|
|
|
it('refuses permissions given as a string', () => {
|
|
// As a string, "crypto:full email:send" used to pass validation and then
|
|
// grant both through String.prototype.includes.
|
|
const { manifest, errors } = checkPluginManifest({ ...BASE, permissions: 'crypto:full email:send' });
|
|
|
|
expect(manifest).toBeNull();
|
|
expect(errors.join()).toMatch(/must be a list of strings/);
|
|
});
|
|
|
|
it('refuses unknown permissions', () => {
|
|
expect(checkPluginManifest({ ...BASE, permissions: ['email:read', 'root:all'] }).errors.join())
|
|
.toMatch(/Unknown permissions: root:all/);
|
|
});
|
|
|
|
it.each(['../index.js', '/index.js', 'a/../../x.js', 'index.css', 'a\\b.js'])('refuses entrypoint %s', (entrypoint) => {
|
|
expect(checkPluginManifest({ ...BASE, entrypoint }).manifest).toBeNull();
|
|
});
|
|
|
|
it('refuses a select setting whose options are not a list', () => {
|
|
const { errors } = checkPluginManifest({
|
|
...BASE,
|
|
settingsSchema: { mode: { type: 'select', label: 'Mode', default: 'a', options: 'a,b' } },
|
|
});
|
|
|
|
expect(errors.join()).toMatch(/settingsSchema\.mode\.options/);
|
|
});
|
|
|
|
it('refuses a number default outside min/max', () => {
|
|
const { errors } = checkPluginManifest({
|
|
...BASE,
|
|
settingsSchema: { size: { type: 'number', label: 'Size', default: 900, min: 16, max: 512 } },
|
|
});
|
|
|
|
expect(errors.join()).toMatch(/outside min\/max/);
|
|
});
|
|
|
|
it('drops invalid origins with a warning instead of storing them', () => {
|
|
const { manifest, warnings } = checkPluginManifest({
|
|
...BASE,
|
|
permissions: ['http:fetch'],
|
|
httpOrigins: ['https://api.example.com', 'https://*.com', 'http://plain.example'],
|
|
});
|
|
|
|
expect(manifest?.httpOrigins).toEqual(['https://api.example.com']);
|
|
expect(warnings.join()).toMatch(/\*\.com/);
|
|
});
|
|
|
|
it('refuses a plugin that needs a newer Bulwark', () => {
|
|
expect(checkPluginManifest({ ...BASE, minAppVersion: '9.0.0' }, { appVersion: '1.9.2' }).errors.join())
|
|
.toMatch(/Needs Bulwark 9\.0\.0/);
|
|
});
|
|
|
|
it('refuses a privileged plugin without crypto:full', () => {
|
|
expect(checkPluginManifest({ ...BASE, tier: 'privileged' }).manifest).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('readPluginPackage', () => {
|
|
it('reads manifest and code from a package in a sub folder', async () => {
|
|
const data = await zipOf({ 'demo/manifest.json': JSON.stringify(BASE), 'demo/index.js': 'module.exports = {}' });
|
|
|
|
const pkg = await readPluginPackage(data);
|
|
|
|
expect(pkg.code).toBe('module.exports = {}');
|
|
expect(pkg.manifest.id).toBe('demo-plugin');
|
|
});
|
|
|
|
it('stops inflating a file that unpacks past the limit (ZIP bomb)', async () => {
|
|
// ~25 KB compressed, more than the per-file cap once inflated.
|
|
const data = await zipOf({
|
|
'manifest.json': JSON.stringify(BASE),
|
|
'index.js': new Uint8Array(MAX_ENTRY_BYTES + 1024),
|
|
});
|
|
expect(data.byteLength).toBeLessThan(200 * 1024);
|
|
|
|
await expect(readPluginPackage(data)).rejects.toThrow(/larger than .* once unpacked/);
|
|
});
|
|
|
|
it('refuses a ZIP with too many entries', async () => {
|
|
const files: Record<string, string> = { 'manifest.json': JSON.stringify(BASE), 'index.js': '' };
|
|
for (let i = 0; i < MAX_ZIP_ENTRIES; i++) files[`f${i}.json`] = '{}';
|
|
|
|
await expect(readPluginPackage(await zipOf(files))).rejects.toThrow(/entries/);
|
|
});
|
|
|
|
it('refuses disallowed file types', async () => {
|
|
const data = await zipOf({ 'manifest.json': JSON.stringify(BASE), 'index.js': '', 'run.exe': 'MZ' });
|
|
|
|
await expect(readPluginPackage(data)).rejects.toThrow(/run\.exe/);
|
|
});
|
|
|
|
it('refuses a package whose id is not the expected one', async () => {
|
|
const data = await zipOf({ 'manifest.json': JSON.stringify(BASE), 'index.js': '' });
|
|
|
|
await expect(readPluginPackage(data, { expectedId: 'other' })).rejects.toThrow(/does not match/);
|
|
});
|
|
});
|
|
|
|
describe('compareVersions', () => {
|
|
it('compares numerically', () => {
|
|
expect(compareVersions('1.10.0', '1.9.9')).toBe(1);
|
|
expect(compareVersions('1.2.0', '1.2.0-beta.1')).toBe(0);
|
|
expect(compareVersions('0.9.0', '1.0.0')).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('deprecated permissions', () => {
|
|
it('drops smime:read with a warning instead of refusing the plugin', () => {
|
|
const { manifest, warnings } = checkPluginManifest({ ...BASE, permissions: ['email:read', 'smime:read'] });
|
|
|
|
expect(manifest?.permissions).toEqual(['email:read']);
|
|
expect(warnings.join()).toMatch(/smime:read/);
|
|
});
|
|
});
|