import { Injector, signal } from '@angular/core'; import { Store } from '@ngrx/store'; import { Subject } from 'rxjs'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { ServerDirectoryFacade } from '../../../server-directory'; import { AttachmentFacade } from '../../../attachment'; import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade'; import type { Channel, Message, Room, TojuPluginManifest, User } from '../../../../shared-kernel'; import { MessagesActions } from '../../../../store/messages/messages.actions'; import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors'; import { selectActiveChannelId, selectCurrentRoom, selectCurrentRoomChannels, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors'; import { UsersActions } from '../../../../store/users/users.actions'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS, assertPluginApiSurfaceImplemented, collectPluginApiMethodPaths, type PluginClientApiMethodPath } from '../../domain/logic/plugin-client-api-surface.rules'; import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service'; import { MessageRevisionService } from '../../../chat/application/services/message-revision.service'; import { MessageSigningService } from '../../../authentication/application/services/message-signing.service'; import { PluginClientApiService } from './plugin-client-api.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginLoggerService } from './plugin-logger.service'; import { PluginMessageBusService } from './plugin-message-bus.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginUiRegistryService } from './plugin-ui-registry.service'; const TEST_MANIFEST = createTestManifest(); describe('PluginClientApiService', () => { let context: ServiceTestContext; beforeEach(() => { context = createServiceTestContext(); context.capabilities.grantAll(TEST_MANIFEST); }); it('implements the full public plugin API surface', () => { const api = context.service.createApi(TEST_MANIFEST); expect(() => assertPluginApiSurfaceImplemented(api as Record>)).not.toThrow(); }); it('freezes the API object returned to plugins', () => { const api = context.service.createApi(TEST_MANIFEST); expect(Object.isFrozen(api)).toBe(true); expect(Object.isFrozen(api.commands)).toBe(true); expect(Object.isFrozen(api.messages)).toBe(true); }); it('exposes current interaction context without capability checks', () => { const api = context.service.createApi(TEST_MANIFEST); expect(api.context.getCurrent()).toEqual({ server: context.room(), source: 'manual', textChannel: context.channels().find((channel) => channel.id === 'general') ?? null, user: context.currentUser(), voiceChannel: null }); }); it('routes logger calls to the plugin logger service', () => { const api = context.service.createApi(TEST_MANIFEST); api.logger.debug('debug'); api.logger.error('error'); api.logger.info('info'); api.logger.warn('warn'); expect(context.logger.debug).toHaveBeenCalledWith(TEST_MANIFEST.id, 'debug', undefined); expect(context.logger.error).toHaveBeenCalledWith(TEST_MANIFEST.id, 'error', undefined); expect(context.logger.info).toHaveBeenCalledWith(TEST_MANIFEST.id, 'info', undefined); expect(context.logger.warn).toHaveBeenCalledWith(TEST_MANIFEST.id, 'warn', undefined); }); it('dispatches profile updates through the users store', () => { const api = context.service.createApi(TEST_MANIFEST); api.profile.update({ displayName: 'Plugin User' }); api.profile.updateAvatar({ avatarHash: 'hash', avatarMime: 'image/png', avatarUrl: '/avatar.png' }); expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserProfile({ profile: expect.objectContaining({ displayName: 'Plugin User' }) })); expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserAvatar({ avatar: expect.objectContaining({ avatarUrl: '/avatar.png' }) })); }); it('sends plugin messages and broadcasts them to peers', async () => { const api = context.service.createApi(TEST_MANIFEST); const message = api.messages.send('hello plugin'); expect(message.content).toBe('hello plugin'); expect(message.roomId).toBe('room-1'); await new Promise((resolve) => setTimeout(resolve, 0)); expect(context.store.dispatch).toHaveBeenCalledWith( MessagesActions.sendMessageSuccess({ message: expect.objectContaining({ content: 'hello plugin', roomId: 'room-1' }) }) ); expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'chat-message', message: expect.objectContaining({ content: 'hello plugin', roomId: 'room-1' }) })); expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled(); }); it('publishes typing state through the realtime facade', () => { const api = context.service.createApi(TEST_MANIFEST); api.messages.setTyping(true, 'general'); expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({ type: 'typing', serverId: 'room-1', channelId: 'general', isTyping: true }); }); it('forwards slash command registration to the UI registry', () => { const api = context.service.createApi(TEST_MANIFEST); const contribution = { name: 'echo', run: () => {} }; api.commands.register('echo', contribution); expect(context.uiRegistry.registerSlashCommand).toHaveBeenCalledWith( TEST_MANIFEST.id, 'echo', contribution ); }); it('lists slash commands from the UI registry', () => { const commands = [{ name: 'echo', run: () => {} }]; context.uiRegistry.slashCommands.mockReturnValue(commands); const api = context.service.createApi(TEST_MANIFEST); expect(api.commands.list()).toBe(commands); }); it('routes storage APIs through the plugin storage service', async () => { const api = context.service.createApi(TEST_MANIFEST); api.storage.set('key', { ok: true }); api.storage.get('key'); api.storage.remove('key'); await api.clientData.write('client-key', { ok: true }); await api.clientData.read('client-key'); await api.clientData.remove('client-key'); await api.serverData.write('server-key', { ok: true }); await api.serverData.read('server-key'); await api.serverData.remove('server-key'); expect(context.storage.setLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key', { ok: true }); expect(context.storage.getLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key'); expect(context.storage.removeLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key'); expect(context.storage.writeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key', { ok: true }); expect(context.storage.readClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key'); expect(context.storage.removeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key'); expect(context.storage.writeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key', { ok: true }); expect(context.storage.readServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key'); expect(context.storage.removeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key'); }); it('publishes declared server events through the realtime facade', () => { const api = context.service.createApi(TEST_MANIFEST); api.events.publishServer('e2e:server', { ok: true }); expect(context.realtime.sendRawMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'plugin_event', eventName: 'e2e:server', payload: { ok: true }, pluginId: TEST_MANIFEST.id, serverId: 'room-1' })); }); it('rejects undeclared plugin events', () => { const api = context.service.createApi(TEST_MANIFEST); expect(() => api.events.publishServer('missing:event', {})).toThrow(/did not declare event/); }); it('enforces capability grants for privileged API methods', async () => { const api = context.service.createApi(TEST_MANIFEST); context.capabilities.revokeAll(TEST_MANIFEST.id); for (const path of collectPluginApiMethodPaths()) { const capability = PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS[path as PluginClientApiMethodPath]; if (!capability) { continue; } const [namespace, method] = path.split('.') as [keyof typeof api, string]; const target = api[namespace] as Record unknown>; await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError); } }); }); interface ServiceTestContext { capabilities: PluginCapabilityService; channels: ReturnType>; currentUser: ReturnType>; logger: { debug: ReturnType; error: ReturnType; info: ReturnType; warn: ReturnType; }; messages: ReturnType>; realtime: { onSignalingMessage: Subject; sendRawMessage: ReturnType; }; room: ReturnType>; service: PluginClientApiService; storage: { getLocal: ReturnType; readClientData: ReturnType; readServerData: ReturnType; removeClientData: ReturnType; removeLocal: ReturnType; removeServerData: ReturnType; setLocal: ReturnType; writeClientData: ReturnType; writeServerData: ReturnType; }; store: { dispatch: ReturnType; }; uiRegistry: { registerSlashCommand: ReturnType; slashCommands: ReturnType; }; voice: { broadcastMessage: ReturnType; getConnectedPeers: ReturnType; setInputVolume: ReturnType; setLocalStream: ReturnType; setOutputVolume: ReturnType; }; messageRevisions: { broadcastRevision: ReturnType; createSignedRevision: ReturnType; persistRevision: ReturnType; }; } function createServiceTestContext(): ServiceTestContext { installLocalStorageMock(); installBrowserMediaMocks(); const currentUser = signal(createUser()); const users = signal(currentUser() ? [currentUser() as User] : []); const room = signal(createRoom()); const channels = signal(room()?.channels ?? []); const messages = signal([]); const activeChannelId = signal('general'); const roomId = signal('room-1'); const logger = { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }; const storage = { getLocal: vi.fn(), setLocal: vi.fn(), removeLocal: vi.fn(), readClientData: vi.fn(async () => null), writeClientData: vi.fn(async () => undefined), removeClientData: vi.fn(async () => undefined), readServerData: vi.fn(async () => null), writeServerData: vi.fn(async () => undefined), removeServerData: vi.fn(async () => undefined) }; const uiRegistry = { registerSlashCommand: vi.fn(() => ({ dispose: vi.fn() })), slashCommands: vi.fn(() => []) }; const voice = { broadcastMessage: vi.fn(), getConnectedPeers: vi.fn(() => []), setInputVolume: vi.fn(), setLocalStream: vi.fn(async () => undefined), setOutputVolume: vi.fn() }; const messageRevisions = { createSignedRevision: vi.fn(async (input: Parameters[0]) => ({ messageId: input.message.id, revision: input.type === 'create' ? 0 : (input.message.revision ?? 0) + 1, prevRevisionHash: input.type === 'create' ? '' : (input.message.headHash ?? ''), headHash: 'test-head-hash', type: input.type, actorId: input.actorId, senderId: input.message.senderId, roomId: input.message.roomId, channelId: input.message.channelId, senderName: input.message.senderName, content: input.content ?? input.message.content, editedAt: input.editedAt, isDeleted: input.isDeleted ?? false, replyToId: input.message.replyToId, pluginId: input.pluginId, signature: input.sign === false ? undefined : 'test-signature' })), persistRevision: vi.fn(async () => undefined), broadcastRevision: vi.fn() }; const realtime = { onSignalingMessage: new Subject(), sendRawMessage: vi.fn() }; const store = { dispatch: vi.fn(), selectSignal: vi.fn((selector: unknown) => { if (selector === selectCurrentUser) { return currentUser; } if (selector === selectAllUsers) { return users; } if (selector === selectCurrentRoom) { return room; } if (selector === selectCurrentRoomChannels) { return channels; } if (selector === selectCurrentRoomMessages) { return messages; } if (selector === selectActiveChannelId) { return activeChannelId; } if (selector === selectCurrentRoomId) { return roomId; } throw new Error(`Unexpected selector in PluginClientApiService test: ${String(selector)}`); }) }; const injector = Injector.create({ providers: [ PluginClientApiService, PluginCapabilityService, { provide: AttachmentFacade, useValue: { publishAttachments: vi.fn(async () => undefined), rememberMessageRoom: vi.fn() } }, { provide: DatabaseService, useValue: { getMessageById: vi.fn(async () => null), saveMessage: vi.fn(async () => undefined), updateMessage: vi.fn(async () => undefined), updateRoom: vi.fn(async () => undefined) } }, { provide: MessageRevisionService, useValue: messageRevisions }, { provide: MessageSigningService, useValue: { signRevision: vi.fn(async () => 'test-signature'), fetchSigningPublicKey: vi.fn(async () => null), verifyRevisionSignature: vi.fn(async () => true) } }, { provide: PluginDesktopStateService, useValue: { readJson: vi.fn(async () => null), writeJson: vi.fn(async () => undefined) } }, { provide: PluginLoggerService, useValue: logger }, { provide: PluginMessageBusService, useValue: { publish: vi.fn(() => ({ topic: 'test' })), sendLatestMessages: vi.fn(() => ({ topic: 'test' })), subscribe: vi.fn(() => ({ dispose: vi.fn() })) } }, { provide: PluginStorageService, useValue: storage }, { provide: PluginUiRegistryService, useValue: uiRegistry }, { provide: RealtimeSessionFacade, useValue: { broadcastMessage: vi.fn(), onSignalingMessage: realtime.onSignalingMessage.asObservable(), sendRawMessage: realtime.sendRawMessage } }, { provide: ServerDirectoryFacade, useValue: { updateServer: vi.fn(() => ({ subscribe: vi.fn() })) } }, { provide: Store, useValue: store }, { provide: VoiceConnectionFacade, useValue: voice } ] }); return { capabilities: injector.get(PluginCapabilityService), channels, currentUser, logger, messages, realtime, room, service: injector.get(PluginClientApiService), storage, store, uiRegistry, voice, messageRevisions }; } function createTestManifest(): TojuPluginManifest { return { apiVersion: '1.0.0', capabilities: [ 'profile.read', 'profile.write', 'users.read', 'users.manage', 'roles.read', 'roles.manage', 'messages.read', 'messages.send', 'messages.editOwn', 'messages.deleteOwn', 'messages.moderate', 'messages.sync', 'channels.read', 'channels.manage', 'server.read', 'server.manage', 'p2p.data', 'media.playAudio', 'media.addAudioStream', 'media.addVideoStream', 'audio.volume', 'ui.settings', 'ui.pages', 'ui.sidePanel', 'ui.channelsSection', 'ui.embeds', 'ui.dom', 'ui.commands', 'storage.local', 'storage.serverData.read', 'storage.serverData.write', 'events.server.publish', 'events.server.subscribe', 'events.p2p.publish', 'events.p2p.subscribe' ], compatibility: { minimumTojuVersion: '1.0.0' }, description: 'Plugin API service test fixture', events: [ { direction: 'serverRelay', eventName: 'e2e:server', scope: 'server' } ], id: 'test.plugin-api', kind: 'client', schemaVersion: 1, title: 'Plugin API Service Fixture', version: '1.0.0' }; } function createUser(): User { return { displayName: 'Alice', id: 'user-1', isOnline: true, joinedAt: Date.now(), oderId: 'user-1', role: 'host', status: 'online', username: 'alice' }; } function createRoom(): Room { return { channels: [{ id: 'general', name: 'general', position: 0, type: 'text' }, { id: 'voice', name: 'voice', position: 1, type: 'voice' }], description: 'Plugin API room', hostId: 'user-1', id: 'room-1', isPrivate: false, members: [], name: 'Plugin API Room', roles: [] }; } async function invokeApiMethod(method: (...args: unknown[]) => unknown, path: string): Promise { switch (path) { case 'attachments.import': return method({ files: [], messageId: 'message-1' }); case 'channels.addAudioChannel': return method({ name: 'Audio' }); case 'channels.addTextChannel': return method({ name: 'Text' }); case 'channels.addVideoChannel': return method({ name: 'Video' }); case 'channels.remove': return method('general'); case 'channels.rename': return method('general', 'renamed'); case 'channels.select': return method('general'); case 'clientData.read': case 'serverData.read': return method('key'); case 'clientData.remove': case 'serverData.remove': return method('key'); case 'clientData.write': case 'serverData.write': return method('key', { ok: true }); case 'commands.register': return method('echo', { name: 'echo', run: () => {} }); case 'events.publishP2p': case 'events.publishServer': return method('e2e:server', {}); case 'events.subscribeP2p': case 'events.subscribeServer': return method({ eventName: 'e2e:server', handler: () => {} }); case 'media.addCustomAudioStream': case 'media.addCustomVideoStream': return method({ stream: new MediaStream() }); case 'media.playAudioClip': return method({ url: 'data:audio/wav;base64,' }); case 'media.setInputVolume': case 'media.setOutputVolume': return method(0.5); case 'messageBus.publish': return method({ topic: 'test' }); case 'messageBus.sendLatestMessages': return method({}); case 'messageBus.subscribe': return method({ handler: () => {} }); case 'messages.delete': return method('message-1'); case 'messages.edit': return method('message-1', 'updated'); case 'messages.moderateDelete': return method('message-1'); case 'messages.import': return method([]); case 'messages.send': return method('hello'); case 'messages.sendAsPluginUser': return method({ content: 'hello', pluginUserId: 'bot' }); case 'messages.setTyping': return method(true); case 'messages.subscribeTyping': return method(() => {}); case 'messages.sync': return method([]); case 'p2p.broadcastData': return method('e2e:server', {}); case 'p2p.sendData': return method('peer-1', 'e2e:server', {}); case 'profile.update': return method({ displayName: 'Updated' }); case 'profile.updateAvatar': return method({ avatarHash: 'hash', avatarMime: 'image/png', avatarUrl: '/avatar.png' }); case 'roles.setAssignments': return method([]); case 'server.registerPluginUser': return method({ displayName: 'Bot' }); case 'server.updateIcon': return method('icon-hash'); case 'server.updatePermissions': return method({ allowVoice: true }); case 'server.updateSettings': return method({ name: 'Room' }); case 'storage.get': return method('key'); case 'storage.remove': return method('key'); case 'storage.set': return method('key', { ok: true }); case 'ui.mountElement': return method('mount', { element: { tagName: 'DIV' }, target: 'body' }); case 'ui.registerAppPage': case 'ui.registerChannelSection': case 'ui.registerComposerAction': case 'ui.registerEmbedRenderer': case 'ui.registerProfileAction': case 'ui.registerSettingsPage': case 'ui.registerSidePanel': case 'ui.registerToolbarAction': return method('id', { label: 'Test', render: () => 'ok', run: () => {} }); case 'users.ban': return method('user-2', 'reason'); case 'users.kick': return method('user-2'); case 'users.setRole': return method('user-2', 'member'); default: return Promise.resolve(method()); } } function installBrowserMediaMocks(): void { vi.stubGlobal('MediaStream', class MediaStream {}); vi.stubGlobal('Audio', class Audio { volume = 1; async play(): Promise {} }); } function installLocalStorageMock(): void { const storage = new Map(); vi.stubGlobal('localStorage', { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => { storage.set(key, value); }, removeItem: (key: string) => { storage.delete(key); }, clear: () => { storage.clear(); } }); }