feat: add plugin/theme harness and admin dashboard
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
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BUILTIN_THEMES } from '../builtin-themes';
|
||||
|
||||
describe('BUILTIN_THEMES', () => {
|
||||
it('contains exactly 3 themes', () => {
|
||||
expect(BUILTIN_THEMES).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('all themes have required fields', () => {
|
||||
for (const theme of BUILTIN_THEMES) {
|
||||
expect(theme.id).toBeTruthy();
|
||||
expect(theme.name).toBeTruthy();
|
||||
expect(theme.version).toBeTruthy();
|
||||
expect(theme.author).toBe('Built-in');
|
||||
expect(theme.css).toBeTruthy();
|
||||
expect(theme.variants).toEqual(['light', 'dark']);
|
||||
expect(theme.enabled).toBe(true);
|
||||
expect(theme.builtIn).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('all IDs are prefixed with builtin-', () => {
|
||||
for (const theme of BUILTIN_THEMES) {
|
||||
expect(theme.id).toMatch(/^builtin-/);
|
||||
}
|
||||
});
|
||||
|
||||
it('all themes have both :root and .dark selectors', () => {
|
||||
for (const theme of BUILTIN_THEMES) {
|
||||
expect(theme.css).toContain(':root');
|
||||
expect(theme.css).toContain('.dark');
|
||||
}
|
||||
});
|
||||
|
||||
it('all themes set --color-primary', () => {
|
||||
for (const theme of BUILTIN_THEMES) {
|
||||
expect(theme.css).toContain('--color-primary:');
|
||||
}
|
||||
});
|
||||
|
||||
it('themes have correct names', () => {
|
||||
const names = BUILTIN_THEMES.map(t => t.name);
|
||||
expect(names).toContain('Nord');
|
||||
expect(names).toContain('Catppuccin');
|
||||
expect(names).toContain('Solarized');
|
||||
});
|
||||
|
||||
it('theme IDs are unique', () => {
|
||||
const ids = BUILTIN_THEMES.map(t => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks } from '../plugin-hooks';
|
||||
|
||||
beforeEach(() => {
|
||||
pluginErrorTracker.resetAll();
|
||||
clearAllHooks();
|
||||
});
|
||||
|
||||
describe('HookBus', () => {
|
||||
describe('register / size / dispose', () => {
|
||||
it('registers a handler', () => {
|
||||
const bus = new HookBus();
|
||||
bus.register('p1', vi.fn());
|
||||
expect(bus.size).toBe(1);
|
||||
});
|
||||
|
||||
it('dispose removes the handler', () => {
|
||||
const bus = new HookBus();
|
||||
const d = bus.register('p1', vi.fn());
|
||||
d.dispose();
|
||||
expect(bus.size).toBe(0);
|
||||
});
|
||||
|
||||
it('registering multiple handlers', () => {
|
||||
const bus = new HookBus();
|
||||
bus.register('p1', vi.fn());
|
||||
bus.register('p2', vi.fn());
|
||||
expect(bus.size).toBe(2);
|
||||
});
|
||||
|
||||
it('removePlugin removes all handlers for that plugin', () => {
|
||||
const bus = new HookBus();
|
||||
bus.register('p1', vi.fn());
|
||||
bus.register('p1', vi.fn());
|
||||
bus.register('p2', vi.fn());
|
||||
bus.removePlugin('p1');
|
||||
expect(bus.size).toBe(1);
|
||||
});
|
||||
|
||||
it('clear removes all handlers', () => {
|
||||
const bus = new HookBus();
|
||||
bus.register('p1', vi.fn());
|
||||
bus.register('p2', vi.fn());
|
||||
bus.clear();
|
||||
expect(bus.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emit (observer)', () => {
|
||||
it('calls all handlers with args', async () => {
|
||||
const bus = new HookBus<(x: number) => void>();
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
bus.register('p1', fn1);
|
||||
bus.register('p2', fn2);
|
||||
await bus.emit(42);
|
||||
expect(fn1).toHaveBeenCalledWith(42);
|
||||
expect(fn2).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('calls handlers in order', async () => {
|
||||
const bus = new HookBus<() => void>();
|
||||
const order: number[] = [];
|
||||
bus.register('p1', () => order.push(200), 200);
|
||||
bus.register('p2', () => order.push(50), 50);
|
||||
bus.register('p3', () => order.push(100), 100);
|
||||
await bus.emit();
|
||||
expect(order).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
it('catches handler errors and records them', async () => {
|
||||
const bus = new HookBus<() => void>();
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
bus.register('p1', () => { throw new Error('fail'); });
|
||||
await bus.emit();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('skips disabled plugins', async () => {
|
||||
const bus = new HookBus<() => void>();
|
||||
const fn = vi.fn();
|
||||
bus.register('p1', fn);
|
||||
|
||||
// Manually trigger circuit breaker
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
pluginErrorTracker.record('p1', new Error('test'));
|
||||
}
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
|
||||
await bus.emit();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSync', () => {
|
||||
it('calls handlers synchronously', () => {
|
||||
const bus = new HookBus<(x: string) => void>();
|
||||
const fn = vi.fn();
|
||||
bus.register('p1', fn);
|
||||
bus.emitSync('hello');
|
||||
expect(fn).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('catches errors without throwing', () => {
|
||||
const bus = new HookBus<() => void>();
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
bus.register('p1', () => { throw new Error('boom'); });
|
||||
expect(() => bus.emitSync()).not.toThrow();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('intercept', () => {
|
||||
it('returns true when all handlers pass', async () => {
|
||||
const bus = new HookBus<() => boolean>();
|
||||
bus.register('p1', () => true);
|
||||
bus.register('p2', () => true);
|
||||
const result = await bus.intercept();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any handler returns false', async () => {
|
||||
const bus = new HookBus<() => boolean>();
|
||||
bus.register('p1', () => true);
|
||||
bus.register('p2', () => false);
|
||||
const result = await bus.intercept();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('stops early on false (short-circuits)', async () => {
|
||||
const bus = new HookBus<() => boolean>();
|
||||
const fn3 = vi.fn(() => true);
|
||||
bus.register('p1', () => true, 10);
|
||||
bus.register('p2', () => false, 20);
|
||||
bus.register('p3', fn3, 30);
|
||||
await bus.intercept();
|
||||
expect(fn3).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true when no handlers registered', async () => {
|
||||
const bus = new HookBus<() => boolean>();
|
||||
expect(await bus.intercept()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transform', () => {
|
||||
it('chains values through handlers', async () => {
|
||||
const bus = new HookBus<(val: number) => number>();
|
||||
bus.register('p1', (val: number) => val * 2);
|
||||
bus.register('p2', (val: number) => val + 1);
|
||||
const result = await bus.transform(5);
|
||||
expect(result).toBe(11); // (5 * 2) + 1
|
||||
});
|
||||
|
||||
it('returns initial value when no handlers', async () => {
|
||||
const bus = new HookBus<(val: string) => string>();
|
||||
const result = await bus.transform('hello');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('skips handler that returns undefined', async () => {
|
||||
const bus = new HookBus<(val: number) => number | undefined>();
|
||||
bus.register('p1', () => undefined);
|
||||
bus.register('p2', (val: number) => val + 10);
|
||||
const result = await bus.transform(5);
|
||||
expect(result).toBe(15);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginErrorTracker', () => {
|
||||
it('is not disabled initially', () => {
|
||||
expect(pluginErrorTracker.isDisabled('some-plugin')).toBe(false);
|
||||
});
|
||||
|
||||
it('disables after threshold errors', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
pluginErrorTracker.record('p1', new Error('1'));
|
||||
pluginErrorTracker.record('p1', new Error('2'));
|
||||
expect(pluginErrorTracker.isDisabled('p1')).toBe(false);
|
||||
pluginErrorTracker.record('p1', new Error('3'));
|
||||
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls auto-disable callback', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const cb = vi.fn();
|
||||
pluginErrorTracker.setAutoDisableCallback(cb);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
pluginErrorTracker.record('p2', new Error(`err-${i}`));
|
||||
}
|
||||
expect(cb).toHaveBeenCalledWith('p2', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
pluginErrorTracker.setAutoDisableCallback(() => {});
|
||||
});
|
||||
|
||||
it('reset re-enables a plugin', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
pluginErrorTracker.record('p3', new Error(`err-${i}`));
|
||||
}
|
||||
expect(pluginErrorTracker.isDisabled('p3')).toBe(true);
|
||||
pluginErrorTracker.reset('p3');
|
||||
expect(pluginErrorTracker.isDisabled('p3')).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook domain instances', () => {
|
||||
it('emailHooks has expected buses', () => {
|
||||
expect(emailHooks.onEmailOpen).toBeInstanceOf(HookBus);
|
||||
expect(emailHooks.onBeforeEmailSend).toBeInstanceOf(HookBus);
|
||||
expect(emailHooks.onAfterEmailDelete).toBeInstanceOf(HookBus);
|
||||
expect(emailHooks.onNewEmailReceived).toBeInstanceOf(HookBus);
|
||||
});
|
||||
|
||||
it('calendarHooks has expected buses', () => {
|
||||
expect(calendarHooks.onCalendarEventOpen).toBeInstanceOf(HookBus);
|
||||
expect(calendarHooks.onBeforeEventCreate).toBeInstanceOf(HookBus);
|
||||
expect(calendarHooks.onEventRsvp).toBeInstanceOf(HookBus);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAllPluginHooks', () => {
|
||||
it('removes handlers from all buses for a plugin', () => {
|
||||
emailHooks.onEmailOpen.register('test-p', vi.fn());
|
||||
calendarHooks.onCalendarEventOpen.register('test-p', vi.fn());
|
||||
emailHooks.onEmailOpen.register('other-p', vi.fn());
|
||||
|
||||
removeAllPluginHooks('test-p');
|
||||
|
||||
expect(emailHooks.onEmailOpen.size).toBe(1); // other-p remains
|
||||
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllHooks', () => {
|
||||
it('removes all handlers from all buses', () => {
|
||||
emailHooks.onEmailOpen.register('p1', vi.fn());
|
||||
calendarHooks.onCalendarEventOpen.register('p2', vi.fn());
|
||||
clearAllHooks();
|
||||
expect(emailHooks.onEmailOpen.size).toBe(0);
|
||||
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
exposePluginExternals,
|
||||
deactivatePlugin,
|
||||
isPluginActive,
|
||||
deactivateAllPlugins,
|
||||
} from '../plugin-loader';
|
||||
import { clearAllHooks, pluginErrorTracker } from '../plugin-hooks';
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
pluginErrorTracker.resetAll();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (globalThis as any).__PLUGIN_EXTERNALS__;
|
||||
});
|
||||
|
||||
describe('exposePluginExternals', () => {
|
||||
it('sets window.__PLUGIN_EXTERNALS__ with React, ReactDOM, ReactJSX', () => {
|
||||
exposePluginExternals();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const externals = (globalThis as any).__PLUGIN_EXTERNALS__;
|
||||
expect(externals).toBeDefined();
|
||||
expect(externals.React).toBeDefined();
|
||||
expect(externals.ReactDOM).toBeDefined();
|
||||
expect(externals.ReactJSX).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPluginActive', () => {
|
||||
it('returns false for unknown plugin', () => {
|
||||
expect(isPluginActive('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivatePlugin', () => {
|
||||
it('does nothing for unknown plugin (no error)', () => {
|
||||
expect(() => deactivatePlugin('nonexistent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivateAllPlugins', () => {
|
||||
it('does not throw when no plugins active', () => {
|
||||
expect(() => deactivateAllPlugins()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { SlotRegistration } from '@/lib/plugin-types';
|
||||
|
||||
// Mock the plugin store
|
||||
const mockSlots: Record<string, SlotRegistration[]> = {};
|
||||
|
||||
vi.mock('@/stores/plugin-store', () => ({
|
||||
usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) =>
|
||||
selector({ slots: mockSlots }),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { PluginSlot } from '@/components/plugins/plugin-slot';
|
||||
import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary';
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(mockSlots).forEach(k => delete mockSlots[k]);
|
||||
});
|
||||
|
||||
describe('PluginSlot', () => {
|
||||
it('renders null when no registrations', () => {
|
||||
mockSlots['toolbar-actions'] = [];
|
||||
const { container } = render(
|
||||
React.createElement(PluginSlot, { name: 'toolbar-actions' })
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders null when slot has undefined registrations', () => {
|
||||
// slot entry doesn't exist at all
|
||||
const { container } = render(
|
||||
React.createElement(PluginSlot, { name: 'toolbar-actions' })
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders registered components', () => {
|
||||
const TestComponent = () => React.createElement('span', null, 'Hello Plugin');
|
||||
mockSlots['email-footer'] = [
|
||||
{ pluginId: 'test', component: TestComponent, order: 100 },
|
||||
];
|
||||
const { getByText } = render(
|
||||
React.createElement(PluginSlot, { name: 'email-footer' })
|
||||
);
|
||||
expect(getByText('Hello Plugin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets data-plugin-slot attribute', () => {
|
||||
const TestComponent = () => React.createElement('span', null, 'x');
|
||||
mockSlots['sidebar-widget'] = [
|
||||
{ pluginId: 'sw', component: TestComponent, order: 100 },
|
||||
];
|
||||
const { container } = render(
|
||||
React.createElement(PluginSlot, { name: 'sidebar-widget' })
|
||||
);
|
||||
expect(container.querySelector('[data-plugin-slot="sidebar-widget"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginErrorBoundary', () => {
|
||||
it('renders children when no error', () => {
|
||||
const { getByText } = render(
|
||||
React.createElement(
|
||||
PluginErrorBoundary,
|
||||
{ pluginId: 'test' },
|
||||
React.createElement('span', null, 'Child')
|
||||
)
|
||||
);
|
||||
expect(getByText('Child')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders fallback on error', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ThrowingComponent = () => { throw new Error('boom'); };
|
||||
const { getByText } = render(
|
||||
React.createElement(
|
||||
PluginErrorBoundary,
|
||||
{ pluginId: 'err', fallback: React.createElement('span', null, 'Error caught') },
|
||||
React.createElement(ThrowingComponent)
|
||||
)
|
||||
);
|
||||
expect(getByText('Error caught')).toBeTruthy();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders null on error when no fallback provided', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ThrowingComponent = () => { throw new Error('boom'); };
|
||||
const { container } = render(
|
||||
React.createElement(
|
||||
PluginErrorBoundary,
|
||||
{ pluginId: 'err2' },
|
||||
React.createElement(ThrowingComponent)
|
||||
)
|
||||
);
|
||||
// ErrorBoundary renders null fallback
|
||||
expect(container.innerHTML).toBe('');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { pluginStorage } from '../plugin-storage';
|
||||
|
||||
// Use unique keys per test to avoid shared state (avoiding deleteDatabase which
|
||||
// blocks on open connections that the module never closes).
|
||||
|
||||
describe('pluginStorage', () => {
|
||||
describe('plugin code', () => {
|
||||
it('saves and retrieves code', async () => {
|
||||
await pluginStorage.saveCode('code-save-1', 'console.log("hello")');
|
||||
const code = await pluginStorage.getCode('code-save-1');
|
||||
expect(code).toBe('console.log("hello")');
|
||||
});
|
||||
|
||||
it('returns null for missing plugin', async () => {
|
||||
const code = await pluginStorage.getCode('code-missing-xyz');
|
||||
expect(code).toBeNull();
|
||||
});
|
||||
|
||||
it('overwrites existing code', async () => {
|
||||
await pluginStorage.saveCode('code-overwrite-1', 'v1');
|
||||
await pluginStorage.saveCode('code-overwrite-1', 'v2');
|
||||
const code = await pluginStorage.getCode('code-overwrite-1');
|
||||
expect(code).toBe('v2');
|
||||
});
|
||||
|
||||
it('deletes code', async () => {
|
||||
await pluginStorage.saveCode('code-del-1', 'code');
|
||||
await pluginStorage.deleteCode('code-del-1');
|
||||
const code = await pluginStorage.getCode('code-del-1');
|
||||
expect(code).toBeNull();
|
||||
});
|
||||
|
||||
it('stores multiple plugins independently', async () => {
|
||||
await pluginStorage.saveCode('code-multi-a', 'code-a');
|
||||
await pluginStorage.saveCode('code-multi-b', 'code-b');
|
||||
expect(await pluginStorage.getCode('code-multi-a')).toBe('code-a');
|
||||
expect(await pluginStorage.getCode('code-multi-b')).toBe('code-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme CSS', () => {
|
||||
it('saves and retrieves CSS', async () => {
|
||||
const css = ':root { --color-primary: blue; }';
|
||||
await pluginStorage.saveThemeCSS('css-save-1', css);
|
||||
const result = await pluginStorage.getThemeCSS('css-save-1');
|
||||
expect(result).toBe(css);
|
||||
});
|
||||
|
||||
it('returns null for missing theme', async () => {
|
||||
const result = await pluginStorage.getThemeCSS('css-missing-xyz');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes CSS', async () => {
|
||||
await pluginStorage.saveThemeCSS('css-del-1', 'css');
|
||||
await pluginStorage.deleteThemeCSS('css-del-1');
|
||||
expect(await pluginStorage.getThemeCSS('css-del-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('previews', () => {
|
||||
it('saves and retrieves preview data URI', async () => {
|
||||
const dataUri = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
await pluginStorage.savePreview('prev-save-1', dataUri);
|
||||
const result = await pluginStorage.getPreview('prev-save-1');
|
||||
expect(result).toBe(dataUri);
|
||||
});
|
||||
|
||||
it('returns null for missing preview', async () => {
|
||||
expect(await pluginStorage.getPreview('prev-missing-xyz')).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes preview', async () => {
|
||||
await pluginStorage.savePreview('prev-del-1', 'data:...');
|
||||
await pluginStorage.deletePreview('prev-del-1');
|
||||
expect(await pluginStorage.getPreview('prev-del-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// We test the raw store by directly invoking Zustand
|
||||
// Mock the external dependencies the store imports
|
||||
vi.mock('@/lib/plugin-storage', () => ({
|
||||
pluginStorage: {
|
||||
saveCode: vi.fn().mockResolvedValue(undefined),
|
||||
getCode: vi.fn().mockResolvedValue(null),
|
||||
deleteCode: vi.fn().mockResolvedValue(undefined),
|
||||
saveThemeCSS: vi.fn().mockResolvedValue(undefined),
|
||||
getThemeCSS: vi.fn().mockResolvedValue(null),
|
||||
deleteThemeCSS: vi.fn().mockResolvedValue(undefined),
|
||||
savePreview: vi.fn().mockResolvedValue(undefined),
|
||||
getPreview: vi.fn().mockResolvedValue(null),
|
||||
deletePreview: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-validator', () => ({
|
||||
extractPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-loader', () => ({
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
deactivatePlugin: vi.fn(),
|
||||
setPluginStoreAccessor: vi.fn(),
|
||||
setupAutoDisable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-api', () => ({
|
||||
setSlotRegistrationBridge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
removeAllPluginHooks: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
|
||||
function resetStore() {
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
slots: {
|
||||
'toolbar-actions': [],
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'sidebar-widget': [],
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
}
|
||||
|
||||
function mockPlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
return {
|
||||
id: 'test-plugin',
|
||||
name: 'Test',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
description: '',
|
||||
type: 'hook',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
enabled: false,
|
||||
status: 'installed',
|
||||
settings: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('usePluginStore', () => {
|
||||
describe('registerSlot / dispose', () => {
|
||||
it('adds registration to slot and removes on dispose', () => {
|
||||
const { registerSlot } = usePluginStore.getState();
|
||||
const reg: SlotRegistration = {
|
||||
pluginId: 'p1',
|
||||
component: () => null,
|
||||
order: 100,
|
||||
};
|
||||
const disposable = registerSlot('toolbar-actions', reg);
|
||||
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
|
||||
disposable.dispose();
|
||||
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sorts registrations by order', () => {
|
||||
const { registerSlot } = usePluginStore.getState();
|
||||
registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
|
||||
registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
|
||||
registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
|
||||
|
||||
const regs = usePluginStore.getState().slots['email-banner'];
|
||||
expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPluginStatus', () => {
|
||||
it('updates status for existing plugin', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin()] });
|
||||
usePluginStore.getState().setPluginStatus('test-plugin', 'running');
|
||||
expect(usePluginStore.getState().plugins[0].status).toBe('running');
|
||||
});
|
||||
|
||||
it('sets error message', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin()] });
|
||||
usePluginStore.getState().setPluginStatus('test-plugin', 'error', 'something broke');
|
||||
const p = usePluginStore.getState().plugins[0];
|
||||
expect(p.status).toBe('error');
|
||||
expect(p.error).toBe('something broke');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePluginSettings', () => {
|
||||
it('merges settings', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin({ settings: { a: 1 } })] });
|
||||
usePluginStore.getState().updatePluginSettings('test-plugin', { b: 2 });
|
||||
expect(usePluginStore.getState().plugins[0].settings).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('disablePlugin', () => {
|
||||
it('sets enabled false and status disabled', () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [mockPlugin({ enabled: true, status: 'running' })],
|
||||
});
|
||||
usePluginStore.getState().disablePlugin('test-plugin');
|
||||
const p = usePluginStore.getState().plugins[0];
|
||||
expect(p.enabled).toBe(false);
|
||||
expect(p.status).toBe('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallPlugin', () => {
|
||||
it('removes plugin from list', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin()] });
|
||||
usePluginStore.getState().uninstallPlugin('test-plugin');
|
||||
expect(usePluginStore.getState().plugins).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('no-op for unknown plugin', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin()] });
|
||||
usePluginStore.getState().uninstallPlugin('unknown');
|
||||
expect(usePluginStore.getState().plugins).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
IMPLICIT_PERMISSIONS,
|
||||
MAX_PLUGIN_SIZE,
|
||||
MAX_THEME_SIZE,
|
||||
ALLOWED_PLUGIN_FILES,
|
||||
DISALLOWED_CSS_PATTERNS,
|
||||
} from '../plugin-types';
|
||||
|
||||
describe('plugin-types constants', () => {
|
||||
describe('ALL_PERMISSIONS', () => {
|
||||
it('contains at least 30 permissions', () => {
|
||||
expect(ALL_PERMISSIONS.length).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('has no duplicates', () => {
|
||||
const unique = new Set(ALL_PERMISSIONS);
|
||||
expect(unique.size).toBe(ALL_PERMISSIONS.length);
|
||||
});
|
||||
|
||||
it('all permissions follow domain:action format', () => {
|
||||
for (const perm of ALL_PERMISSIONS) {
|
||||
expect(perm).toMatch(/^[a-z]+:[a-z-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes core email/calendar/contacts permissions', () => {
|
||||
expect(ALL_PERMISSIONS).toContain('email:read');
|
||||
expect(ALL_PERMISSIONS).toContain('email:write');
|
||||
expect(ALL_PERMISSIONS).toContain('email:send');
|
||||
expect(ALL_PERMISSIONS).toContain('calendar:read');
|
||||
expect(ALL_PERMISSIONS).toContain('calendar:write');
|
||||
expect(ALL_PERMISSIONS).toContain('contacts:read');
|
||||
expect(ALL_PERMISSIONS).toContain('contacts:write');
|
||||
});
|
||||
|
||||
it('includes UI permissions for all slot types', () => {
|
||||
expect(ALL_PERMISSIONS).toContain('ui:toolbar');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:email-banner');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:email-footer');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:composer-toolbar');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:sidebar-widget');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:settings-section');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:context-menu');
|
||||
expect(ALL_PERMISSIONS).toContain('ui:navigation-rail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMPLICIT_PERMISSIONS', () => {
|
||||
it('contains ui:observe and app:lifecycle', () => {
|
||||
expect(IMPLICIT_PERMISSIONS).toContain('ui:observe');
|
||||
expect(IMPLICIT_PERMISSIONS).toContain('app:lifecycle');
|
||||
});
|
||||
|
||||
it('has exactly 2 implicit permissions', () => {
|
||||
expect(IMPLICIT_PERMISSIONS).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('implicit permissions are in ALL_PERMISSIONS', () => {
|
||||
for (const perm of IMPLICIT_PERMISSIONS) {
|
||||
expect(ALL_PERMISSIONS).toContain(perm);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('size limits', () => {
|
||||
it('MAX_PLUGIN_SIZE is 5 MB', () => {
|
||||
expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('MAX_THEME_SIZE is 1 MB', () => {
|
||||
expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALLOWED_PLUGIN_FILES', () => {
|
||||
it('allows JavaScript files', () => {
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.js')).toBe(true);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.mjs')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows assets', () => {
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.css')).toBe(true);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.json')).toBe(true);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.png')).toBe(true);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.svg')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not allow executable types', () => {
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.exe')).toBe(false);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.sh')).toBe(false);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.bat')).toBe(false);
|
||||
expect(ALLOWED_PLUGIN_FILES.has('.html')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DISALLOWED_CSS_PATTERNS', () => {
|
||||
it('blocks @import', () => {
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('@import url("evil.css")'));
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks external URLs', () => {
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('background: url("https://evil.com/track.png")'));
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks javascript: in CSS', () => {
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('background: javascript:alert(1)'));
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks expression()', () => {
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('width: expression(document.body.clientWidth)'));
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks -moz-binding', () => {
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('-moz-binding: url("evil.xml#xbl")'));
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks behavior:', () => {
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('behavior: url(evil.htc)'));
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it('allows safe CSS', () => {
|
||||
const safeCSS = ':root { --color-primary: #3b82f6; }';
|
||||
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test(safeCSS));
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { extractTheme, extractPlugin } from '../plugin-validator';
|
||||
|
||||
function createZipFile(zip: JSZip, name = 'test.zip'): Promise<File> {
|
||||
return zip.generateAsync({ type: 'blob' }).then(blob => new File([blob], name));
|
||||
}
|
||||
|
||||
describe('extractTheme', () => {
|
||||
it('extracts a valid theme ZIP', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'my-theme',
|
||||
name: 'My Theme',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'theme',
|
||||
variants: ['light', 'dark'],
|
||||
}));
|
||||
zip.file('theme.css', ':root { --color-primary: #ff0000; }\n.dark { --color-primary: #00ff00; }');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest).not.toBeNull();
|
||||
expect(result.manifest!.id).toBe('my-theme');
|
||||
expect(result.css).toContain('--color-primary');
|
||||
});
|
||||
|
||||
it('rejects oversized theme', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'big-theme',
|
||||
name: 'Big',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'theme',
|
||||
variants: ['light'],
|
||||
}));
|
||||
// Make a large file > 1MB
|
||||
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
|
||||
|
||||
// Manually create oversized File
|
||||
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
|
||||
const result = await extractTheme(oversizedFile);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit');
|
||||
});
|
||||
|
||||
it('rejects non-ZIP file', async () => {
|
||||
const file = new File(['not a zip'], 'bad.zip');
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid ZIP file');
|
||||
});
|
||||
|
||||
it('rejects missing manifest.json', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('theme.css', ':root { --color-primary: blue; }');
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing manifest.json');
|
||||
});
|
||||
|
||||
it('rejects invalid JSON manifest', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', 'not json {{{');
|
||||
zip.file('theme.css', ':root { --color-primary: blue; }');
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid manifest.json (not valid JSON)');
|
||||
});
|
||||
|
||||
it('rejects missing theme.css', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'no-css',
|
||||
name: 'No CSS',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'theme',
|
||||
variants: ['light'],
|
||||
}));
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing theme.css');
|
||||
});
|
||||
|
||||
it('rejects wrong type in manifest', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'wrong-type',
|
||||
name: 'Wrong',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'plugin', // wrong
|
||||
variants: ['light'],
|
||||
}));
|
||||
zip.file('theme.css', ':root { --color-primary: blue; }');
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Expected type "theme"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing variants', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'no-variants',
|
||||
name: 'No Variants',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'theme',
|
||||
}));
|
||||
zip.file('theme.css', ':root { --color-primary: blue; }');
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('variants'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles ZIP with folder root', async () => {
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder('my-theme')!;
|
||||
folder.file('manifest.json', JSON.stringify({
|
||||
id: 'nested-theme',
|
||||
name: 'Nested',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'theme',
|
||||
variants: ['light', 'dark'],
|
||||
}));
|
||||
folder.file('theme.css', ':root { --color-primary: #aaa; }\n.dark { --color-primary: #bbb; }');
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractTheme(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest!.id).toBe('nested-theme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPlugin', () => {
|
||||
it('extracts a valid plugin ZIP', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'my-plugin',
|
||||
name: 'My Plugin',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'ui-extension',
|
||||
entrypoint: 'index.js',
|
||||
permissions: ['email:read'],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate(api) { console.log("hi"); }');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest!.id).toBe('my-plugin');
|
||||
expect(result.code).toContain('activate');
|
||||
});
|
||||
|
||||
it('rejects oversized plugin', async () => {
|
||||
const oversizedFile = new File([new ArrayBuffer(5 * 1024 * 1024 + 1)], 'big.zip');
|
||||
const result = await extractPlugin(oversizedFile);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Plugin ZIP exceeds 5 MB size limit');
|
||||
});
|
||||
|
||||
it('rejects non-ZIP file', async () => {
|
||||
const file = new File(['not a zip'], 'bad.zip');
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid ZIP file');
|
||||
});
|
||||
|
||||
it('rejects missing manifest', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('index.js', 'export function activate() {}');
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing manifest.json');
|
||||
});
|
||||
|
||||
it('rejects disallowed file types', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'bad-files',
|
||||
name: 'Bad',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'hook',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() {}');
|
||||
zip.file('hack.exe', 'binary');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('.exe'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown permissions', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'bad-perms',
|
||||
name: 'Bad perms',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'hook',
|
||||
entrypoint: 'index.js',
|
||||
permissions: ['email:read', 'nuclear:launch'],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() {}');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('nuclear:launch'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns about eval() in code', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'eval-plugin',
|
||||
name: 'Eval',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'hook',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() { eval("alert(1)"); }');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings.some(w => w.includes('eval()'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns about document.cookie in code', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'cookie-plugin',
|
||||
name: 'Cookie',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'hook',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() { const c = document.cookie; }');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings.some(w => w.includes('document.cookie'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid plugin type', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'bad-type',
|
||||
name: 'Bad Type',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'theme', // wrong type for plugin
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() {}');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Invalid type'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing entrypoint', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'no-entry',
|
||||
name: 'No Entry',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'hook',
|
||||
entrypoint: 'main.js',
|
||||
permissions: [],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() {}');
|
||||
// entrypoint 'main.js' doesn't exist
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Missing entrypoint'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid manifest ID format', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'Bad_ID!',
|
||||
name: 'Bad ID',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
type: 'hook',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
}));
|
||||
zip.file('index.js', 'export function activate() {}');
|
||||
|
||||
const file = await createZipFile(zip);
|
||||
const result = await extractPlugin(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('ID must be lowercase'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
sanitizeThemeCSS,
|
||||
validateThemeSelectors,
|
||||
injectThemeCSS,
|
||||
removeThemeCSS,
|
||||
validateThemeCSSSafety,
|
||||
} from '../theme-loader';
|
||||
|
||||
describe('theme-loader', () => {
|
||||
describe('sanitizeThemeCSS', () => {
|
||||
it('passes through safe CSS unchanged', () => {
|
||||
const css = ':root { --color-primary: #3b82f6; }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).toBe(css);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips @import directives', () => {
|
||||
const css = '@import url("evil.css");\n:root { --color-primary: red; }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('@import');
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('strips external url() references', () => {
|
||||
const css = ':root { background: url("https://evil.com/track.png"); }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('https://evil.com');
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('strips javascript: in CSS', () => {
|
||||
const css = ':root { background: javascript:alert(1); }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('javascript:');
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('strips expression()', () => {
|
||||
const css = ':root { width: expression(document.body.clientWidth); }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('expression(');
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('strips -moz-binding', () => {
|
||||
const css = ':root { -moz-binding: url("evil.xml#xbl"); }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('-moz-binding');
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('strips behavior:', () => {
|
||||
const css = ':root { behavior: url(evil.htc); }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('behavior');
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('strips multiple dangerous patterns at once', () => {
|
||||
const css = '@import url("a.css"); :root { -moz-binding: url("b.xml"); background: expression(1); }';
|
||||
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
|
||||
expect(cleaned).not.toContain('@import');
|
||||
expect(cleaned).not.toContain('-moz-binding');
|
||||
expect(cleaned).not.toContain('expression(');
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateThemeSelectors', () => {
|
||||
it('accepts :root selector', () => {
|
||||
const warnings = validateThemeSelectors(':root { --color-primary: red; }');
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts .dark selector', () => {
|
||||
const warnings = validateThemeSelectors('.dark { --color-primary: blue; }');
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts @media queries', () => {
|
||||
const css = '@media (prefers-color-scheme: dark) { :root { --color-bg: #000; } }';
|
||||
const warnings = validateThemeSelectors(css);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts @font-face', () => {
|
||||
const css = '@font-face { font-family: "Test"; src: local("Test"); }';
|
||||
const warnings = validateThemeSelectors(css);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns about body selector', () => {
|
||||
const css = 'body { background: red; }';
|
||||
const warnings = validateThemeSelectors(css);
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
expect(warnings[0]).toContain('body');
|
||||
});
|
||||
|
||||
it('warns about element selectors', () => {
|
||||
const css = 'button { color: red; }';
|
||||
const warnings = validateThemeSelectors(css);
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('warns about class selectors other than .dark', () => {
|
||||
const css = '.my-class { color: red; }';
|
||||
const warnings = validateThemeSelectors(css);
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectThemeCSS / removeThemeCSS', () => {
|
||||
afterEach(() => {
|
||||
removeThemeCSS();
|
||||
});
|
||||
|
||||
it('injects a style element into head', () => {
|
||||
injectThemeCSS(':root { --color-primary: red; }');
|
||||
const styleEl = document.getElementById('active-theme');
|
||||
expect(styleEl).not.toBeNull();
|
||||
expect(styleEl?.tagName).toBe('STYLE');
|
||||
expect(styleEl?.textContent).toBe(':root { --color-primary: red; }');
|
||||
});
|
||||
|
||||
it('updates existing style element on subsequent call', () => {
|
||||
injectThemeCSS(':root { --color-primary: red; }');
|
||||
injectThemeCSS(':root { --color-primary: blue; }');
|
||||
const styleEls = document.querySelectorAll('#active-theme');
|
||||
expect(styleEls).toHaveLength(1);
|
||||
expect(styleEls[0].textContent).toBe(':root { --color-primary: blue; }');
|
||||
});
|
||||
|
||||
it('removeThemeCSS removes the style element', () => {
|
||||
injectThemeCSS(':root { --color-primary: red; }');
|
||||
removeThemeCSS();
|
||||
const styleEl = document.getElementById('active-theme');
|
||||
expect(styleEl).toBeNull();
|
||||
});
|
||||
|
||||
it('removeThemeCSS is safe when no theme is injected', () => {
|
||||
expect(() => removeThemeCSS()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateThemeCSSSafety', () => {
|
||||
it('accepts valid theme CSS', () => {
|
||||
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
||||
const { valid, errors } = validateThemeCSSSafety(css);
|
||||
expect(valid).toBe(true);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects empty CSS', () => {
|
||||
const { valid, errors } = validateThemeCSSSafety(' ');
|
||||
expect(valid).toBe(false);
|
||||
expect(errors).toContain('Theme CSS is empty');
|
||||
});
|
||||
|
||||
it('rejects CSS without color variables', () => {
|
||||
const css = ':root { font-size: 16px; }';
|
||||
const { valid, errors } = validateThemeCSSSafety(css);
|
||||
expect(valid).toBe(false);
|
||||
expect(errors.some(e => e.includes('--color-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags dangerous patterns', () => {
|
||||
const css = ':root { --color-primary: red; } @import url("evil.css");';
|
||||
const { valid, errors } = validateThemeCSSSafety(css);
|
||||
expect(valid).toBe(false);
|
||||
expect(errors.some(e => e.includes('disallowed'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user