209 lines
5.5 KiB
TypeScript
209 lines
5.5 KiB
TypeScript
import {
|
|
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
|
|
parseQqbotPluginManifest,
|
|
QqbotPluginManifestValidationError,
|
|
} from '../../../../src/modules/qqbot/plugin-platform/manifest';
|
|
|
|
const createValidManifest = () => ({
|
|
assets: [
|
|
{
|
|
key: 'song-card-bg',
|
|
path: 'assets/song-card-bg.png',
|
|
},
|
|
],
|
|
author: 'KT',
|
|
configSchema: {
|
|
properties: {
|
|
apiBaseUrl: {
|
|
default: 'https://example.com',
|
|
type: 'string',
|
|
},
|
|
},
|
|
type: 'object',
|
|
},
|
|
description: 'BangDream query operations',
|
|
entry: 'src/index.ts',
|
|
events: [
|
|
{
|
|
eventName: 'message.group',
|
|
handlerName: 'onGroupMessage',
|
|
key: 'bangdream.group-message',
|
|
name: '群消息事件',
|
|
},
|
|
],
|
|
homepage: 'https://example.com/bangdream',
|
|
license: 'MIT',
|
|
migrations: [
|
|
{
|
|
path: 'migrations/001-init.sql',
|
|
version: '1.0.0',
|
|
},
|
|
],
|
|
minApiSdkVersion: '1.0.0',
|
|
name: 'BangDream',
|
|
operations: [
|
|
{
|
|
aliases: ['/查歌', '/bd song'],
|
|
description: 'Search songs',
|
|
handlerName: 'searchSong',
|
|
inputSchema: {
|
|
properties: {
|
|
keyword: {
|
|
type: 'string',
|
|
},
|
|
},
|
|
type: 'object',
|
|
},
|
|
key: 'bangdream.song.search',
|
|
name: '查歌',
|
|
outputSchema: {
|
|
type: 'object',
|
|
},
|
|
permissions: ['qqbot.send', 'runtime.http', 'asset.read'],
|
|
timeoutMs: 5000,
|
|
},
|
|
],
|
|
permissions: ['qqbot.send', 'runtime.http', 'asset.read'],
|
|
pluginKey: 'bangdream',
|
|
runtime: {
|
|
maxConcurrency: 2,
|
|
memoryMb: 256,
|
|
timeoutMs: 8000,
|
|
workerType: 'node-worker',
|
|
},
|
|
version: '1.2.3',
|
|
});
|
|
|
|
const expectValidationError = (
|
|
manifest: unknown,
|
|
code: string,
|
|
path?: string,
|
|
) => {
|
|
try {
|
|
parseQqbotPluginManifest(manifest, { pluginRoot: 'D:/plugins/bangdream' });
|
|
throw new Error('Expected manifest validation to fail');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(QqbotPluginManifestValidationError);
|
|
expect((error as QqbotPluginManifestValidationError).issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code,
|
|
...(path ? { path } : {}),
|
|
}),
|
|
]),
|
|
);
|
|
}
|
|
};
|
|
|
|
describe('QQBot plugin manifest contract', () => {
|
|
it('parses a complete manifest and normalizes declared plugin capabilities', () => {
|
|
const manifest = parseQqbotPluginManifest(createValidManifest(), {
|
|
pluginRoot: 'D:/plugins/bangdream',
|
|
});
|
|
|
|
expect(manifest).toMatchObject({
|
|
entry: 'src/index.ts',
|
|
minApiSdkVersion: '1.0.0',
|
|
pluginKey: 'bangdream',
|
|
runtime: {
|
|
maxConcurrency: 2,
|
|
memoryMb: 256,
|
|
timeoutMs: 8000,
|
|
workerType: 'node-worker',
|
|
},
|
|
version: '1.2.3',
|
|
});
|
|
expect(manifest.permissions).toEqual([
|
|
'qqbot.send',
|
|
'runtime.http',
|
|
'asset.read',
|
|
]);
|
|
expect(manifest.operations[0]).toMatchObject({
|
|
aliases: ['/查歌', '/bd song'],
|
|
key: 'bangdream.song.search',
|
|
permissions: ['qqbot.send', 'runtime.http', 'asset.read'],
|
|
timeoutMs: 5000,
|
|
});
|
|
expect(manifest.events[0]).toMatchObject({
|
|
eventName: 'message.group',
|
|
key: 'bangdream.group-message',
|
|
});
|
|
expect(manifest.assets[0].path).toBe('assets/song-card-bg.png');
|
|
expect(manifest.migrations[0].path).toBe('migrations/001-init.sql');
|
|
expect(QQBOT_PLUGIN_ALLOWED_PERMISSIONS).toEqual(
|
|
expect.arrayContaining([
|
|
'asset.read',
|
|
'plugin.config.read',
|
|
'plugin.config.write',
|
|
'plugin.storage.read',
|
|
'plugin.storage.write',
|
|
'qqbot.event.receive',
|
|
'qqbot.send',
|
|
'runtime.http',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('rejects unknown permissions from both plugin and operation scopes', () => {
|
|
const manifest = createValidManifest();
|
|
manifest.permissions = ['qqbot.send', 'host.env.read'];
|
|
manifest.operations[0].permissions = ['qqbot.send', 'host.fs.read'];
|
|
|
|
expectValidationError(manifest, 'UNKNOWN_PERMISSION', 'permissions[1]');
|
|
expectValidationError(
|
|
manifest,
|
|
'UNKNOWN_PERMISSION',
|
|
'operations[0].permissions[1]',
|
|
);
|
|
});
|
|
|
|
it('rejects duplicate operation keys and duplicate event keys', () => {
|
|
const manifest = createValidManifest();
|
|
manifest.operations.push({
|
|
...manifest.operations[0],
|
|
handlerName: 'searchSongAgain',
|
|
});
|
|
manifest.events.push({
|
|
...manifest.events[0],
|
|
handlerName: 'onGroupMessageAgain',
|
|
});
|
|
|
|
expectValidationError(manifest, 'DUPLICATE_OPERATION_KEY', 'operations[1]');
|
|
expectValidationError(manifest, 'DUPLICATE_EVENT_KEY', 'events[1]');
|
|
});
|
|
|
|
it('rejects manifests without explicit runtime budgets', () => {
|
|
const manifest = createValidManifest();
|
|
manifest.runtime = {
|
|
workerType: 'node-worker',
|
|
} as typeof manifest.runtime;
|
|
delete manifest.operations[0].timeoutMs;
|
|
|
|
expectValidationError(manifest, 'MISSING_RUNTIME_BUDGET', 'runtime');
|
|
expectValidationError(
|
|
manifest,
|
|
'MISSING_OPERATION_TIMEOUT',
|
|
'operations[0].timeoutMs',
|
|
);
|
|
});
|
|
|
|
it('rejects package paths outside the plugin root', () => {
|
|
const manifest = createValidManifest();
|
|
manifest.entry = '../outside.ts';
|
|
manifest.assets[0].path = 'assets/../../secret.txt';
|
|
manifest.migrations[0].path = 'C:/secrets/drop.sql';
|
|
|
|
expectValidationError(manifest, 'PATH_OUTSIDE_PLUGIN_ROOT', 'entry');
|
|
expectValidationError(
|
|
manifest,
|
|
'PATH_OUTSIDE_PLUGIN_ROOT',
|
|
'assets[0].path',
|
|
);
|
|
expectValidationError(
|
|
manifest,
|
|
'PATH_OUTSIDE_PLUGIN_ROOT',
|
|
'migrations[0].path',
|
|
);
|
|
});
|
|
});
|