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,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