Plugin & Theme System: - Add plugin type definitions, permissions (30+), and validation constants - Add IndexedDB storage layer for plugin code, theme CSS, and previews - Add theme CSS sanitization, injection, and safety validation - Add HookBus event system with 130+ hooks across 20 domains - Add plugin ZIP extraction and manifest validation with JS security checks - Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating - Add plugin loader with blob URL dynamic import and auto-disable circuit breaker - Add 3 built-in themes (Nord, Catppuccin, Solarized) - Add Zustand plugin store with install/uninstall/enable/disable lifecycle - Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components - Add plugins and themes settings UI panels - Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu - Extend theme store with custom theme installation and activation Admin Dashboard: - Add admin authentication with scrypt password hashing and AES-256-GCM sessions - Add rate-limited login (5 attempts/15min per IP) - Add config manager with admin override > env var > default priority - Add settings policy system with feature gates and per-setting restrictions - Add audit logging with rotation - Add admin API routes (login, logout, config, policy, audit, password change) - Add admin UI pages (login, dashboard, config, policy, audit) - Add policy store for client-side feature gate enforcement - Wire admin password initialization into server instrumentation Tests: - Add 139 tests across 10 test files covering all plugin/theme modules
151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
import { clearAllHooks } from '../plugin-hooks';
|
|
|
|
function makePlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
|
return {
|
|
id: 'test-plugin',
|
|
name: 'Test Plugin',
|
|
version: '1.0.0',
|
|
author: 'Test',
|
|
description: '',
|
|
type: 'ui-extension',
|
|
entrypoint: 'index.js',
|
|
permissions: [],
|
|
enabled: true,
|
|
status: 'running',
|
|
settings: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearAllHooks();
|
|
localStorage.clear();
|
|
setSlotRegistrationBridge(null);
|
|
});
|
|
|
|
describe('createPluginAPI', () => {
|
|
it('exposes plugin info', () => {
|
|
const plugin = makePlugin();
|
|
const api = createPluginAPI(plugin);
|
|
expect(api.plugin.id).toBe('test-plugin');
|
|
expect(api.plugin.version).toBe('1.0.0');
|
|
});
|
|
|
|
it('returns a frozen copy of settings', () => {
|
|
const plugin = makePlugin({ settings: { key: 'val' } });
|
|
const api = createPluginAPI(plugin);
|
|
expect(api.plugin.settings).toEqual({ key: 'val' });
|
|
});
|
|
});
|
|
|
|
describe('plugin storage (scoped localStorage)', () => {
|
|
it('set and get a value', () => {
|
|
const api = createPluginAPI(makePlugin());
|
|
api.storage.set('foo', 42);
|
|
expect(api.storage.get('foo')).toBe(42);
|
|
});
|
|
|
|
it('scopes to plugin id', () => {
|
|
const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
|
|
const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
|
|
api1.storage.set('key', 'a');
|
|
api2.storage.set('key', 'b');
|
|
expect(api1.storage.get('key')).toBe('a');
|
|
expect(api2.storage.get('key')).toBe('b');
|
|
});
|
|
|
|
it('remove deletes a value', () => {
|
|
const api = createPluginAPI(makePlugin());
|
|
api.storage.set('x', 10);
|
|
api.storage.remove('x');
|
|
expect(api.storage.get('x')).toBeNull();
|
|
});
|
|
|
|
it('keys lists only plugin-scoped keys', () => {
|
|
const api = createPluginAPI(makePlugin({ id: 'kp' }));
|
|
api.storage.set('a', 1);
|
|
api.storage.set('b', 2);
|
|
localStorage.setItem('unrelated', 'val');
|
|
expect(api.storage.keys()).toContain('a');
|
|
expect(api.storage.keys()).toContain('b');
|
|
expect(api.storage.keys()).not.toContain('unrelated');
|
|
});
|
|
});
|
|
|
|
describe('plugin logger', () => {
|
|
it('prefixes log messages with plugin id', () => {
|
|
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
const api = createPluginAPI(makePlugin({ id: 'log-test' }));
|
|
api.log.info('hello');
|
|
expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
|
|
infoSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('hooks permission gating', () => {
|
|
it('returns no-op disposable without permission', () => {
|
|
const plugin = makePlugin({ permissions: [] }); // no email:read
|
|
const api = createPluginAPI(plugin);
|
|
const d = api.hooks.onEmailOpen(vi.fn());
|
|
expect(d).toBeDefined();
|
|
expect(d.dispose).toBeInstanceOf(Function);
|
|
});
|
|
|
|
it('registers handler when permission is granted', () => {
|
|
const plugin = makePlugin({ permissions: ['email:read'] });
|
|
const api = createPluginAPI(plugin);
|
|
const fn = vi.fn();
|
|
const d = api.hooks.onEmailOpen(fn);
|
|
expect(d).toBeDefined();
|
|
d.dispose(); // should not throw
|
|
});
|
|
});
|
|
|
|
describe('ui permission requirement', () => {
|
|
it('throws without ui:toolbar permission', () => {
|
|
const plugin = makePlugin({ permissions: [] });
|
|
const api = createPluginAPI(plugin);
|
|
expect(() => api.ui.registerToolbarAction({
|
|
id: 'test',
|
|
label: 'Test',
|
|
onClick: () => {},
|
|
})).toThrow('lacks permission');
|
|
});
|
|
|
|
it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const plugin = makePlugin({ permissions: ['ui:toolbar'] });
|
|
const api = createPluginAPI(plugin);
|
|
const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
|
|
expect(d.dispose).toBeInstanceOf(Function);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('slot registration bridge', () => {
|
|
it('calls bridge when set', () => {
|
|
const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
|
|
setSlotRegistrationBridge(bridge);
|
|
|
|
const plugin = makePlugin({ permissions: ['ui:email-footer'] });
|
|
const api = createPluginAPI(plugin);
|
|
const DummyComponent = () => null;
|
|
api.ui.registerEmailFooter(DummyComponent);
|
|
expect(bridge).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('toast bridge', () => {
|
|
it('exposes success/error/info/warning methods', () => {
|
|
const plugin = makePlugin();
|
|
const api = createPluginAPI(plugin);
|
|
expect(api.toast.success).toBeInstanceOf(Function);
|
|
expect(api.toast.error).toBeInstanceOf(Function);
|
|
expect(api.toast.info).toBeInstanceOf(Function);
|
|
expect(api.toast.warning).toBeInstanceOf(Function);
|
|
});
|
|
});
|