mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
The Settings upload, the admin upload, the marketplace install and the marketplace preview each parsed the ZIP and manifest with their own copy of the code, and the copies had drifted. A `permissions` string skipped validation and was then matched by substring, a small ZIP could inflate to hundreds of megabytes on the server, the Settings upload dropped `locales` and took origin lists unchecked, and settings schemas were never checked at all. All of them now go through `lib/extension-package.ts`: - every file is inflated through a byte cap, with a limit on entries - one manifest check: types, permissions, entrypoint, version, minAppVersion, settingsSchema, configSchema, locales and origin lists - only checked fields are stored
246 lines
8.4 KiB
TypeScript
246 lines
8.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { 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(),
|
|
setSandboxLocale: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/plugin-hooks', () => ({
|
|
removeAllPluginHooks: vi.fn(),
|
|
}));
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
apiFetch: vi.fn(),
|
|
downloadManagedBundle: vi.fn(),
|
|
}));
|
|
vi.mock('@/lib/browser-navigation', () => ({
|
|
apiFetch: (...args: unknown[]) => mocks.apiFetch(...args),
|
|
}));
|
|
vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({
|
|
downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args),
|
|
}));
|
|
|
|
// Import after mocks
|
|
import { usePluginStore } from '@/stores/plugin-store';
|
|
import { pluginStorage } from '@/lib/plugin-storage';
|
|
|
|
function resetStore() {
|
|
usePluginStore.setState({
|
|
plugins: [],
|
|
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('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);
|
|
});
|
|
});
|
|
|
|
describe('initializePlugins server sync', () => {
|
|
const serverPlugin = {
|
|
id: 'managed-plugin',
|
|
name: 'Managed',
|
|
version: '1.0.0',
|
|
author: 'admin',
|
|
description: '',
|
|
type: 'hook',
|
|
permissions: [],
|
|
entrypoint: 'index.js',
|
|
forceEnabled: true,
|
|
bundleHash: 'hash-v1',
|
|
};
|
|
|
|
// The record the sync creates for serverPlugin once it has run before.
|
|
function syncedRecord(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
|
return mockPlugin({
|
|
id: 'managed-plugin',
|
|
name: 'Managed',
|
|
author: 'admin',
|
|
enabled: true,
|
|
status: 'enabled',
|
|
managed: true,
|
|
forceEnabled: true,
|
|
adminApproved: true,
|
|
bundleHash: 'hash-v1',
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
mocks.apiFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ plugins: [serverPlugin], themes: [] }),
|
|
});
|
|
mocks.downloadManagedBundle.mockResolvedValue('bundle-code');
|
|
vi.mocked(pluginStorage.getCode).mockResolvedValue(null);
|
|
});
|
|
|
|
it('installs a new server plugin and caches its bundle', async () => {
|
|
await usePluginStore.getState().initializePlugins();
|
|
|
|
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1');
|
|
expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code');
|
|
const p = usePluginStore.getState().plugins.find(x => x.id === 'managed-plugin');
|
|
expect(p).toMatchObject({ managed: true, enabled: true, bundleHash: 'hash-v1' });
|
|
});
|
|
|
|
it('re-downloads the bundle when the record is current but IndexedDB has no copy (#636)', async () => {
|
|
usePluginStore.setState({ plugins: [syncedRecord()] });
|
|
vi.mocked(pluginStorage.getCode).mockResolvedValue(null);
|
|
|
|
await usePluginStore.getState().initializePlugins();
|
|
|
|
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1');
|
|
expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code');
|
|
expect(usePluginStore.getState().plugins).toHaveLength(1);
|
|
});
|
|
|
|
it('leaves a current plugin alone when its bundle is cached', async () => {
|
|
usePluginStore.setState({ plugins: [syncedRecord()] });
|
|
vi.mocked(pluginStorage.getCode).mockResolvedValue('cached-code');
|
|
|
|
await usePluginStore.getState().initializePlugins();
|
|
|
|
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
|
|
expect(pluginStorage.saveCode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('re-downloads when the server hash changed even though a bundle is cached', async () => {
|
|
usePluginStore.setState({ plugins: [syncedRecord({ bundleHash: 'hash-v0' })] });
|
|
vi.mocked(pluginStorage.getCode).mockResolvedValue('cached-code');
|
|
|
|
await usePluginStore.getState().initializePlugins();
|
|
|
|
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1');
|
|
expect(usePluginStore.getState().plugins[0].bundleHash).toBe('hash-v1');
|
|
});
|
|
|
|
it('keeps the record when the download fails so the error can surface at load time', async () => {
|
|
usePluginStore.setState({ plugins: [syncedRecord()] });
|
|
mocks.downloadManagedBundle.mockRejectedValue(new Error('Could not download the bundle (HTTP 503)'));
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await usePluginStore.getState().initializePlugins();
|
|
|
|
expect(pluginStorage.saveCode).not.toHaveBeenCalled();
|
|
expect(usePluginStore.getState().plugins).toHaveLength(1);
|
|
expect(usePluginStore.getState().initialized).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('installPlugin', () => {
|
|
beforeEach(() => {
|
|
resetStore();
|
|
});
|
|
|
|
it('keeps the translations a plugin ships', async () => {
|
|
const { extractPlugin } = await import('@/lib/plugin-validator');
|
|
vi.mocked(extractPlugin).mockResolvedValueOnce({
|
|
valid: true, errors: [], warnings: [], preview: null, code: 'module.exports = {}',
|
|
manifest: {
|
|
id: 'translated', name: 'T', version: '1.0.0', author: 'a', description: '', type: 'hook',
|
|
permissions: [], entrypoint: 'index.js', locales: { de: { hello: 'Hallo' } },
|
|
},
|
|
});
|
|
|
|
const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip'));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(usePluginStore.getState().plugins[0].locales).toEqual({ de: { hello: 'Hallo' } });
|
|
});
|
|
});
|