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:
Linus Rath
2026-03-25 00:44:03 +01:00
parent 78bcf8db1b
commit 76b21147e4
63 changed files with 7894 additions and 67 deletions
+52
View File
@@ -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);
});
});
+150
View File
@@ -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);
});
});
+249
View File
@@ -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);
});
});
+45
View File
@@ -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();
});
});
+102
View File
@@ -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();
});
});
+81
View File
@@ -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();
});
});
});
+156
View File
@@ -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);
});
});
});
+135
View File
@@ -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);
});
});
});
+322
View File
@@ -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);
});
});
+175
View File
@@ -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);
});
});
});