Spaces:
Runtime error
Runtime error
File size: 6,921 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | import { ConfigService } from '@nestjs/config';
import { ModuleRef } from '@nestjs/core';
import { PluginLoaderService } from './plugin-loader.service';
import { PluginStorageService } from './plugin-storage.service';
import { HookManager } from '../hooks';
import { IPlugin, PluginInstance, PluginManifest, PluginStatus, PluginType } from './plugin.interfaces';
import { PluginWorkerHost } from './sandbox/plugin-worker-host';
type FakeHost = { load: jest.Mock; runLifecycle: jest.Mock; terminate: jest.Mock };
/** Loader that returns fake worker hosts so routing is testable without spawning a real OS thread. */
class TestableLoader extends PluginLoaderService {
readonly hosts: FakeHost[] = [];
capturedOnHookSubscribe?: (event: string, priority?: number) => void;
protected createSandboxHost(
_capDispatcher?: (verb: string, args: unknown[]) => Promise<unknown>,
onHookSubscribe?: (event: string, priority?: number) => void,
): PluginWorkerHost {
this.capturedOnHookSubscribe = onHookSubscribe;
const host: FakeHost = {
load: jest.fn().mockResolvedValue(undefined),
runLifecycle: jest.fn().mockResolvedValue(undefined),
terminate: jest.fn().mockResolvedValue(undefined),
};
this.hosts.push(host);
return host as unknown as PluginWorkerHost;
}
}
function makeLoader(): TestableLoader {
const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService;
const pluginStorage = {
createPluginStorage: jest.fn().mockReturnValue({}),
getPluginConfig: jest.fn().mockReturnValue(undefined),
getPluginEntry: jest.fn().mockReturnValue(undefined),
setPluginEntry: jest.fn(),
setPluginStatus: jest.fn(),
} as unknown as PluginStorageService;
const moduleRef = { get: jest.fn() } as unknown as ModuleRef;
return new TestableLoader(configService, new HookManager(), pluginStorage, moduleRef);
}
const manifest = (): PluginManifest => ({
id: 'p1',
name: 'P1',
version: '1.0.0',
type: PluginType.EXTENSION,
main: 'index.js',
});
function seed(loader: TestableLoader, opts: { builtIn: boolean; instance: IPlugin | null }): void {
const plugin: PluginInstance = {
manifest: manifest(),
status: PluginStatus.INSTALLED,
config: {},
instance: opts.instance,
builtIn: opts.builtIn,
};
(loader as unknown as { plugins: Map<string, PluginInstance> }).plugins.set('p1', plugin);
}
const pluginOf = (loader: TestableLoader): PluginInstance =>
(loader as unknown as { plugins: Map<string, PluginInstance> }).plugins.get('p1') as PluginInstance;
describe('PluginLoaderService — sandbox tier routing', () => {
it('enables an untrusted plugin in a sandbox worker, not in-process', async () => {
const loader = makeLoader();
seed(loader, { builtIn: false, instance: null });
await loader.enablePlugin('p1');
expect(loader.hosts).toHaveLength(1);
expect(loader.hosts[0].load).toHaveBeenCalled();
expect(loader.hosts[0].runLifecycle).toHaveBeenCalledWith('onEnable', expect.any(Number));
const plugin = pluginOf(loader);
expect(plugin.status).toBe(PluginStatus.ENABLED);
expect(plugin.instance).toBeNull(); // the instance lives in the worker, never in-process
});
it('disables an untrusted plugin by running onDisable then terminating the worker', async () => {
const loader = makeLoader();
seed(loader, { builtIn: false, instance: null });
await loader.enablePlugin('p1');
const host = loader.hosts[0];
await loader.disablePlugin('p1');
expect(host.runLifecycle).toHaveBeenCalledWith('onDisable', expect.any(Number));
expect(host.terminate).toHaveBeenCalled();
expect(pluginOf(loader).status).toBe(PluginStatus.DISABLED);
});
it('force-terminates the sandbox worker even when onDisable rejects (e.g. times out)', async () => {
const loader = makeLoader();
seed(loader, { builtIn: false, instance: null });
await loader.enablePlugin('p1');
const host = loader.hosts[0];
host.runLifecycle.mockRejectedValueOnce(new Error("plugin worker lifecycle 'onDisable' timed out after 30000ms"));
await loader.disablePlugin('p1'); // resolves: disable is a force-teardown, not blocked by onDisable
expect(host.terminate).toHaveBeenCalled(); // worker killed despite the onDisable failure
expect(pluginOf(loader).status).toBe(PluginStatus.DISABLED);
// the host must be dropped so a misbehaving plugin can't leak its worker thread
expect((loader as unknown as { sandboxHosts: Map<string, unknown> }).sandboxHosts.has('p1')).toBe(false);
});
it('dedups duplicate hook-subscribe from the worker so a flood cannot grow the host registry', async () => {
const loader = makeLoader();
seed(loader, { builtIn: false, instance: null });
const hookManager = (loader as unknown as { hookManager: HookManager }).hookManager;
const registerSpy = jest.spyOn(hookManager, 'register');
await loader.enablePlugin('p1');
const onHookSubscribe = loader.capturedOnHookSubscribe;
expect(onHookSubscribe).toBeDefined();
// A hostile worker posts the same subscribe many times; the host must register it ONCE.
onHookSubscribe!('message:received');
onHookSubscribe!('message:received');
onHookSubscribe!('message:received');
expect(registerSpy.mock.calls.filter(c => c[1] === 'message:received')).toHaveLength(1);
// A genuinely distinct event still registers.
onHookSubscribe!('message:sending');
expect(registerSpy.mock.calls.filter(c => c[1] === 'message:sending')).toHaveLength(1);
});
it('drops fabricated (unknown) hook-subscribe events so the host registry cannot grow unbounded', async () => {
const loader = makeLoader();
seed(loader, { builtIn: false, instance: null });
const hookManager = (loader as unknown as { hookManager: HookManager }).hookManager;
const registerSpy = jest.spyOn(hookManager, 'register');
await loader.enablePlugin('p1');
const onHookSubscribe = loader.capturedOnHookSubscribe;
expect(onHookSubscribe).toBeDefined();
// A worker floods the boundary with fabricated event names — none may reach hookManager.register.
for (let i = 0; i < 1000; i++) onHookSubscribe!(`x:${i}`);
expect(registerSpy).not.toHaveBeenCalled();
// A real, known event still registers.
onHookSubscribe!('message:received');
expect(registerSpy.mock.calls.filter(c => c[1] === 'message:received')).toHaveLength(1);
});
it('enables a built-in plugin in-process (no sandbox worker spawned)', async () => {
const loader = makeLoader();
const onEnable = jest.fn().mockResolvedValue(undefined);
seed(loader, { builtIn: true, instance: { onEnable } });
await loader.enablePlugin('p1');
expect(loader.hosts).toHaveLength(0);
expect(onEnable).toHaveBeenCalled();
expect(pluginOf(loader).status).toBe(PluginStatus.ENABLED);
});
});
|