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,