Files
Toju/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts
2026-06-05 18:34:01 +02:00

711 lines
22 KiB
TypeScript

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<string, Record<string, unknown>>)).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<string, (...args: unknown[]) => unknown>;
await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError);
}
});
});
interface ServiceTestContext {
capabilities: PluginCapabilityService;
channels: ReturnType<typeof signal<Channel[]>>;
currentUser: ReturnType<typeof signal<User | null>>;
logger: {
debug: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
};
messages: ReturnType<typeof signal<Message[]>>;
realtime: {
onSignalingMessage: Subject<unknown>;
sendRawMessage: ReturnType<typeof vi.fn>;
};
room: ReturnType<typeof signal<Room | null>>;
service: PluginClientApiService;
storage: {
getLocal: ReturnType<typeof vi.fn>;
readClientData: ReturnType<typeof vi.fn>;
readServerData: ReturnType<typeof vi.fn>;
removeClientData: ReturnType<typeof vi.fn>;
removeLocal: ReturnType<typeof vi.fn>;
removeServerData: ReturnType<typeof vi.fn>;
setLocal: ReturnType<typeof vi.fn>;
writeClientData: ReturnType<typeof vi.fn>;
writeServerData: ReturnType<typeof vi.fn>;
};
store: {
dispatch: ReturnType<typeof vi.fn>;
};
uiRegistry: {
registerSlashCommand: ReturnType<typeof vi.fn>;
slashCommands: ReturnType<typeof vi.fn>;
};
voice: {
broadcastMessage: ReturnType<typeof vi.fn>;
getConnectedPeers: ReturnType<typeof vi.fn>;
setInputVolume: ReturnType<typeof vi.fn>;
setLocalStream: ReturnType<typeof vi.fn>;
setOutputVolume: ReturnType<typeof vi.fn>;
};
messageRevisions: {
broadcastRevision: ReturnType<typeof vi.fn>;
createSignedRevision: ReturnType<typeof vi.fn>;
persistRevision: ReturnType<typeof vi.fn>;
};
}
function createServiceTestContext(): ServiceTestContext {
installLocalStorageMock();
installBrowserMediaMocks();
const currentUser = signal<User | null>(createUser());
const users = signal<User[]>(currentUser() ? [currentUser() as User] : []);
const room = signal<Room | null>(createRoom());
const channels = signal<Channel[]>(room()?.channels ?? []);
const messages = signal<Message[]>([]);
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<MessageRevisionService['createSignedRevision']>[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<unknown>(),
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<unknown> {
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<void> {}
});
}
function installLocalStorageMock(): void {
const storage = new Map<string, string>();
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();
}
});
}