import { ConfigService } from '@nestjs/config'; import { ModuleRef } from '@nestjs/core'; import { PluginLoaderService } from './plugin-loader.service'; import { PluginStorageService } from './plugin-storage.service'; import { HookManager } from '../hooks'; import { PluginCapabilityError, PluginContext, PluginInstance, PluginManifest, PluginStatus, PluginType, } from './plugin.interfaces'; import { MessageService } from '../../modules/message/message.service'; import { SessionService } from '../../modules/session/session.service'; function makePlugin( sessions?: string[], permissions: string[] = ['messages:send', 'engine:read'], activeSessions?: string[], sessionScoped?: boolean, ): PluginInstance { const manifest: PluginManifest = { id: 'test-ext', name: 'Test Extension', version: '1.0.0', type: PluginType.EXTENSION, main: 'index.ts', sessions, permissions, sessionScoped, }; return { manifest, status: PluginStatus.INSTALLED, config: {}, instance: null, activeSessions }; } describe('PluginLoaderService capability facade — ctx.messages', () => { let loader: PluginLoaderService; let messageService: { sendText: jest.Mock; reply: jest.Mock }; let sessionService: { getEngine: jest.Mock }; let moduleRef: { get: jest.Mock }; beforeEach(() => { messageService = { sendText: jest.fn().mockResolvedValue({ messageId: 'wamid', timestamp: 1 }), reply: jest.fn().mockResolvedValue({ messageId: 'wamid', timestamp: 1 }), }; sessionService = { getEngine: jest.fn().mockReturnValue({}) }; // truthy live engine moduleRef = { get: jest .fn() .mockImplementation((token: unknown) => (token === SessionService ? sessionService : messageService)), }; const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; const pluginStorage = { createPluginStorage: jest.fn().mockReturnValue({}), } as unknown as PluginStorageService; loader = new PluginLoaderService( configService, new HookManager(), pluginStorage, moduleRef as unknown as ModuleRef, ); }); function contextFor(plugin: PluginInstance): PluginContext { return (loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext }).createPluginContext( plugin, ); } it('messages.sendText delegates to MessageService.sendText with a wrapped dto', async () => { const ctx = contextFor(makePlugin(['*'])); await ctx.messages.sendText('sess-1', '628@c.us', 'hi'); expect(moduleRef.get).toHaveBeenCalledWith(MessageService, { strict: false }); expect(messageService.sendText).toHaveBeenCalledWith('sess-1', { chatId: '628@c.us', text: 'hi' }); }); it('messages.reply delegates to MessageService.reply', async () => { const ctx = contextFor(makePlugin(['*'])); await ctx.messages.reply('sess-1', '628@c.us', 'quoted-id', 'pong'); expect(moduleRef.get).toHaveBeenCalledWith(MessageService, { strict: false }); expect(messageService.reply).toHaveBeenCalledWith('sess-1', { chatId: '628@c.us', quotedMessageId: 'quoted-id', text: 'pong', }); }); it('allows any session when manifest.sessions is absent (defaults to all)', async () => { const ctx = contextFor(makePlugin()); // no sessions field await ctx.messages.sendText('any-session', '628@c.us', 'hi'); expect(messageService.sendText).toHaveBeenCalledWith('any-session', { chatId: '628@c.us', text: 'hi' }); }); it('rejects an out-of-scope session BEFORE resolving the service', async () => { const ctx = contextFor(makePlugin(['allowed-session'])); await expect(ctx.messages.sendText('other-session', '628@c.us', 'hi')).rejects.toBeInstanceOf( PluginCapabilityError, ); expect(moduleRef.get).not.toHaveBeenCalled(); expect(messageService.sendText).not.toHaveBeenCalled(); }); it('denies sendText when the plugin is DEACTIVATED for the session, even if manifest.sessions is ["*"]', async () => { // manifest allows all sessions, but the operator activated the plugin only for sess-1 — a capability // call to sess-2 must be denied (per-session activation is a real boundary, not just a hook filter). const ctx = contextFor(makePlugin(['*'], ['messages:send', 'engine:read'], ['sess-1'])); await expect(ctx.messages.sendText('sess-2', '628@c.us', 'hi')).rejects.toBeInstanceOf(PluginCapabilityError); expect(moduleRef.get).not.toHaveBeenCalled(); expect(messageService.sendText).not.toHaveBeenCalled(); }); it('allows sendText when the plugin IS activated for the session', async () => { const ctx = contextFor(makePlugin(['*'], ['messages:send', 'engine:read'], ['sess-1'])); await ctx.messages.sendText('sess-1', '628@c.us', 'hi'); expect(messageService.sendText).toHaveBeenCalledWith('sess-1', { chatId: '628@c.us', text: 'hi' }); }); it('allows any session when activeSessions is undefined (operator never restricted it)', async () => { const ctx = contextFor(makePlugin(['*'], ['messages:send', 'engine:read'], undefined)); await ctx.messages.sendText('whatever', '628@c.us', 'hi'); expect(messageService.sendText).toHaveBeenCalledWith('whatever', { chatId: '628@c.us', text: 'hi' }); }); it('a global (sessionScoped:false) plugin is allowed on any session regardless of activeSessions', async () => { const ctx = contextFor(makePlugin(['*'], ['messages:send', 'engine:read'], [], false)); await ctx.messages.sendText('sess-9', '628@c.us', 'hi'); expect(messageService.sendText).toHaveBeenCalledWith('sess-9', { chatId: '628@c.us', text: 'hi' }); }); it('rejects sendText with PluginCapabilityError when the session has no active engine', async () => { sessionService.getEngine.mockReturnValue(undefined); const ctx = contextFor(makePlugin(['*'])); await expect(ctx.messages.sendText('dead-session', '628@c.us', 'hi')).rejects.toBeInstanceOf(PluginCapabilityError); expect(messageService.sendText).not.toHaveBeenCalled(); }); it('denies sendText when the plugin does not declare the messages:send permission', async () => { const ctx = contextFor(makePlugin(['*'], [])); // no permissions await expect(ctx.messages.sendText('sess-1', '628@c.us', 'hi')).rejects.toBeInstanceOf(PluginCapabilityError); expect(moduleRef.get).not.toHaveBeenCalled(); expect(messageService.sendText).not.toHaveBeenCalled(); }); it('denies reply when the plugin does not declare the messages:send permission', async () => { const ctx = contextFor(makePlugin(['*'], [])); await expect(ctx.messages.reply('sess-1', '628@c.us', 'q', 'hi')).rejects.toBeInstanceOf(PluginCapabilityError); expect(messageService.reply).not.toHaveBeenCalled(); }); }); describe('PluginLoaderService capability facade — ctx.engine', () => { let loader: PluginLoaderService; let moduleRef: { get: jest.Mock }; function build(getEngineReturn: unknown): { sessionService: { getEngine: jest.Mock } } { const sessionService = { getEngine: jest.fn().mockReturnValue(getEngineReturn) }; moduleRef = { get: jest.fn().mockReturnValue(sessionService) }; const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; const pluginStorage = { createPluginStorage: jest.fn().mockReturnValue({}), } as unknown as PluginStorageService; loader = new PluginLoaderService( configService, new HookManager(), pluginStorage, moduleRef as unknown as ModuleRef, ); return { sessionService }; } function contextFor(plugin: PluginInstance): PluginContext { return (loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext }).createPluginContext( plugin, ); } it('engine.getGroupInfo delegates to SessionService.getEngine(id).getGroupInfo', async () => { const engine = { getGroupInfo: jest.fn().mockResolvedValue({ id: 'g@g.us' }) }; const { sessionService } = build(engine); const ctx = contextFor(makePlugin(['*'])); await ctx.engine.getGroupInfo('sess-1', 'g@g.us'); expect(moduleRef.get).toHaveBeenCalledWith(SessionService, { strict: false }); expect(sessionService.getEngine).toHaveBeenCalledWith('sess-1'); expect(engine.getGroupInfo).toHaveBeenCalledWith('g@g.us'); }); it('throws PluginCapabilityError when the session has no active engine', async () => { build(undefined); const ctx = contextFor(makePlugin(['*'])); await expect(ctx.engine.getContacts('dead-session')).rejects.toBeInstanceOf(PluginCapabilityError); }); it('rejects an out-of-scope session before resolving the engine', async () => { const { sessionService } = build({ getChats: jest.fn() }); const ctx = contextFor(makePlugin(['allowed'])); await expect(ctx.engine.getChats('other')).rejects.toBeInstanceOf(PluginCapabilityError); expect(sessionService.getEngine).not.toHaveBeenCalled(); }); it('denies engine.getGroupInfo when the plugin does not declare the engine:read permission', async () => { const { sessionService } = build({ getGroupInfo: jest.fn() }); const ctx = contextFor(makePlugin(['*'], ['messages:send'])); // has messages, lacks engine:read await expect(ctx.engine.getGroupInfo('sess-1', 'g@g.us')).rejects.toBeInstanceOf(PluginCapabilityError); expect(sessionService.getEngine).not.toHaveBeenCalled(); }); it('denies engine reads when the plugin is deactivated for the session (activeSessions excludes it)', async () => { const { sessionService } = build({ getGroupInfo: jest.fn() }); const ctx = contextFor(makePlugin(['*'], ['engine:read'], ['sess-1'])); await expect(ctx.engine.getGroupInfo('sess-2', 'g@g.us')).rejects.toBeInstanceOf(PluginCapabilityError); expect(sessionService.getEngine).not.toHaveBeenCalled(); }); it('allows engine.getGroupInfo when the plugin declares engine:read', async () => { const engine = { getGroupInfo: jest.fn().mockResolvedValue({ id: 'g@g.us' }) }; build(engine); const ctx = contextFor(makePlugin(['*'], ['engine:read'])); await ctx.engine.getGroupInfo('sess-1', 'g@g.us'); expect(engine.getGroupInfo).toHaveBeenCalledWith('g@g.us'); }); it('engine.getChatHistory delegates to the engine and clamps the limit to 100', async () => { const engine = { getChatHistory: jest.fn().mockResolvedValue([]) }; build(engine); const ctx = contextFor(makePlugin(['*'], ['engine:read'])); await ctx.engine.getChatHistory('sess-1', 'c@c.us', 500, true); expect(engine.getChatHistory).toHaveBeenCalledWith('c@c.us', 100, true); // 500 clamped to 100 }); it('engine.getChatHistory defaults the limit and clamps a non-positive value to 1', async () => { const engine = { getChatHistory: jest.fn().mockResolvedValue([]) }; build(engine); const ctx = contextFor(makePlugin(['*'], ['engine:read'])); await ctx.engine.getChatHistory('sess-1', 'c@c.us'); // no limit → default 50, includeMedia → false await ctx.engine.getChatHistory('sess-1', 'c@c.us', 0); expect(engine.getChatHistory).toHaveBeenNthCalledWith(1, 'c@c.us', 50, false); expect(engine.getChatHistory).toHaveBeenNthCalledWith(2, 'c@c.us', 1, false); }); it('denies engine.getChatHistory without the engine:read permission', async () => { const { sessionService } = build({ getChatHistory: jest.fn() }); const ctx = contextFor(makePlugin(['*'], ['messages:send'])); await expect(ctx.engine.getChatHistory('sess-1', 'c@c.us')).rejects.toBeInstanceOf(PluginCapabilityError); expect(sessionService.getEngine).not.toHaveBeenCalled(); }); }); describe('PluginLoaderService capability facade — ctx.net', () => { function loaderWith(): PluginLoaderService { const configService = { get: jest.fn().mockReturnValue(undefined) } as unknown as ConfigService; const pluginStorage = { createPluginStorage: jest.fn().mockReturnValue({}) } as unknown as PluginStorageService; return new PluginLoaderService(configService, new HookManager(), pluginStorage, { get: jest.fn(), } as unknown as ModuleRef); } function netPlugin(permissions: string[], allow?: string[]): PluginInstance { const manifest: PluginManifest = { id: 'net-ext', name: 'Net Extension', version: '1.0.0', type: PluginType.EXTENSION, main: 'index.ts', permissions, net: allow ? { allow } : undefined, }; return { manifest, status: PluginStatus.INSTALLED, config: {}, instance: null }; } function contextFor(loader: PluginLoaderService, plugin: PluginInstance): PluginContext { return (loader as unknown as { createPluginContext: (p: PluginInstance) => PluginContext }).createPluginContext( plugin, ); } it('denies net.fetch when the plugin does not declare net:fetch', async () => { const ctx = contextFor(loaderWith(), netPlugin([], ['*'])); await expect(ctx.net.fetch('https://api.example.com/x')).rejects.toBeInstanceOf(PluginCapabilityError); }); it('denies net.fetch when the host is not in the manifest net.allow list', async () => { const ctx = contextFor(loaderWith(), netPlugin(['net:fetch'], ['only.example.com:443'])); await expect(ctx.net.fetch('https://api.example.com/x')).rejects.toBeInstanceOf(PluginCapabilityError); }); });