mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
180 lines
7.0 KiB
TypeScript
180 lines
7.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { createHash } from 'node:crypto';
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getCode: vi.fn(),
|
|
saveCode: vi.fn(),
|
|
downloadManagedBundle: vi.fn(),
|
|
createBackgroundInstance: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../plugin-storage', () => ({
|
|
pluginStorage: {
|
|
getCode: (...args: unknown[]) => mocks.getCode(...args),
|
|
saveCode: (...args: unknown[]) => mocks.saveCode(...args),
|
|
deleteCode: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('../plugin-sandbox/bundle-fetch', () => ({
|
|
downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args),
|
|
}));
|
|
vi.mock('../plugin-sandbox/host-bridge', () => ({
|
|
createBackgroundInstance: (...args: unknown[]) => mocks.createBackgroundInstance(...args),
|
|
SandboxInstance: class {},
|
|
}));
|
|
vi.mock('../plugin-sandbox/host-api', () => ({
|
|
cancelPluginDialogs: vi.fn(),
|
|
}));
|
|
vi.mock('../plugin-sandbox/shortcuts', () => ({
|
|
registerShortcuts: () => () => {},
|
|
}));
|
|
|
|
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
|
|
import { get as getActive } from '../plugin-sandbox/registry';
|
|
|
|
const CODE = 'export default { activate() {} }';
|
|
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
|
|
|
|
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
|
return {
|
|
id: 'managed-plugin',
|
|
name: 'Managed plugin',
|
|
version: '1.0.0',
|
|
author: 'admin',
|
|
description: '',
|
|
type: 'hook',
|
|
permissions: [],
|
|
entrypoint: 'index.js',
|
|
enabled: true,
|
|
status: 'enabled',
|
|
settings: {},
|
|
managed: true,
|
|
adminApproved: true,
|
|
bundleHash: HASH,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeBackground() {
|
|
return {
|
|
initPromise: Promise.resolve({ hooks: [], slots: [], shortcuts: [] }),
|
|
invokeHook: vi.fn(),
|
|
destroy: vi.fn(),
|
|
};
|
|
}
|
|
|
|
let statuses: Array<{ id: string; status: string; error?: string }>;
|
|
|
|
beforeEach(() => {
|
|
statuses = [];
|
|
setSandboxStoreAccessor({
|
|
setPluginStatus: (id, status, error) => { statuses.push({ id, status, error }); },
|
|
});
|
|
mocks.getCode.mockReset().mockResolvedValue(null);
|
|
mocks.saveCode.mockReset().mockResolvedValue(undefined);
|
|
mocks.downloadManagedBundle.mockReset();
|
|
mocks.createBackgroundInstance.mockReset().mockImplementation(() => fakeBackground());
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
unloadSandboxedPlugin('managed-plugin');
|
|
unloadSandboxedPlugin('user-plugin');
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function lastStatus() {
|
|
return statuses[statuses.length - 1];
|
|
}
|
|
|
|
describe('loadSandboxedPlugin bundle sourcing', () => {
|
|
it('runs a managed plugin from IndexedDB when the cached copy verifies', async () => {
|
|
mocks.getCode.mockResolvedValue(CODE);
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' });
|
|
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
|
|
expect(mocks.createBackgroundInstance).toHaveBeenCalledWith(expect.objectContaining({ code: CODE }));
|
|
expect(getActive('managed-plugin')).toBeDefined();
|
|
});
|
|
|
|
it('refills a missing managed bundle from the server (#636)', async () => {
|
|
mocks.getCode.mockResolvedValue(null);
|
|
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH, { requireSignature: false });
|
|
expect(mocks.saveCode).toHaveBeenCalledWith('managed-plugin', CODE);
|
|
expect(lastStatus()).toMatchObject({ status: 'running' });
|
|
expect(getActive('managed-plugin')?.code).toBe(CODE);
|
|
});
|
|
|
|
it('replaces a cached managed bundle that fails the hash check', async () => {
|
|
mocks.getCode.mockResolvedValue('stale or tampered copy');
|
|
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(mocks.downloadManagedBundle).toHaveBeenCalledTimes(1);
|
|
expect(mocks.saveCode).toHaveBeenCalledWith('managed-plugin', CODE);
|
|
expect(lastStatus()).toMatchObject({ status: 'running' });
|
|
});
|
|
|
|
it('fails when the server copy does not match the record hash either', async () => {
|
|
mocks.getCode.mockResolvedValue(null);
|
|
mocks.downloadManagedBundle.mockResolvedValue('something else entirely');
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(lastStatus()).toMatchObject({ status: 'error', error: expect.stringMatching(/integrity mismatch/) });
|
|
expect(mocks.saveCode).not.toHaveBeenCalled();
|
|
expect(mocks.createBackgroundInstance).not.toHaveBeenCalled();
|
|
expect(getActive('managed-plugin')).toBeUndefined();
|
|
});
|
|
|
|
it('surfaces the download failure on the plugin', async () => {
|
|
mocks.downloadManagedBundle.mockRejectedValue(
|
|
new Error('Could not download the bundle for plugin "managed-plugin" (HTTP 404)'),
|
|
);
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(lastStatus()).toMatchObject({ status: 'error', error: expect.stringMatching(/HTTP 404/) });
|
|
expect(mocks.createBackgroundInstance).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a managed bundle without a recorded hash (older records)', async () => {
|
|
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
|
await loadSandboxedPlugin(plugin({ bundleHash: undefined }));
|
|
expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined, { requireSignature: false });
|
|
expect(lastStatus()).toMatchObject({ status: 'running' });
|
|
});
|
|
|
|
it('still runs a managed plugin when the cache cannot be written', async () => {
|
|
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
|
mocks.saveCode.mockRejectedValue(new Error('QuotaExceededError'));
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(lastStatus()).toMatchObject({ status: 'running' });
|
|
});
|
|
|
|
it('still runs a managed plugin when IndexedDB cannot be read', async () => {
|
|
mocks.getCode.mockRejectedValue(new Error('InvalidStateError'));
|
|
mocks.downloadManagedBundle.mockResolvedValue(CODE);
|
|
await loadSandboxedPlugin(plugin());
|
|
expect(lastStatus()).toMatchObject({ status: 'running' });
|
|
});
|
|
|
|
it('does not consult the server for a user-uploaded plugin', async () => {
|
|
mocks.getCode.mockResolvedValue(null);
|
|
await loadSandboxedPlugin(plugin({ id: 'user-plugin', managed: false }));
|
|
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
|
|
expect(lastStatus()).toMatchObject({
|
|
id: 'user-plugin',
|
|
status: 'error',
|
|
error: expect.stringMatching(/No bundle in storage.*Reinstall/),
|
|
});
|
|
});
|
|
|
|
it('fails a user-uploaded plugin whose cached copy does not verify', async () => {
|
|
mocks.getCode.mockResolvedValue('tampered');
|
|
await loadSandboxedPlugin(plugin({ id: 'user-plugin', managed: false }));
|
|
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
|
|
expect(lastStatus()).toMatchObject({ status: 'error', error: expect.stringMatching(/integrity mismatch/) });
|
|
});
|
|
});
|