qwen_2.5_model / src /modules /plugins /plugins.service.spec.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
19.3 kB
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);
});
});