Spaces:
Runtime error
Runtime error
File size: 19,309 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 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 | 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);
});
});
|