feat: harden plugin sandbox and migrate in-tree plugins
This commit is contained in:
@@ -1,193 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http.post path validation', () => {
|
||||
function makeApi(permissions: string[] = ['http:post']) {
|
||||
return createPluginAPI(makePlugin({ permissions }));
|
||||
}
|
||||
|
||||
it('rejects protocol-relative URLs like //evil.example', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects absolute URLs to other origins', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths not under /api/', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths that use backslash to bypass the check', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws without http:post permission', async () => {
|
||||
const api = makeApi([]);
|
||||
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('accepts a valid /api/ path', async () => {
|
||||
const api = makeApi();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
|
||||
});
|
||||
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// We test the raw store by directly invoking Zustand
|
||||
// Mock the external dependencies the store imports
|
||||
@@ -28,10 +28,6 @@ vi.mock('@/lib/plugin-loader', () => ({
|
||||
setupAutoDisable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-api', () => ({
|
||||
setSlotRegistrationBridge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
removeAllPluginHooks: vi.fn(),
|
||||
}));
|
||||
@@ -42,22 +38,6 @@ import { usePluginStore } from '@/stores/plugin-store';
|
||||
function resetStore() {
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
slots: {
|
||||
'toolbar-actions': [],
|
||||
'app-top-banner': [],
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'composer-sidebar': [],
|
||||
'composer-sidebar-right': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
'calendar-event-actions': [],
|
||||
'admin-plugin-page': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
}
|
||||
@@ -85,31 +65,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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()] });
|
||||
|
||||
Reference in New Issue
Block a user