Spaces:
Runtime error
Runtime error
| import * as fs from 'fs'; | |
| import * as os from 'os'; | |
| import * as path from 'path'; | |
| import AdmZip from 'adm-zip'; | |
| import { BadRequestException } from '@nestjs/common'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { ModuleRef } from '@nestjs/core'; | |
| import { PluginsService, isIngressCapable } from './plugins.service'; | |
| import { SECRET_SENTINEL } from './redact-config'; | |
| import { PluginLoaderService } from '../../core/plugins/plugin-loader.service'; | |
| import { PluginStorageService } from '../../core/plugins/plugin-storage.service'; | |
| import { PluginStatus } from '../../core/plugins/plugin.interfaces'; | |
| import { HookManager } from '../../core/hooks'; | |
| const manifest = { id: 'svc-plg', name: 'Svc Plugin', version: '1.0.0', type: 'extension', main: 'index.js' }; | |
| function pkg(over: Record<string, unknown> = {}): Buffer { | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify({ ...manifest, ...over }))); | |
| z.addFile('index.js', Buffer.from('module.exports = class {};')); | |
| return z.toBuffer(); | |
| } | |
| describe('PluginsService — install / uninstall (real loader + disk)', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let loader: PluginLoaderService; | |
| let service: PluginsService; | |
| let pluginStorage: PluginStorageService; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-svc-')); | |
| 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; | |
| pluginStorage = new PluginStorageService(config); | |
| loader = new PluginLoaderService(config, new HookManager(), pluginStorage, {} as unknown as ModuleRef); | |
| service = new PluginsService(loader, config); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('installs a valid package — writes the files, loads it, reports builtIn:false', () => { | |
| const dto = service.install({ buffer: pkg() }); | |
| expect(dto.id).toBe('svc-plg'); | |
| expect(dto.status).toBe('installed'); | |
| expect(dto.builtIn).toBe(false); | |
| expect(fs.existsSync(path.join(pluginsDir, 'svc-plg', 'index.js'))).toBe(true); | |
| expect(loader.getPlugin('svc-plg')).toBeDefined(); | |
| }); | |
| it('rejects an empty upload', () => { | |
| expect(() => service.install({ buffer: Buffer.alloc(0) })).toThrow(/no plugin file/i); | |
| }); | |
| it('rejects a duplicate install (already installed)', () => { | |
| service.install({ buffer: pkg() }); | |
| expect(() => service.install({ buffer: pkg() })).toThrow(/already installed/i); | |
| }); | |
| it('does not leave a directory behind when the package is invalid', () => { | |
| // Reserved id is rejected by the parser before anything is written. | |
| expect(() => service.install({ buffer: pkg({ id: 'baileys' }) })).toThrow(/reserved/i); | |
| expect(fs.existsSync(path.join(pluginsDir, 'baileys'))).toBe(false); | |
| }); | |
| it('uninstalls a user plugin — removes its files, registry entry, and runtime instance', async () => { | |
| service.install({ buffer: pkg() }); | |
| const res = await service.uninstall('svc-plg'); | |
| expect(res.success).toBe(true); | |
| expect(fs.existsSync(path.join(pluginsDir, 'svc-plg'))).toBe(false); | |
| expect(loader.getPlugin('svc-plg')).toBeUndefined(); | |
| }); | |
| it('uninstalling an unknown plugin throws NotFound', async () => { | |
| await expect(service.uninstall('nope')).rejects.toThrow(/not found/i); | |
| }); | |
| it('updatePackage swaps to the new version and preserves operator config', async () => { | |
| service.install({ buffer: pkg({ version: '1.0.0' }) }); | |
| service.updateConfig('svc-plg', { apiKey: 'secret-123' }); | |
| const dto = await service.updatePackage('svc-plg', pkg({ version: '2.0.0' })); | |
| expect(dto.version).toBe('2.0.0'); | |
| // Read view masks config for a schemaless plugin (fail-closed), but the stored value survived the update. | |
| expect(dto.config).toEqual({ apiKey: SECRET_SENTINEL }); | |
| expect(loader.getPlugin('svc-plg')?.config).toEqual({ apiKey: 'secret-123' }); | |
| expect(fs.existsSync(path.join(pluginsDir, 'svc-plg', 'index.js'))).toBe(true); | |
| expect(fs.existsSync(path.join(pluginsDir, '.svc-plg.bak'))).toBe(false); // backup cleaned up | |
| }); | |
| it('preserves ctx.storage state across an in-place package update', async () => { | |
| service.install({ buffer: pkg({ version: '1.0.0' }) }); | |
| const storage = pluginStorage.createPluginStorage('svc-plg'); | |
| await storage.set('cursor', { lastId: 'msg-42' }); | |
| await service.updatePackage('svc-plg', pkg({ version: '2.0.0' })); | |
| expect(await storage.get('cursor')).toEqual({ lastId: 'msg-42' }); | |
| const stateFile = fs.readdirSync(path.join(pluginsDir, 'svc-plg')).find(name => /^key-.*\.json$/.test(name)); | |
| expect(stateFile).toBeDefined(); | |
| if (process.platform !== 'win32') { | |
| expect(fs.statSync(path.join(pluginsDir, 'svc-plg', stateFile as string)).mode & 0o777).toBe(0o600); | |
| } | |
| }); | |
| it('updatePackage rejects a package whose id does not match', async () => { | |
| service.install({ buffer: pkg() }); | |
| await expect(service.updatePackage('svc-plg', pkg({ id: 'other-plg' }))).rejects.toThrow(/does not match/i); | |
| }); | |
| it('updatePackage on an unknown plugin throws NotFound', async () => { | |
| await expect(service.updatePackage('nope', pkg())).rejects.toThrow(/not found/i); | |
| }); | |
| it('rolls back to the OLD version (loaded, on disk) when the new version fails to enable', async () => { | |
| service.install({ buffer: pkg({ version: '1.0.0' }) }); | |
| // Pretend it was enabled so the update tries to re-enable — and that re-enable fails for the new version. | |
| loader.getPlugin('svc-plg')!.status = PluginStatus.ENABLED; | |
| const enableSpy = jest.spyOn(loader, 'enablePlugin').mockRejectedValue(new Error('worker failed to enable')); | |
| await expect(service.updatePackage('svc-plg', pkg({ version: '2.0.0' }))).rejects.toThrow(/Failed to update/i); | |
| // The rollback must leave the OLD version loaded — not the new, half-enabled (ERROR) instance. | |
| expect(loader.getPlugin('svc-plg')?.manifest.version).toBe('1.0.0'); | |
| expect(fs.existsSync(path.join(pluginsDir, '.svc-plg.bak'))).toBe(false); | |
| const onDisk = JSON.parse(fs.readFileSync(path.join(pluginsDir, 'svc-plg', 'manifest.json'), 'utf8')) as { | |
| version: string; | |
| }; | |
| expect(onDisk.version).toBe('1.0.0'); | |
| enableSpy.mockRestore(); | |
| }); | |
| it('serializes concurrent lifecycle operations on the same plugin id', async () => { | |
| service.install({ buffer: pkg() }); | |
| let resolveFirst: () => void = () => undefined; | |
| const firstDone = new Promise<void>(r => (resolveFirst = r)); | |
| let calls = 0; | |
| jest.spyOn(loader, 'uninstallPlugin').mockImplementation(() => { | |
| calls++; | |
| return calls === 1 ? firstDone : Promise.resolve(); | |
| }); | |
| const p1 = service.uninstall('svc-plg'); | |
| const p2 = service.uninstall('svc-plg'); | |
| await Promise.resolve(); | |
| await Promise.resolve(); | |
| expect(calls).toBe(1); // the second op is queued behind the first, not run concurrently | |
| resolveFirst(); | |
| await Promise.all([p1, p2]); | |
| expect(calls).toBe(2); | |
| }); | |
| // A literal link-local IP is rejected synchronously by the SSRF guard before any fetch/DNS, so this | |
| // is fully offline. The download path follows redirects, so the guard always runs (no opt-out flag); | |
| // the rejected-IP detail must be redacted from the surfaced BadRequestException (recon oracle). | |
| it('installFromUrl redacts the resolved internal IP when the SSRF guard blocks the URL', async () => { | |
| const err = await service.installFromUrl('https://169.254.169.254/pkg.zip').catch((e: unknown) => e); | |
| expect(err).toBeInstanceOf(BadRequestException); | |
| const message = (err as BadRequestException).message; | |
| expect(message).toMatch(/^Failed to download plugin from URL: /); | |
| expect(message).not.toMatch(/169\.254\.169\.254/); | |
| expect(message).toBe('Failed to download plugin from URL: Destination address is not allowed'); | |
| }); | |
| }); | |
| describe('PluginsService — getConfigUiHtml (sandboxed config editor)', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let loader: PluginLoaderService; | |
| let service: PluginsService; | |
| const HTML = '<!doctype html><title>cfg</title><script>parent.postMessage({type:"config:get"},"*")</script>'; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-cfgui-')); | |
| 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, | |
| ); | |
| service = new PluginsService(loader, config); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| function installUi(over: Record<string, unknown> = {}, files: Record<string, string> = {}): void { | |
| const z = new AdmZip(); | |
| z.addFile( | |
| 'manifest.json', | |
| Buffer.from(JSON.stringify({ ...manifest, id: 'cfgui-plg', configUi: { entry: 'config/index.html' }, ...over })), | |
| ); | |
| z.addFile('index.js', Buffer.from('module.exports = class {};')); | |
| for (const [p, c] of Object.entries(files)) z.addFile(p, Buffer.from(c)); | |
| service.install({ buffer: z.toBuffer() }); | |
| } | |
| it('serves the configUi entry HTML for an installed plugin', () => { | |
| installUi({}, { 'config/index.html': HTML }); | |
| expect(service.getConfigUiHtml('cfgui-plg')).toBe(HTML); | |
| }); | |
| it('exposes configUi on the DTO so the dashboard can render the iframe', () => { | |
| installUi({ configUi: { entry: 'config/index.html', height: 480 } }, { 'config/index.html': HTML }); | |
| expect(service.findOne('cfgui-plg').configUi).toEqual({ entry: 'config/index.html', height: 480 }); | |
| }); | |
| it('throws NotFound when the plugin does not exist', () => { | |
| expect(() => service.getConfigUiHtml('ghost')).toThrow(/not found/i); | |
| }); | |
| it('throws NotFound when the plugin declares no configUi', () => { | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify({ ...manifest, id: 'no-ui' }))); | |
| z.addFile('index.js', Buffer.from('module.exports = class {};')); | |
| service.install({ buffer: z.toBuffer() }); | |
| expect(() => service.getConfigUiHtml('no-ui')).toThrow(/config ui/i); | |
| }); | |
| it('throws NotFound when the entry file is missing from the package', () => { | |
| installUi({ configUi: { entry: 'config/missing.html' } }, { 'config/index.html': HTML }); | |
| expect(() => service.getConfigUiHtml('cfgui-plg')).toThrow(/not found/i); | |
| }); | |
| it('rejects a configUi entry that escapes the plugin directory (404, not a 500)', () => { | |
| installUi({ configUi: { entry: '../../../etc/passwd' } }, { 'config/index.html': HTML }); | |
| expect(() => service.getConfigUiHtml('cfgui-plg')).toThrow(/not found/i); | |
| }); | |
| it('rejects a non-string configUi entry from an untrusted manifest', () => { | |
| installUi({ configUi: { entry: 123 } }, { 'config/index.html': HTML }); | |
| expect(() => service.getConfigUiHtml('cfgui-plg')).toThrow(/config ui/i); | |
| }); | |
| it('rejects a configUi entry that is a symlink escaping the plugin directory', () => { | |
| installUi({ configUi: { entry: 'config/escape.html' } }, { 'config/index.html': HTML }); | |
| const outside = path.join(tmpDir, 'outside-secret.txt'); | |
| fs.writeFileSync(outside, 'TOP SECRET'); | |
| fs.symlinkSync(outside, path.join(pluginsDir, 'cfgui-plg', 'config', 'escape.html')); | |
| expect(() => service.getConfigUiHtml('cfgui-plg')).toThrow(/not found/i); | |
| }); | |
| }); | |
| describe('PluginsService — per-session config', () => { | |
| let tmpDir: string; | |
| let pluginsDir: string; | |
| let loader: PluginLoaderService; | |
| let service: PluginsService; | |
| const schemaManifest = { | |
| ...manifest, | |
| id: 'sess-cfg', | |
| configSchema: { | |
| type: 'object', | |
| properties: { apiKey: { type: 'string', secret: true }, lang: { type: 'string' } }, | |
| }, | |
| }; | |
| beforeEach(() => { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-sesscfg-')); | |
| 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, | |
| ); | |
| service = new PluginsService(loader, config); | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify(schemaManifest))); | |
| z.addFile('index.js', Buffer.from('module.exports = class {};')); | |
| service.install({ buffer: z.toBuffer() }); | |
| }); | |
| afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); | |
| it('stores a per-session override and exposes it (secrets redacted) on the DTO', () => { | |
| service.updateSessionConfig('sess-cfg', 'sess-A', { apiKey: 'A-secret', lang: 'he' }); | |
| const dto = service.findOne('sess-cfg'); | |
| expect(dto.sessionConfig).toEqual({ 'sess-A': { apiKey: '***', lang: 'he' } }); | |
| }); | |
| it('restores the stored per-session secret when the incoming value is the sentinel', () => { | |
| service.updateSessionConfig('sess-cfg', 'sess-A', { apiKey: 'A-secret', lang: 'he' }); | |
| // The dashboard PUTs the masked slice back; the real per-session secret must survive. | |
| service.updateSessionConfig('sess-cfg', 'sess-A', { apiKey: '***', lang: 'en' }); | |
| expect(loader.getPlugin('sess-cfg')?.sessionConfig?.['sess-A']).toEqual({ apiKey: 'A-secret', lang: 'en' }); | |
| }); | |
| it('keeps the base config and per-session overrides independent', () => { | |
| service.updateConfig('sess-cfg', { apiKey: 'BASE', lang: 'en' }); | |
| service.updateSessionConfig('sess-cfg', 'sess-A', { apiKey: 'A-secret', lang: 'he' }); | |
| const plugin = loader.getPlugin('sess-cfg'); | |
| expect(plugin?.config).toEqual({ apiKey: 'BASE', lang: 'en' }); | |
| expect(plugin?.sessionConfig?.['sess-A']).toEqual({ apiKey: 'A-secret', lang: 'he' }); | |
| }); | |
| // A session-restricted API key must not activate the plugin for sessions outside its allowedSessions | |
| // scope (the target sessions are in the request body the guard never inspects). | |
| it('rejects activating for a session outside a restricted key scope', () => { | |
| expect(() => service.updateSessions('sess-cfg', ['sess-B'], ['sess-A'])).toThrow(/not authorized/i); | |
| expect(() => service.updateSessions('sess-cfg', ['*'], ['sess-A'])).toThrow(/not authorized/i); | |
| }); | |
| it('allows a restricted key to activate only within its scope', () => { | |
| expect(service.updateSessions('sess-cfg', ['sess-A'], ['sess-A']).activeSessions).toEqual(['sess-A']); | |
| }); | |
| it('lets an unrestricted key activate for all sessions', () => { | |
| expect(service.updateSessions('sess-cfg', ['*'], undefined).activeSessions).toEqual(['*']); | |
| expect(service.updateSessions('sess-cfg', ['*'], []).activeSessions).toEqual(['*']); | |
| }); | |
| it('clears the override when an empty slice is written', () => { | |
| service.updateSessionConfig('sess-cfg', 'sess-A', { lang: 'he' }); | |
| service.updateSessionConfig('sess-cfg', 'sess-A', {}); | |
| expect(loader.getPlugin('sess-cfg')?.sessionConfig?.['sess-A']).toBeUndefined(); | |
| }); | |
| it('404s for an unknown plugin', () => { | |
| expect(() => service.updateSessionConfig('ghost', 'sess-A', { lang: 'he' })).toThrow(/not found/i); | |
| }); | |
| it('rejects per-session config for a global (non-session-scoped) plugin with 400', () => { | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify({ ...manifest, id: 'global-plg', sessionScoped: false }))); | |
| z.addFile('index.js', Buffer.from('module.exports = class {};')); | |
| service.install({ buffer: z.toBuffer() }); | |
| expect(() => service.updateSessionConfig('global-plg', 'sess-A', { lang: 'he' })).toThrow(BadRequestException); | |
| }); | |
| // A reload rebuilds the registry entry; it must NOT drop the operator's per-session config or | |
| // active-session selection. The wipe only surfaces on the SECOND restart (the first still has the | |
| // pre-wipe in-memory copy), so exercise two reload cycles. | |
| it('preserves per-session config and active sessions across two restarts', () => { | |
| const pluginDir = path.join(pluginsDir, 'sess-cfg'); | |
| service.updateSessionConfig('sess-cfg', 'sess-A', { lang: 'he' }); | |
| loader.setPluginSessions('sess-cfg', ['sess-A']); | |
| const reload = (): PluginLoaderService => { | |
| const l = new PluginLoaderService( | |
| { | |
| get: (k: string) => (k === 'plugins.dir' ? pluginsDir : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService, | |
| new HookManager(), | |
| new PluginStorageService({ | |
| get: (k: string) => (k === 'plugins.dir' ? pluginsDir : k === 'dataDir' ? tmpDir : undefined), | |
| } as unknown as ConfigService), | |
| {} as unknown as ModuleRef, | |
| ); | |
| l.loadPlugin(pluginDir); | |
| return l; | |
| }; | |
| const boot2 = reload(); | |
| expect(boot2.getPlugin('sess-cfg')?.sessionConfig?.['sess-A']).toEqual({ lang: 'he' }); | |
| expect(boot2.getPlugin('sess-cfg')?.activeSessions).toEqual(['sess-A']); | |
| const boot3 = reload(); | |
| expect(boot3.getPlugin('sess-cfg')?.sessionConfig?.['sess-A']).toEqual({ lang: 'he' }); | |
| expect(boot3.getPlugin('sess-cfg')?.activeSessions).toEqual(['sess-A']); | |
| }); | |
| }); | |
| describe('PluginsService i18n passthrough', () => { | |
| function build(manifestI18n: unknown) { | |
| const plugin = { | |
| manifest: { id: 'p', name: 'P', version: '1.0.0', type: 'extension', main: 'dist/index.js', i18n: manifestI18n }, | |
| status: 'enabled', | |
| config: {}, | |
| activeSessions: ['*'], | |
| }; | |
| const loader = { | |
| getAllPlugins: () => [plugin], | |
| getPlugin: () => plugin, | |
| isBuiltIn: () => false, | |
| } as unknown as PluginLoaderService; | |
| return new PluginsService(loader, { get: () => undefined } as unknown as ConfigService); | |
| } | |
| it('surfaces manifest.i18n on the DTO (findOne + findAll)', () => { | |
| const i18n = { es: { name: 'P-es', config: { k: { title: 'T-es' } } } }; | |
| const svc = build(i18n); | |
| expect(svc.findOne('p').i18n).toEqual(i18n); | |
| expect(svc.findAll()[0].i18n).toEqual(i18n); | |
| }); | |
| it('leaves i18n undefined when the manifest has none', () => { | |
| const svc = build(undefined); | |
| expect(svc.findOne('p').i18n).toBeUndefined(); | |
| }); | |
| }); | |
| describe('isIngressCapable', () => { | |
| it('is true when the manifest has an ingress route AND the webhook:ingress permission', () => { | |
| expect(isIngressCapable({ ingress: [{ route: 'events' }], permissions: ['webhook:ingress'] })).toBe(true); | |
| }); | |
| it('is false without an ingress route', () => { | |
| expect(isIngressCapable({ ingress: [], permissions: ['webhook:ingress'] })).toBe(false); | |
| expect(isIngressCapable({ permissions: ['webhook:ingress'] })).toBe(false); | |
| }); | |
| it('is false without the webhook:ingress permission', () => { | |
| expect(isIngressCapable({ ingress: [{ route: 'events' }], permissions: [] })).toBe(false); | |
| expect(isIngressCapable({ ingress: [{ route: 'events' }] })).toBe(false); | |
| }); | |
| }); | |