Spaces:
Runtime error
Runtime error
| import * as path from 'path'; | |
| import { resolvePluginMainPath, buildSandboxWorkerEnv, dispatchConversationMedia } from './plugin-loader.service'; | |
| /** Regression lock: a plugin's manifest.main must not escape its plugin directory. */ | |
| describe('resolvePluginMainPath', () => { | |
| const dir = '/app/data/plugins'; | |
| it('allows a normal entry inside the plugin directory', () => { | |
| expect(resolvePluginMainPath(dir, 'my-plugin', 'index.js')).toBe(path.resolve(dir, 'my-plugin', 'index.js')); | |
| expect(resolvePluginMainPath(dir, 'my-plugin', 'dist/main.js')).toBe( | |
| path.resolve(dir, 'my-plugin', 'dist/main.js'), | |
| ); | |
| }); | |
| it('rejects a path-traversal escape (../../)', () => { | |
| expect(() => resolvePluginMainPath(dir, 'my-plugin', '../../etc/passwd')).toThrow(/escapes/); | |
| }); | |
| it('rejects an absolute path', () => { | |
| expect(() => resolvePluginMainPath(dir, 'my-plugin', '/etc/passwd')).toThrow(/escapes/); | |
| }); | |
| it('rejects climbing into a sibling plugin', () => { | |
| expect(() => resolvePluginMainPath(dir, 'my-plugin', '../other-plugin/evil.js')).toThrow(/escapes/); | |
| }); | |
| }); | |
| /** | |
| * Untrusted plugins run in a worker thread; the worker must NOT inherit the host's secrets. The | |
| * worker env is an allowlist, not a copy of process.env. | |
| */ | |
| describe('buildSandboxWorkerEnv', () => { | |
| it('forwards only the allowlisted vars and drops host secrets', () => { | |
| const env = buildSandboxWorkerEnv({ | |
| NODE_ENV: 'production', | |
| TZ: 'UTC', | |
| NODE_EXTRA_CA_CERTS: '/certs/ca.pem', | |
| API_MASTER_KEY: 'super-secret', | |
| API_KEY_PEPPER: 'pepper', | |
| DATABASE_PASSWORD: 'dbpw', | |
| DATABASE_URL: 'postgres://u:p@host/db', | |
| REDIS_URL: 'redis://u:p@host', | |
| DOCKER_HOST: 'tcp://0.0.0.0:2375', | |
| }); | |
| expect(env.NODE_ENV).toBe('production'); | |
| expect(env.TZ).toBe('UTC'); | |
| expect(env.NODE_EXTRA_CA_CERTS).toBe('/certs/ca.pem'); | |
| // Host secrets must never reach an untrusted plugin. | |
| expect(env.API_MASTER_KEY).toBeUndefined(); | |
| expect(env.API_KEY_PEPPER).toBeUndefined(); | |
| expect(env.DATABASE_PASSWORD).toBeUndefined(); | |
| expect(env.DATABASE_URL).toBeUndefined(); | |
| expect(env.REDIS_URL).toBeUndefined(); | |
| expect(env.DOCKER_HOST).toBeUndefined(); | |
| }); | |
| it('omits allowlisted keys that are unset rather than emitting undefined entries', () => { | |
| const env = buildSandboxWorkerEnv({ NODE_ENV: 'development' }); | |
| expect(env.NODE_ENV).toBe('development'); | |
| expect('TZ' in env).toBe(false); | |
| expect('NODE_EXTRA_CA_CERTS' in env).toBe(false); | |
| }); | |
| it('defaults NODE_ENV to production when the host has none', () => { | |
| expect(buildSandboxWorkerEnv({}).NODE_ENV).toBe('production'); | |
| }); | |
| }); | |
| /** conversation.send media types must route to the matching MessageService method (not a copy-paste sibling). */ | |
| describe('dispatchConversationMedia', () => { | |
| const svc = () => ({ | |
| sendImage: jest.fn().mockResolvedValue({ messageId: 'i' }), | |
| sendVideo: jest.fn().mockResolvedValue({ messageId: 'v' }), | |
| sendAudio: jest.fn().mockResolvedValue({ messageId: 'a' }), | |
| sendDocument: jest.fn().mockResolvedValue({ messageId: 'd' }), | |
| }); | |
| const opts = (type: 'image' | 'video' | 'audio' | 'file') => ({ | |
| chatId: 'c@c.us', | |
| url: 'https://cdn.example/m', | |
| caption: 'cap', | |
| type, | |
| }); | |
| it.each([ | |
| ['image', 'sendImage'], | |
| ['video', 'sendVideo'], | |
| ['audio', 'sendAudio'], | |
| ['file', 'sendDocument'], | |
| ] as const)('routes %s to %s with a url+caption DTO (no ptt)', async (type, method) => { | |
| const s = svc(); | |
| await dispatchConversationMedia(s, 's', opts(type)); | |
| expect(s[method]).toHaveBeenCalledWith('s', { chatId: 'c@c.us', url: 'https://cdn.example/m', caption: 'cap' }); | |
| // No sibling method is invoked for the wrong type. | |
| for (const other of ['sendImage', 'sendVideo', 'sendAudio', 'sendDocument'] as const) { | |
| if (other !== method) expect(s[other]).not.toHaveBeenCalled(); | |
| } | |
| }); | |
| it("routes 'voice' to sendAudio with ptt:true so it renders as a WhatsApp voice note", async () => { | |
| const s = svc(); | |
| await dispatchConversationMedia(s, 's', { chatId: 'c@c.us', url: 'https://cdn.example/n.ogg', type: 'voice' }); | |
| expect(s.sendAudio).toHaveBeenCalledWith('s', { | |
| chatId: 'c@c.us', | |
| url: 'https://cdn.example/n.ogg', | |
| caption: undefined, | |
| ptt: true, | |
| }); | |
| for (const other of ['sendImage', 'sendVideo', 'sendDocument'] as const) { | |
| expect(s[other]).not.toHaveBeenCalled(); | |
| } | |
| }); | |
| }); | |
| import * as fs from 'fs'; | |
| import * as os from 'os'; | |
| import { PluginLoaderService, seedConfigDefaults } from './plugin-loader.service'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { ModuleRef } from '@nestjs/core'; | |
| import { HookManager } from '../hooks'; | |
| import { PluginStorageService } from './plugin-storage.service'; | |
| import { IPlugin, PluginContext, PluginManifest, PluginStatus, PluginType } from './plugin.interfaces'; | |
| import { SearchProviderRegistry } from '../../modules/search/search-provider.registry'; | |
| import { WorkerThreadChannel } from './sandbox/worker-thread-channel'; | |
| import { PluginWorkerHost } from './sandbox/plugin-worker-host'; | |
| import { PluginLogLevel } from './sandbox/protocol'; | |
| describe('PluginLoaderService.registerBuiltInPlugin config', () => { | |
| function makeLoader(): PluginLoaderService { | |
| const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; | |
| const pluginStorage = { | |
| getPluginEntry: jest.fn().mockReturnValue(undefined), | |
| setPluginEntry: jest.fn(), | |
| getPluginConfig: jest.fn().mockReturnValue(null), | |
| getPluginSessions: jest.fn().mockReturnValue(undefined), | |
| getPluginSessionConfig: jest.fn().mockReturnValue(undefined), | |
| } as unknown as PluginStorageService; | |
| return new PluginLoaderService(configService, new HookManager(), pluginStorage, {} as unknown as ModuleRef); | |
| } | |
| const manifest: PluginManifest = { | |
| id: 'cfg-test', | |
| name: 'Cfg Test', | |
| version: '1.0.0', | |
| type: PluginType.ENGINE, | |
| main: 'index.ts', | |
| }; | |
| const instance = {} as unknown as IPlugin; | |
| it('stores the supplied config on the plugin instance', () => { | |
| const loader = makeLoader(); | |
| loader.registerBuiltInPlugin(manifest, instance, { sessionDataPath: '/d', puppeteer: { headless: false } }); | |
| expect(loader.getPlugin('cfg-test')?.config).toEqual({ sessionDataPath: '/d', puppeteer: { headless: false } }); | |
| }); | |
| it('defaults to an empty config when none is supplied (back-compat)', () => { | |
| const loader = makeLoader(); | |
| loader.registerBuiltInPlugin(manifest, instance); | |
| expect(loader.getPlugin('cfg-test')?.config).toEqual({}); | |
| }); | |
| }); | |
| describe('PluginLoaderService — enable/config persistence', () => { | |
| let tmpDir: string; | |
| let config: ConfigService; | |
| let storage: PluginStorageService; | |
| let loader: PluginLoaderService; | |
| const manifest: PluginManifest = { | |
| id: 'persist-test', | |
| name: 'Persist Test', | |
| version: '1.0.0', | |
| type: PluginType.EXTENSION, | |
| main: 'index.js', | |
| }; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-plugin-')); | |
| config = { get: (k: string) => (k === 'dataDir' ? tmpDir : undefined) } as unknown as ConfigService; | |
| storage = new PluginStorageService(config); | |
| loader = new PluginLoaderService(config, new HookManager(), storage, {} as unknown as ModuleRef); | |
| }); | |
| afterEach(() => { | |
| fs.rmSync(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('creates a complete INSTALLED registry entry on register so a status write persists across a restart', () => { | |
| loader.registerBuiltInPlugin(manifest, {}, { apiKey: 'default' }); | |
| const entry = storage.getPluginEntry('persist-test'); | |
| expect(entry).toMatchObject({ | |
| id: 'persist-test', | |
| status: PluginStatus.INSTALLED, | |
| builtIn: true, | |
| }); | |
| // The status write now lands (previously a silent no-op because no entry existed). | |
| storage.setPluginStatus('persist-test', PluginStatus.ENABLED); | |
| // Durable: a fresh storage instance re-reads registry.json (simulates a restart). | |
| expect(new PluginStorageService(config).getPluginStatus('persist-test')).toBe(PluginStatus.ENABLED); | |
| }); | |
| it('keeps using live env config for a built-in across restarts (the first snapshot must not freeze it)', () => { | |
| // Boot 1: register with one env-derived default, no operator edit. | |
| loader.registerBuiltInPlugin(manifest, {}, { execPath: '/old/chromium', headless: true }); | |
| // Boot 2: env changed (e.g. operator set PUPPETEER_EXECUTABLE_PATH on a new image) → the live value wins. | |
| const storage2 = new PluginStorageService(config); | |
| const loader2 = new PluginLoaderService(config, new HookManager(), storage2, {} as unknown as ModuleRef); | |
| loader2.registerBuiltInPlugin(manifest, {}, { execPath: '/new/chromium', headless: true }); | |
| expect(loader2.getPlugin('persist-test')?.config).toEqual({ execPath: '/new/chromium', headless: true }); | |
| }); | |
| it('reports a re-registered plugin as installed: registering never runs it, and the registry agrees', () => { | |
| loader.registerBuiltInPlugin(manifest, {}, {}); | |
| storage.setPluginStatus('persist-test', PluginStatus.ENABLED); // operator enabled it | |
| // Restart: re-register the built-in. | |
| const storage2 = new PluginStorageService(config); | |
| const loader2 = new PluginLoaderService(config, new HookManager(), storage2, {} as unknown as ModuleRef); | |
| loader2.registerBuiltInPlugin(manifest, {}, {}); | |
| // Runtime is INSTALLED (registering does not run the lifecycle) AND the registry agrees, so there | |
| // is no enabled/installed divergence. Restoring an operator-enabled plugin is a separate step that | |
| // happens at bootstrap and skips built-ins — see plugin-restore-on-boot.spec.ts (#856). | |
| expect(loader2.getPlugin('persist-test')?.status).toBe(PluginStatus.INSTALLED); | |
| expect(storage2.getPluginStatus('persist-test')).toBe(PluginStatus.INSTALLED); | |
| }); | |
| it('writes registry.json without group/other access (plugin config can hold secrets)', () => { | |
| loader.registerBuiltInPlugin(manifest, {}, { apiKey: 'secret' }); | |
| const registryPath = path.join(tmpDir, 'plugins', 'registry.json'); | |
| expect(fs.existsSync(registryPath)).toBe(true); | |
| if (process.platform !== 'win32') { | |
| const mode = fs.statSync(registryPath).mode & 0o777; | |
| expect(mode & 0o077).toBe(0); | |
| } | |
| }); | |
| it('restores the operator config on the next load instead of resetting to the default', () => { | |
| loader.registerBuiltInPlugin(manifest, {}, { apiKey: 'default' }); | |
| loader.updatePluginConfig('persist-test', { apiKey: 'operator-secret' }); | |
| expect(storage.getPluginConfig('persist-test')).toEqual({ apiKey: 'operator-secret' }); | |
| // Restart: re-register the built-in with its default config — the persisted operator config wins. | |
| const storage2 = new PluginStorageService(config); | |
| const loader2 = new PluginLoaderService(config, new HookManager(), storage2, {} as unknown as ModuleRef); | |
| loader2.registerBuiltInPlugin(manifest, {}, { apiKey: 'default' }); | |
| expect(loader2.getPlugin('persist-test')?.config).toEqual({ apiKey: 'operator-secret' }); | |
| }); | |
| }); | |
| describe('PluginLoaderService — engine mutual exclusion', () => { | |
| let tmpDir: string; | |
| let storage: PluginStorageService; | |
| const engineManifest = (id: string): PluginManifest => ({ | |
| id, | |
| name: id, | |
| version: '1.0.0', | |
| type: PluginType.ENGINE, | |
| main: 'index.js', | |
| }); | |
| const makeLoader = (activeEngine: string): PluginLoaderService => { | |
| const config = { | |
| get: (k: string) => (k === 'engine.type' ? activeEngine : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService; | |
| return new PluginLoaderService(config, new HookManager(), storage, {} as unknown as ModuleRef); | |
| }; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-eng-')); | |
| storage = new PluginStorageService({ | |
| get: (k: string) => (k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('rejects enabling an engine that is not the configured active engine', async () => { | |
| const loader = makeLoader('whatsapp-web.js'); | |
| loader.registerBuiltInPlugin(engineManifest('baileys'), {}); | |
| await expect(loader.enablePlugin('baileys')).rejects.toThrow(/active engine/i); | |
| // Rejected up front — the plugin stays INSTALLED (not flipped to ERROR). | |
| expect(loader.getPlugin('baileys')?.status).toBe(PluginStatus.INSTALLED); | |
| }); | |
| it('allows enabling the configured active engine', async () => { | |
| const loader = makeLoader('baileys'); | |
| loader.registerBuiltInPlugin(engineManifest('baileys'), {}); | |
| await loader.enablePlugin('baileys'); | |
| expect(loader.getPlugin('baileys')?.status).toBe(PluginStatus.ENABLED); | |
| }); | |
| }); | |
| describe('PluginLoaderService — uninstall', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let storage: PluginStorageService; | |
| let loader: PluginLoaderService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-uninst-')); | |
| pluginsDir = path.join(tmpDir, 'plugins'); | |
| fs.mkdirSync(pluginsDir, { recursive: true }); | |
| const config = { | |
| get: (k: string) => (k === 'plugins.dir' ? pluginsDir : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService; | |
| storage = new PluginStorageService(config); | |
| loader = new PluginLoaderService(config, new HookManager(), storage, {} as unknown as ModuleRef); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| const writeUserPlugin = (id: string): string => { | |
| const dir = path.join(pluginsDir, id); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(dir, 'manifest.json'), | |
| JSON.stringify({ id, name: id, version: '1.0.0', type: 'extension', main: 'index.js' }), | |
| ); | |
| fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = class {};'); | |
| return dir; | |
| }; | |
| it('removes the plugin directory, registry entry, and runtime instance', async () => { | |
| const dir = writeUserPlugin('user-plg'); | |
| loader.loadPlugin(dir); | |
| expect(storage.getPluginEntry('user-plg')).toBeDefined(); | |
| await loader.uninstallPlugin('user-plg'); | |
| expect(fs.existsSync(dir)).toBe(false); | |
| expect(storage.getPluginEntry('user-plg')).toBeUndefined(); | |
| expect(loader.getPlugin('user-plg')).toBeUndefined(); | |
| }); | |
| it('refuses to uninstall a built-in plugin', async () => { | |
| loader.registerBuiltInPlugin( | |
| { id: 'core-engine', name: 'Core', version: '1.0.0', type: PluginType.ENGINE, main: 'x.js' }, | |
| {}, | |
| ); | |
| await expect(loader.uninstallPlugin('core-engine')).rejects.toThrow(/built-in/i); | |
| }); | |
| it('rejects a plugin that declares ingress routes but omits the webhook:ingress permission', () => { | |
| // loadPlugin must run validateIngressManifest, so a malformed ingress declaration fails to load | |
| // rather than silently loading and becoming provisionable. | |
| const dir = path.join(pluginsDir, 'bad-ingress'); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(dir, 'manifest.json'), | |
| JSON.stringify({ | |
| id: 'bad-ingress', | |
| name: 'Bad Ingress', | |
| version: '1.0.0', | |
| type: 'extension', | |
| main: 'index.js', | |
| ingress: [{ route: 'events', signature: { headerName: 'X-Sig', scheme: 'hmac-sha256' } }], | |
| // permissions intentionally omitted → validateIngressManifest must reject | |
| }), | |
| ); | |
| fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = class {};'); | |
| expect(() => loader.loadPlugin(dir)).toThrow(/webhook:ingress/i); | |
| }); | |
| }); | |
| describe('PluginLoaderService — skips dot-prefixed directories on load (crash-leftover .bak)', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let loader: PluginLoaderService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-dotskip-')); | |
| pluginsDir = path.join(tmpDir, 'plugins'); | |
| fs.mkdirSync(pluginsDir, { recursive: true }); | |
| const config = { | |
| get: (k: string) => (k === 'plugins.dir' ? pluginsDir : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService; | |
| loader = new PluginLoaderService( | |
| config, | |
| new HookManager(), | |
| new PluginStorageService(config), | |
| {} as unknown as ModuleRef, | |
| ); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| const writePlugin = (dirName: string, id: string): void => { | |
| const dir = path.join(pluginsDir, dirName); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(dir, 'manifest.json'), | |
| JSON.stringify({ id, name: id, version: '1.0.0', type: 'extension', main: 'index.js' }), | |
| ); | |
| fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = class {};'); | |
| }; | |
| it('does not scan a crash-leftover .<id>.bak directory (no duplicate-id load race)', () => { | |
| writePlugin('svc-plg', 'svc-plg'); | |
| writePlugin('.svc-plg.bak', 'svc-plg'); // a leftover update backup carrying the SAME manifest id | |
| const loadSpy = jest.spyOn(loader, 'loadPlugin'); | |
| loader.onModuleInit(); | |
| const scanned = loadSpy.mock.calls.map(c => c[0]); | |
| expect(scanned).toContain(path.join(pluginsDir, 'svc-plg')); | |
| expect(scanned).not.toContain(path.join(pluginsDir, '.svc-plg.bak')); | |
| }); | |
| }); | |
| describe('PluginLoaderService — prunes registry ghosts of removed built-ins', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let loader: PluginLoaderService; | |
| let storage: PluginStorageService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-ghosts-')); | |
| pluginsDir = path.join(tmpDir, 'plugins'); | |
| fs.mkdirSync(pluginsDir, { recursive: true }); | |
| const config = { | |
| get: (k: string) => (k === 'plugins.dir' ? pluginsDir : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService; | |
| storage = new PluginStorageService(config); | |
| loader = new PluginLoaderService(config, new HookManager(), storage, {} as unknown as ModuleRef); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| const seedGhost = (id: string): void => { | |
| // A leftover directory with no manifest (code deleted) + a stale registry entry still claiming | |
| // the plugin is installed — the ghost state upgrades from <=0.6 are in. | |
| fs.mkdirSync(path.join(pluginsDir, id), { recursive: true }); | |
| storage.setPluginEntry({ | |
| id, | |
| type: 'extension', | |
| name: id, | |
| version: '1.0.0', | |
| status: 'installed', | |
| config: {}, | |
| builtIn: true, | |
| installedAt: new Date(), | |
| updatedAt: new Date(), | |
| } as never); | |
| }; | |
| it('prunes registry entries of legacy removed built-ins (auto-reply, translation) with no manifest', () => { | |
| seedGhost('auto-reply'); | |
| seedGhost('translation'); | |
| loader.onModuleInit(); | |
| expect(storage.getPluginEntry('auto-reply')).toBeUndefined(); | |
| expect(storage.getPluginEntry('translation')).toBeUndefined(); | |
| }); | |
| it('keeps a manifest-less NON-legacy plugin entry (config survives an unreadable dir)', () => { | |
| seedGhost('some-other-plugin'); | |
| loader.onModuleInit(); | |
| expect(storage.getPluginEntry('some-other-plugin')).toBeDefined(); | |
| }); | |
| }); | |
| describe('PluginLoaderService — loadPlugin seeds configSchema defaults', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let loader: PluginLoaderService; | |
| let storage: PluginStorageService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-seed-')); | |
| pluginsDir = path.join(tmpDir, 'plugins'); | |
| fs.mkdirSync(pluginsDir, { recursive: true }); | |
| const config = { | |
| get: (k: string) => (k === 'plugins.dir' ? pluginsDir : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService; | |
| storage = new PluginStorageService(config); | |
| loader = new PluginLoaderService(config, new HookManager(), storage, {} as unknown as ModuleRef); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('seeds defaults into the runtime instance and the persisted registry entry at load', () => { | |
| const dir = path.join(pluginsDir, 'seeded-plg'); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(dir, 'manifest.json'), | |
| JSON.stringify({ | |
| id: 'seeded-plg', | |
| name: 'seeded-plg', | |
| version: '1.0.0', | |
| type: 'extension', | |
| main: 'index.js', | |
| configSchema: { | |
| type: 'object', | |
| properties: { timezone: { type: 'string', default: 'UTC' } }, | |
| }, | |
| }), | |
| ); | |
| fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = class {};'); | |
| loader.onModuleInit(); | |
| expect(loader.getPlugin('seeded-plg')?.config).toEqual({ timezone: 'UTC' }); | |
| expect(storage.getPluginEntry('seeded-plg')?.config).toEqual({ timezone: 'UTC' }); | |
| }); | |
| }); | |
| describe('seedConfigDefaults', () => { | |
| const schema = { | |
| type: 'object' as const, | |
| properties: { | |
| timezone: { type: 'string' as const, default: 'UTC' }, | |
| cooldownSec: { type: 'number' as const, default: 3600 }, | |
| schedule: { type: 'object' as const, required: true }, // no default — must stay absent | |
| rules: { type: 'array' as const, default: [{ q: 'hi', a: 'hello' }] }, | |
| }, | |
| }; | |
| it('seeds schema defaults for absent keys only', () => { | |
| const out = seedConfigDefaults(schema, { timezone: 'Asia/Jakarta' }); | |
| expect(out).toEqual({ | |
| timezone: 'Asia/Jakarta', // explicit value wins | |
| cooldownSec: 3600, | |
| rules: [{ q: 'hi', a: 'hello' }], | |
| }); | |
| expect(out).not.toHaveProperty('schedule'); // required-without-default is never invented | |
| }); | |
| it('returns the input unchanged when nothing is missing (and no schema)', () => { | |
| const config = { timezone: 'UTC', cooldownSec: 1, rules: [] }; | |
| expect(seedConfigDefaults(schema, config)).toBe(config); | |
| expect(seedConfigDefaults(undefined, config)).toBe(config); | |
| }); | |
| it('deep-clones object/array defaults so runtime and persisted copies cannot share references', () => { | |
| const out = seedConfigDefaults(schema, {}); | |
| const again = seedConfigDefaults(schema, {}); | |
| expect(out.rules).toEqual(again.rules); | |
| expect(out.rules).not.toBe(again.rules); | |
| expect((out.rules as unknown[])[0]).not.toBe((again.rules as unknown[])[0]); | |
| }); | |
| it('null is an explicit value and is not overwritten by the default', () => { | |
| const out = seedConfigDefaults(schema, { timezone: null }); | |
| expect(out.timezone).toBeNull(); | |
| }); | |
| }); | |
| describe('PluginLoaderService — enable concurrency', () => { | |
| let tmpDir: string; | |
| let loader: PluginLoaderService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-enable-')); | |
| const config = { get: (k: string) => (k === 'dataDir' ? tmpDir : undefined) } as unknown as ConfigService; | |
| loader = new PluginLoaderService( | |
| config, | |
| new HookManager(), | |
| new PluginStorageService(config), | |
| {} as unknown as ModuleRef, | |
| ); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('rejects a racing second enable instead of double-running onEnable', async () => { | |
| let enableCount = 0; | |
| const instance = { | |
| onEnable: async (): Promise<void> => { | |
| enableCount++; | |
| await new Promise(resolve => setTimeout(resolve, 25)); // keep the first enable in flight | |
| }, | |
| } as unknown as IPlugin; | |
| loader.registerBuiltInPlugin( | |
| { id: 'race-plg', name: 'Race', version: '1.0.0', type: PluginType.EXTENSION, main: 'index.js' }, | |
| instance, | |
| ); | |
| const results = await Promise.allSettled([loader.enablePlugin('race-plg'), loader.enablePlugin('race-plg')]); | |
| // The first claims the lock and runs onEnable once; the second is rejected before any await. | |
| expect(enableCount).toBe(1); | |
| const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); | |
| expect(rejected).toHaveLength(1); | |
| expect(String(rejected[0].reason)).toMatch(/already being enabled/i); | |
| expect(loader.getPlugin('race-plg')?.status).toBe(PluginStatus.ENABLED); | |
| }); | |
| }); | |
| describe('PluginLoaderService — graceful shutdown (onModuleDestroy)', () => { | |
| let tmpDir: string; | |
| let loader: PluginLoaderService; | |
| const ext = (id: string): PluginManifest => ({ | |
| id, | |
| name: id, | |
| version: '1.0.0', | |
| type: PluginType.EXTENSION, | |
| main: 'index.js', | |
| }); | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-shutdown-')); | |
| const config = { get: (k: string) => (k === 'dataDir' ? tmpDir : undefined) } as unknown as ConfigService; | |
| loader = new PluginLoaderService( | |
| config, | |
| new HookManager(), | |
| new PluginStorageService(config), | |
| {} as unknown as ModuleRef, | |
| ); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('runs onDisable for every enabled plugin on shutdown, best-effort past a failure', async () => { | |
| const okDisable = jest.fn(() => Promise.resolve()); | |
| loader.registerBuiltInPlugin(ext('bad-plg'), { | |
| onDisable: () => Promise.reject(new Error('flush failed')), | |
| }); | |
| loader.registerBuiltInPlugin(ext('ok-plg'), { onDisable: okDisable }); | |
| await loader.enablePlugin('bad-plg'); | |
| await loader.enablePlugin('ok-plg'); | |
| await expect(loader.onModuleDestroy()).resolves.toBeUndefined(); | |
| // The failing plugin's onDisable error didn't block the other from being disabled. | |
| expect(okDisable).toHaveBeenCalledTimes(1); | |
| expect(loader.getPlugin('ok-plg')?.status).toBe(PluginStatus.DISABLED); | |
| }); | |
| }); | |
| describe('PluginLoaderService — enable-failure hook cleanup', () => { | |
| let tmpDir: string; | |
| let hooks: HookManager; | |
| let loader: PluginLoaderService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-enfail-')); | |
| const config = { get: (k: string) => (k === 'dataDir' ? tmpDir : undefined) } as unknown as ConfigService; | |
| hooks = new HookManager(); | |
| loader = new PluginLoaderService(config, hooks, new PluginStorageService(config), {} as unknown as ModuleRef); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('does not leak hook registrations when an enable attempt fails, so a later enable does not double-dispatch', async () => { | |
| let shouldThrow = true; | |
| const instance = { | |
| onEnable: (ctx: PluginContext): Promise<void> => { | |
| // The plugin subscribes a hook, then its enable fails (e.g. a transient connect timeout). | |
| ctx.registerHook('message:received', () => Promise.resolve({ continue: true })); | |
| return shouldThrow ? Promise.reject(new Error('transient onEnable failure')) : Promise.resolve(); | |
| }, | |
| } as unknown as IPlugin; | |
| loader.registerBuiltInPlugin( | |
| { id: 'flaky-plg', name: 'Flaky', version: '1.0.0', type: PluginType.EXTENSION, main: 'index.js' }, | |
| instance, | |
| ); | |
| // First enable fails AFTER the hook was registered → the registration must not survive. | |
| await expect(loader.enablePlugin('flaky-plg')).rejects.toThrow(/transient/); | |
| expect(loader.getPlugin('flaky-plg')?.status).toBe(PluginStatus.ERROR); | |
| // Retry succeeds. | |
| shouldThrow = false; | |
| await loader.enablePlugin('flaky-plg'); | |
| expect(loader.getPlugin('flaky-plg')?.status).toBe(PluginStatus.ENABLED); | |
| // Exactly one handler — the failed attempt left nothing behind. Without cleanup this is 2, | |
| // and every message:received would dispatch to the plugin twice. | |
| expect(hooks.getHookCount('message:received')).toBe(1); | |
| }); | |
| }); | |
| describe('PluginLoaderService.dispatchWebhookForInstance config delivery', () => { | |
| it('delivers the instance-session-resolved config to the sandbox host', async () => { | |
| const fakeInstanceService = { resolve: jest.fn().mockResolvedValue({ sessionScope: 'sess-1' }) }; | |
| const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; | |
| const pluginStorage = { | |
| getPluginEntry: jest.fn().mockReturnValue(undefined), | |
| setPluginEntry: jest.fn(), | |
| getPluginConfig: jest.fn().mockReturnValue(null), | |
| getPluginSessions: jest.fn().mockReturnValue(undefined), | |
| getPluginSessionConfig: jest.fn().mockReturnValue(undefined), | |
| } as unknown as PluginStorageService; | |
| const moduleRef = { get: jest.fn().mockReturnValue(fakeInstanceService) } as unknown as ModuleRef; | |
| const loader = new PluginLoaderService(configService, new HookManager(), pluginStorage, moduleRef); | |
| const internals = loader as unknown as { | |
| plugins: Map<string, unknown>; | |
| sandboxHosts: Map<string, { dispatchWebhook: jest.Mock }>; | |
| }; | |
| internals.plugins.set('chatwoot-adapter', { | |
| manifest: { | |
| id: 'chatwoot-adapter', | |
| sessionScoped: true, | |
| ingress: [{ route: 'chatwoot', signature: { scheme: 'none' } }], | |
| }, | |
| config: { baseUrl: 'base', accountId: 1 }, | |
| sessionConfig: { 'sess-1': { baseUrl: 'https://tenant1' } }, | |
| }); | |
| const dispatchWebhook = jest.fn().mockResolvedValue({ ok: true, status: 200 }); | |
| internals.sandboxHosts.set('chatwoot-adapter', { dispatchWebhook }); | |
| await loader.dispatchWebhookForInstance({ | |
| pluginId: 'chatwoot-adapter', | |
| instanceId: 'acct1', | |
| route: 'chatwoot', | |
| method: 'PATCH', | |
| deliveryId: 'd1', | |
| sessionId: 'sess-1', | |
| payload: { headers: {}, query: {}, body: '', rawBody: '' }, | |
| }); | |
| expect(fakeInstanceService.resolve).toHaveBeenCalledWith('chatwoot-adapter', 'acct1'); | |
| expect(dispatchWebhook).toHaveBeenCalledTimes(1); | |
| // Session override (tenant1) merged over the base — this is what makes an instance multi-tenant. | |
| expect(dispatchWebhook).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| config: { baseUrl: 'https://tenant1', accountId: 1 }, | |
| method: 'PATCH', | |
| verified: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| describe('PluginLoaderService — search-provider wiring', () => { | |
| function makeLoader(moduleRefGet: jest.Mock): PluginLoaderService { | |
| const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; | |
| const pluginStorage = { | |
| getPluginEntry: jest.fn().mockReturnValue(undefined), | |
| setPluginEntry: jest.fn(), | |
| setPluginStatus: jest.fn(), | |
| getPluginConfig: jest.fn().mockReturnValue(null), | |
| getPluginSessions: jest.fn().mockReturnValue(undefined), | |
| getPluginSessionConfig: jest.fn().mockReturnValue(undefined), | |
| createPluginStorage: jest | |
| .fn() | |
| .mockReturnValue({ get: jest.fn(), set: jest.fn(), delete: jest.fn(), list: jest.fn() }), | |
| } as unknown as PluginStorageService; | |
| return new PluginLoaderService(configService, new HookManager(), pluginStorage, { | |
| get: moduleRefGet, | |
| } as unknown as ModuleRef); | |
| } | |
| it('getSearchRegistry returns the registry when ModuleRef has it', () => { | |
| const registry = new SearchProviderRegistry(); | |
| const loader = makeLoader(jest.fn().mockReturnValue(registry)); | |
| expect((loader as unknown as { getSearchRegistry: () => unknown }).getSearchRegistry()).toBe(registry); | |
| }); | |
| it('getSearchRegistry returns undefined when ModuleRef has no registry (search disabled)', () => { | |
| const loader = makeLoader( | |
| jest.fn().mockImplementation(() => { | |
| throw new Error('not found'); | |
| }), | |
| ); | |
| expect((loader as unknown as { getSearchRegistry: () => unknown }).getSearchRegistry()).toBeUndefined(); | |
| }); | |
| it('disablePlugin unregisters the plugin’s search-provider entry', async () => { | |
| const registry = new SearchProviderRegistry(); | |
| registry.register({ id: 'plugin:disable-test', label: 'p', search: jest.fn(), health: jest.fn() }); | |
| const loader = makeLoader(jest.fn().mockReturnValue(registry)); | |
| const manifest: PluginManifest = { | |
| id: 'disable-test', | |
| name: 'Disable Test', | |
| version: '1.0.0', | |
| type: PluginType.EXTENSION, | |
| main: 'index.js', | |
| }; | |
| loader.registerBuiltInPlugin(manifest, {}); | |
| await loader.enablePlugin('disable-test'); // builtIn → enableInProcess, status→ENABLED | |
| expect(registry.list().map(p => p.id)).toContain('plugin:disable-test'); | |
| await loader.disablePlugin('disable-test'); | |
| expect(registry.list().map(p => p.id)).not.toContain('plugin:disable-test'); | |
| }); | |
| }); | |
| describe('PluginLoaderService — search-provider enable-failure cleanup', () => { | |
| jest.setTimeout(30000); | |
| let tmpDir: string; | |
| const BOOTSTRAP = path.resolve(__dirname, 'sandbox/worker-bootstrap.ts'); | |
| const TS_NODE_OPTS = JSON.stringify({ | |
| module: 'commonjs', | |
| moduleResolution: 'node', | |
| resolvePackageJsonExports: false, | |
| // TypeScript 6 rejects the legacy resolution pair unless acknowledged. Revisit before TS 7. | |
| ignoreDeprecations: '6.0', | |
| }); | |
| // Runs the REAL worker (ts-node) instead of the compiled dist bootstrap, so enableSandboxed | |
| // exercises its true load/lifecycle/catch path with a live worker thread. | |
| class RealWorkerLoader extends PluginLoaderService { | |
| protected createSandboxHost( | |
| capDispatcher?: (verb: string, args: unknown[]) => Promise<unknown>, | |
| onHookSubscribe?: (event: string, priority?: number) => void, | |
| onWebhookSubscribe?: (route: string) => void, | |
| onLog?: (level: PluginLogLevel, message: string, meta?: Record<string, unknown>) => void, | |
| runWithHookGuard?: (inFlightEvents: string[], run: () => Promise<unknown>) => Promise<unknown>, | |
| onSearchProviderRegister?: () => void, | |
| ): PluginWorkerHost { | |
| return new PluginWorkerHost( | |
| new WorkerThreadChannel({ | |
| workerEntry: BOOTSTRAP, | |
| execArgv: ['-r', 'ts-node/register/transpile-only'], | |
| env: { ...process.env, TS_NODE_COMPILER_OPTIONS: TS_NODE_OPTS }, | |
| }), | |
| capDispatcher, | |
| onHookSubscribe, | |
| onWebhookSubscribe, | |
| onLog, | |
| runWithHookGuard, | |
| undefined, | |
| onSearchProviderRegister, | |
| ); | |
| } | |
| } | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-search-ef-')); | |
| fs.mkdirSync(path.join(tmpDir, 'rt'), { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(tmpDir, 'rt', 'manifest.json'), | |
| JSON.stringify({ id: 'rt', name: 'RT', version: '1.0.0', type: 'EXTENSION', main: 'index.cjs' }), | |
| ); | |
| // Fixture: register a search provider, THEN throw in onEnable — so the host has received | |
| // search-provider-register (and activated the provider in auto mode) before enable fails. | |
| fs.writeFileSync( | |
| path.join(tmpDir, 'rt', 'index.cjs'), | |
| "module.exports = class { async onEnable(ctx) { ctx.registerSearchProvider(async () => ({ hits: [], total: 0, tookMs: 1, provider: 'plugin:rt' })); throw new Error('onEnable failed'); } };", | |
| ); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('unregisters the search provider when enable fails after registration (no dead active provider)', async () => { | |
| const registry = new SearchProviderRegistry(); | |
| registry.register({ id: 'builtin-fts', label: 'b', search: jest.fn(), health: jest.fn() }); | |
| const config = { | |
| get: (k: string) => | |
| k === 'search.provider' ? 'auto' : k === 'plugins.dir' || k === 'dataDir' ? tmpDir : undefined, | |
| } as unknown as ConfigService; | |
| const storage = new PluginStorageService(config); | |
| const loader = new RealWorkerLoader(config, new HookManager(), storage, { | |
| get: () => registry, | |
| } as unknown as ModuleRef); | |
| loader.loadPlugin(path.join(tmpDir, 'rt')); | |
| await expect(loader.enablePlugin('rt')).rejects.toThrow('onEnable failed'); | |
| // Registered mid-onEnable, then onEnable threw → the catch must unregister the dead provider. | |
| expect(registry.list().map(p => p.id)).not.toContain('plugin:rt'); | |
| expect(registry.active()?.id).toBe('builtin-fts'); | |
| }); | |
| }); | |
| describe('PluginLoaderService — search-provider worker-crash fallback', () => { | |
| jest.setTimeout(30000); | |
| let tmpDir: string; | |
| const BOOTSTRAP = path.resolve(__dirname, 'sandbox/worker-bootstrap.ts'); | |
| const TS_NODE_OPTS = JSON.stringify({ | |
| module: 'commonjs', | |
| moduleResolution: 'node', | |
| resolvePackageJsonExports: false, | |
| // TypeScript 6 rejects the legacy resolution pair unless acknowledged. Revisit before TS 7. | |
| ignoreDeprecations: '6.0', | |
| }); | |
| // Real ts-node worker (so enableSandboxed runs its true path) that captures the host so the test can | |
| // crash it. | |
| class CapturingLoader extends PluginLoaderService { | |
| lastHost?: PluginWorkerHost; | |
| protected createSandboxHost( | |
| capDispatcher?: (verb: string, args: unknown[]) => Promise<unknown>, | |
| onHookSubscribe?: (event: string, priority?: number) => void, | |
| onWebhookSubscribe?: (route: string) => void, | |
| onLog?: (level: PluginLogLevel, message: string, meta?: Record<string, unknown>) => void, | |
| runWithHookGuard?: (inFlightEvents: string[], run: () => Promise<unknown>) => Promise<unknown>, | |
| onSearchProviderRegister?: () => void, | |
| onWorkerExit?: (code: number, intentional: boolean) => void, | |
| ): PluginWorkerHost { | |
| const host = new PluginWorkerHost( | |
| new WorkerThreadChannel({ | |
| workerEntry: BOOTSTRAP, | |
| execArgv: ['-r', 'ts-node/register/transpile-only'], | |
| env: { ...process.env, TS_NODE_COMPILER_OPTIONS: TS_NODE_OPTS }, | |
| }), | |
| capDispatcher, | |
| onHookSubscribe, | |
| onWebhookSubscribe, | |
| onLog, | |
| runWithHookGuard, | |
| undefined, | |
| onSearchProviderRegister, | |
| onWorkerExit, | |
| ); | |
| this.lastHost = host; | |
| return host; | |
| } | |
| } | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-search-crash-')); | |
| fs.mkdirSync(path.join(tmpDir, 'ok'), { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(tmpDir, 'ok', 'manifest.json'), | |
| JSON.stringify({ id: 'ok', name: 'OK', version: '1.0.0', type: 'EXTENSION', main: 'index.cjs' }), | |
| ); | |
| fs.writeFileSync( | |
| path.join(tmpDir, 'ok', 'index.cjs'), | |
| "module.exports = class { async onEnable(ctx) { ctx.registerSearchProvider(async () => ({ hits: [], total: 0, tookMs: 1, provider: 'plugin:ok' })); } };", | |
| ); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('falls back to builtin-fts when the plugin worker crashes after a successful enable', async () => { | |
| const registry = new SearchProviderRegistry(); | |
| registry.register({ id: 'builtin-fts', label: 'b', search: jest.fn(), health: jest.fn() }); | |
| const config = { | |
| get: (k: string) => | |
| k === 'search.provider' ? 'auto' : k === 'plugins.dir' || k === 'dataDir' ? tmpDir : undefined, | |
| } as unknown as ConfigService; | |
| const storage = new PluginStorageService(config); | |
| const loader = new CapturingLoader(config, new HookManager(), storage, { | |
| get: () => registry, | |
| } as unknown as ModuleRef); | |
| loader.loadPlugin(path.join(tmpDir, 'ok')); | |
| await loader.enablePlugin('ok'); // registers + setActive -> active = plugin:ok | |
| expect(registry.active()?.id).toBe('plugin:ok'); | |
| // Worker crashes (unexpected exit) — terminate() emits the worker 'exit' event -> handleExit -> onWorkerExit. | |
| await loader.lastHost!.terminate(); | |
| await new Promise(resolve => setImmediate(resolve)); | |
| await new Promise(resolve => setImmediate(resolve)); | |
| expect(registry.list().map(p => p.id)).not.toContain('plugin:ok'); | |
| expect(registry.active()?.id).toBe('builtin-fts'); // fell back, not pinned to the dead plugin | |
| }); | |
| }); | |