Files
EasyCLIProxyAPI/tests/plugins.test.ts
T
lzt404 d29a1af69b feat: add plugin management and redesign dashboard and usage analytics
- Add plugin store, version selection, configuration, and OAuth login
- Support Muse (Meta) OAuth device authorization
- Redesign home dashboard and add batch model health checks
- Support per-credential provider editing, removal, and reordering
- Improve usage charts and track actual response models
- Preserve installed plugins and runtime data during portable rebuilds
- Update translations, documentation, and related tests
2026-10-04 17:31:52 +08:00

273 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import {
mergePluginConfigPatch, normalizePluginList, normalizePluginStore, pluginsApi, pluginStoreApi,
} from '../src/services/plugins';
import {
buildRepositoryURL, collectPluginResourceEntries, getPluginConfirmToken, isOfficialPlugin,
isOfficialRepository, resolvePluginAssetURL,
} from '../src/services/pluginResources';
import {
buildGitHubReleasesPageURL, isValidManualReleaseTag, supportsPluginVersionSelection,
} from '../src/services/pluginReleaseVersions';
import { buildPluginConfigDraft, buildPluginConfigPatch } from '../src/services/pluginConfigDraft';
import { getPluginStatus, isPluginInstalled } from '../src/services/pluginStatus';
describe('plugin Management API', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
});
afterEach(() => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
it('preserves the latest unknown config fields and removes or replaces only edited fields', async () => {
const requests: unknown[] = [];
mockIPC((command, args) => {
expect(command).toBe('management_request');
const request = args?.request as { method: string; path: string; body?: unknown };
requests.push(request);
if (request.method === 'GET') return {
enabled: true, priority: 7, unknown: { concurrent: 'kept' },
options: { removed: 1, keep: 2 }, old: 'delete', list: [1, 2],
};
return { status: 'ok' };
});
await pluginsApi.patchConfig('sample..plugin', { options: { keep: 3 }, old: null, list: [], flag: false, zero: 0 });
expect(requests).toMatchObject([
{ method: 'GET', path: '/config/plugins/configs/sample%2E%2Eplugin' },
{ method: 'PUT', path: '/config/plugins/configs/sample%2E%2Eplugin', body: {
enabled: true, priority: 7, unknown: { concurrent: 'kept' },
options: { keep: 3 }, list: [], flag: false, zero: 0,
} },
]);
expect((requests[1] as { body: unknown }).body).not.toHaveProperty('old');
});
it('defaults only a missing configuration node and propagates transport, auth and endpoint errors', async () => {
mockIPC(() => { throw 'Management API error (404): not_found'; });
expect(await pluginsApi.getConfig('sample')).toEqual({});
for (const message of [
'Management API error (404): 404 page not found',
'Management API error (401): unauthorized',
'Management API request failed: connection refused',
]) {
mockIPC(() => { throw new Error(message); });
await expect(pluginsApi.patchConfig('sample', { label: 'changed' })).rejects.toThrow(message);
}
mockIPC(() => []);
await expect(pluginsApi.getConfig('sample')).rejects.toThrow('Invalid plugin configuration response');
});
it('updates global settings with PATCH so other plugin configs and auth revision survive', async () => {
let request: unknown;
mockIPC((_command, args) => { request = args?.request; return {}; });
await pluginsApi.updateSettings({ enabled: true, storeSources: [], storeAuth: [] });
expect(request).toMatchObject({ method: 'PATCH', path: '/config/plugins', body: {
enabled: true, 'store-sources': [], 'store-auth': [],
} });
expect((request as { body: unknown }).body).not.toHaveProperty('configs');
expect((request as { body: unknown }).body).not.toHaveProperty('dir');
});
it('reads global source/auth configuration without losing unknown auth fields', async () => {
mockIPC(() => ({ enabled: false, dir: 'custom/plugins', 'store-sources': ['https://store.test/index.json'],
'store-auth': [{ host: 'store.test', token: 'fixture-only', custom: true }], configs: {} }));
expect(await pluginsApi.getSettings()).toEqual({ enabled: false, dir: 'custom/plugins',
storeSources: ['https://store.test/index.json'],
storeAuth: [{ host: 'store.test', token: 'fixture-only', custom: true }],
});
});
it('toggles one instance, keeps source/version selection, and returns restart flags', async () => {
const requests: unknown[] = [];
mockIPC((_command, args) => {
requests.push(args?.request);
return { status: 'ok', id: 'sample', source_id: 'private/source', version: 'v1.2.3+test',
restart_required: true, file_deleted: true, configured_removed: true };
});
await pluginsApi.updateEnabled('sample', false);
const installed = await pluginStoreApi.install('sample', { sourceId: ' private/source ', version: ' v1.2.3+test ' });
const deleted = await pluginsApi.deletePlugin('sample');
expect(requests).toMatchObject([
{ method: 'PUT', path: '/config/plugins/configs/sample/enabled', body: false },
{ method: 'POST', path: '/plugins/store/sample/install', query: { source: 'private/source', version: 'v1.2.3+test' },
body: { version: 'v1.2.3+test' }, timeoutMs: 120_000 },
{ method: 'DELETE', path: '/plugins/sample' },
]);
expect(installed).toMatchObject({ sourceId: 'private/source', version: 'v1.2.3+test', restartRequired: true });
expect(deleted).toMatchObject({ fileDeleted: true, configuredRemoved: true, restartRequired: true });
});
it('rejects path traversal and separators before requests', async () => {
const requests: unknown[] = [];
mockIPC((_command, args) => { requests.push(args); return {}; });
for (const id of ['', '.', '..', '../config', 'a/b', 'a\\b', '\u0000', 'a b', 'a'.repeat(129)]) {
expect(() => pluginsApi.updateEnabled(id, true)).toThrow('Invalid plugin ID');
await expect(pluginStoreApi.install(id)).rejects.toThrow('Invalid plugin ID');
}
expect(requests).toEqual([]);
});
});
describe('plugin normalization and resources', () => {
it('does not treat the core configuration-only response as installed or loading', () => {
const response = normalizePluginList({ plugins_enabled: true, plugins_dir: 'plugins', plugins:
['example', 'kiro', 'model-fallback-router'].map(id => ({
id, path: '', configured: true, registered: false, enabled: true,
effective_enabled: false, metadata: null, config_fields: [], menus: [],
})),
});
expect(response.plugins.filter(isPluginInstalled)).toHaveLength(0);
expect(response.plugins.map(plugin => getPluginStatus(plugin, response.pluginsEnabled)))
.toEqual(['missingFile', 'missingFile', 'missingFile']);
expect(collectPluginResourceEntries(response.plugins)).toEqual([]);
const [configured] = response.plugins;
const discovered = { ...configured, path: 'plugins/windows/amd64/kiro-v0.1.1.dll' };
expect(getPluginStatus(discovered, true)).toBe('notLoaded');
expect(getPluginStatus(discovered, false)).toBe('globalDisabled');
expect(getPluginStatus({ ...discovered, enabled: false }, true)).toBe('inactive');
// A loaded library can stay registered after its on-disk file disappears.
// Keep it available for unloading instead of offering configuration removal.
const loaded = { ...configured, registered: true, effectiveEnabled: true };
expect(isPluginInstalled(loaded)).toBe(true);
expect(getPluginStatus(loaded, true)).toBe('active');
expect(getPluginStatus(loaded, false)).toBe('globalDisabled');
});
it('retains runtime capabilities, schema fallback and backend-declared resource paths', () => {
const result = normalizePluginList({ plugins_enabled: true, plugins_dir: '/plugins', plugins: [null, {}, {
id: 'sample', enabled: false, effective_enabled: true, registered: true,
supports_oauth: true, oauth_provider: 'custom-oauth', supports_quota: true, quota_provider: 'custom',
metadata: { name: 'Sample', config_fields: [{ name: 'count', type: 'integer' }] },
menus: [null, { path: '', menu: 'Unavailable' }, { path: '/v0/resource/plugins/sample/ui?view=usage', menu: 'Usage' }],
}] });
expect(result.plugins).toHaveLength(1);
expect(result.plugins[0]).toMatchObject({ enabled: false, supportsOAuth: true, oauthProvider: 'custom-oauth',
supportsQuota: true, quotaProvider: 'custom', configFields: [{ name: 'count', type: 'integer' }],
});
const [resource] = collectPluginResourceEntries(result.plugins);
expect(resource).toMatchObject({ pluginID: 'sample', label: 'Usage', menuIndex: 2 });
expect(resolvePluginAssetURL(resource.menu.path, 'http://127.0.0.1:8317')).toBe(
'http://127.0.0.1:8317/v0/resource/plugins/sample/ui?view=usage',
);
expect(collectPluginResourceEntries([{ ...result.plugins[0], effectiveEnabled: false }])).toEqual([]);
for (const path of ['javascript:alert(1)', 'data:text/html,<script>', '//evil.test/ui', '/\\evil.test/ui']) {
expect(resolvePluginAssetURL(path, 'http://127.0.0.1:8317')).toBe('');
}
});
it('keeps registry identity and failures when duplicate IDs exist across sources', () => {
const result = normalizePluginStore({ plugins: [
{ id: 'sample', source_id: 'official', installed: true, installed_source_id: 'official', install_source_status: 'match' },
{ id: 'sample', source_id: 'third-party', installed: false },
], source_errors: [{ source_id: 'offline', source_url: 'https://offline.test', message: 'Unavailable' }] });
expect(result.plugins.map((entry) => entry.storeId)).toEqual(['official/sample', 'third-party/sample']);
expect(result.plugins[0]).toMatchObject({ installedSourceId: 'official', installSourceStatus: 'match' });
expect(result.sourceErrors[0]).toMatchObject({ sourceId: 'offline', message: 'Unavailable' });
expect(() => normalizePluginStore({ error: 'unavailable' })).toThrow();
expect(() => normalizePluginList('not JSON')).toThrow();
});
});
describe('plugin installation trust and versions', () => {
it('requires the official source and a canonical official GitHub repository', () => {
for (const repository of ['router-for-me/sample', 'https://github.com/router-for-me/sample.git']) {
expect(isOfficialPlugin({ sourceId: 'official', repository })).toBe(true);
expect(isOfficialPlugin({ sourceId: 'untrusted', repository })).toBe(false);
}
for (const repository of [
'other/sample', 'https://github.com.evil.test/router-for-me/sample',
'https://github.com@evil.test/router-for-me/sample', 'https://evil.test/router-for-me/sample',
'http://github.com/router-for-me/sample', 'https://github.com/router-for-me/../other/sample',
'https://github.com/router-for-me/sample?redirect=evil', 'javascript:alert(1)',
]) expect(isOfficialRepository(repository)).toBe(false);
expect(buildRepositoryURL('javascript:alert(1)')).toBe('');
expect(getPluginConfirmToken({ repository: 'other/sample', id: 'plugin' })).toBe('other/sample');
expect(getPluginConfirmToken({ repository: '', id: 'plugin' })).toBe('plugin');
});
it('limits manual selection to GitHub releases and rejects unsafe tags', () => {
expect(supportsPluginVersionSelection(' GitHub-Release ')).toBe(true);
expect(supportsPluginVersionSelection('direct')).toBe(false);
for (const version of ['v1.2.3', '1.0.0-rc.1+build']) expect(isValidManualReleaseTag(version)).toBe(true);
for (const version of ['', '../v1', 'v1/next', 'x'.repeat(129), 'v1?foo=bar']) expect(isValidManualReleaseTag(version)).toBe(false);
expect(buildGitHubReleasesPageURL('router-for-me/sample.git')).toBe('https://github.com/router-for-me/sample/releases');
expect(buildGitHubReleasesPageURL('https://evil.test/router-for-me/sample')).toBe('');
});
});
describe('schema-driven plugin configuration', () => {
const fields = [
{ name: 'prompt', type: 'string', enumValues: [], description: '' },
{ name: 'items', type: 'array', enumValues: [], description: '' },
{ name: 'count', type: 'integer', enumValues: [], description: '' },
{ name: 'optional', type: 'boolean', enumValues: [], description: '' },
];
const t = (key: string) => key;
it('does not persist untouched defaults and preserves meaningful string whitespace', () => {
const draft = buildPluginConfigDraft({ enabled: false, configFields: fields }, {});
expect(buildPluginConfigPatch(draft, fields, t).patch).toEqual({});
draft.values.prompt = ' secret or prompt\n';
draft.touchedFields.prompt = true;
draft.touchedFields.optional = true;
expect(buildPluginConfigPatch(draft, fields, t).patch).toEqual({ prompt: ' secret or prompt\n', optional: false });
draft.values.prompt = '';
expect(buildPluginConfigPatch(draft, fields, t).patch.prompt).toBeNull();
});
it('validates array structure and safe integers, preserving mixed JSON data', () => {
const draft = buildPluginConfigDraft({ enabled: true, configFields: fields }, {});
draft.touchedFields.items = true;
draft.values.items = '{"not":"array"}';
draft.touchedFields.count = true;
draft.values.count = '9007199254740993';
draft.priorityTouched = true;
draft.priority = '1.5';
expect(buildPluginConfigPatch(draft, fields, t).errors).toEqual({
priority: 'plugin_management.invalid_priority', items: 'plugin_management.expected_array',
count: 'plugin_management.invalid_integer',
});
draft.values.items = '[0,false,{"nested":[1]}]';
draft.values.count = '0';
draft.priority = '-2';
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
patch: { priority: -2, items: [0, false, { nested: [1] }], count: 0 }, errors: {},
});
});
it('treats prototype-looking config keys as data', () => {
const current = JSON.parse('{"__proto__":{"keep":true},"constructor":"original","safe":true}');
const changes = JSON.parse('{"__proto__":{"next":true},"constructor":null}');
const merged = mergePluginConfigPatch(current, changes);
expect(Object.hasOwn(merged, '__proto__')).toBe(true);
expect(merged.__proto__).toEqual({ next: true });
expect(Object.hasOwn(merged, 'constructor')).toBe(false);
expect(Object.getPrototypeOf(merged)).toBe(Object.prototype);
const prototypeField = [{ name: '__proto__', type: 'string', enumValues: [], description: '' }];
const draft = buildPluginConfigDraft({ enabled: false, configFields: prototypeField }, current);
draft.values.__proto__ = 'literal';
draft.touchedFields.__proto__ = true;
expect(Object.hasOwn(buildPluginConfigPatch(draft, prototypeField, t).patch, '__proto__')).toBe(true);
const inheritedField = [{ name: 'constructor', type: 'string', enumValues: [], description: '' }];
const untouched = buildPluginConfigDraft({ enabled: false, configFields: inheritedField }, {});
untouched.touchedFields = { ...untouched.touchedFields };
expect(untouched.values.constructor).toBe('');
expect(Object.keys(buildPluginConfigPatch(untouched, inheritedField, t).patch)).toEqual([]);
});
it('keeps literal nulls in raw JSON while removing only explicitly deleted keys', () => {
expect(mergePluginConfigPatch({ latest: { preserved: true }, remove: 'old', nullable: 1 },
{ nullable: null, newNull: null }, { nullMeansDelete: false, removeKeys: ['remove'] })).toEqual({
latest: { preserved: true }, nullable: null, newNull: null,
});
expect(mergePluginConfigPatch({ nullable: 1 }, { nullable: null })).toEqual({});
});
});