diff --git a/src/app/plugins/plugin-cache.service.ts b/src/app/plugins/plugin-cache.service.ts index 4886f4df65..ffd7b05fc8 100644 --- a/src/app/plugins/plugin-cache.service.ts +++ b/src/app/plugins/plugin-cache.service.ts @@ -8,6 +8,7 @@ export interface CachedPlugin { indexHtml?: string; icon?: string; translations?: Record; // language code -> JSON string + configSchema?: string; uploadDate: number; } @@ -57,6 +58,7 @@ export class PluginCacheService { indexHtml?: string, icon?: string, translations?: Record, + configSchema?: string, ): Promise { // Log plugin data sizes for debugging const translationsSize = translations @@ -105,6 +107,7 @@ export class PluginCacheService { indexHtml, icon, translations, + configSchema, uploadDate: Date.now(), }; diff --git a/src/app/plugins/plugin-config.service.spec.ts b/src/app/plugins/plugin-config.service.spec.ts new file mode 100644 index 0000000000..9a2746a956 --- /dev/null +++ b/src/app/plugins/plugin-config.service.spec.ts @@ -0,0 +1,139 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { PluginConfigService } from './plugin-config.service'; +import { PluginCacheService, CachedPlugin } from './plugin-cache.service'; +import { PluginUserPersistenceService } from './plugin-user-persistence.service'; +import { PluginManifest } from './plugin-api.model'; + +const BASE_MANIFEST: PluginManifest = { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + manifestVersion: 1, + minSupVersion: '1.0.0', + hooks: [], + permissions: [], + jsonSchemaCfg: 'config-schema.json', +}; + +const VALID_SCHEMA = { type: 'object', properties: { apiKey: { type: 'string' } } }; + +const makeCache = (overrides: Partial = {}): CachedPlugin => ({ + id: 'test-plugin', + manifest: JSON.stringify(BASE_MANIFEST), + code: 'console.log("test");', + uploadDate: Date.now(), + ...overrides, +}); + +describe('PluginConfigService', () => { + let service: PluginConfigService; + let cacheSpy: jasmine.SpyObj; + let httpSpy: jasmine.SpyObj; + + beforeEach(() => { + cacheSpy = jasmine.createSpyObj('PluginCacheService', ['getPlugin']); + httpSpy = jasmine.createSpyObj('HttpClient', ['get']); + + TestBed.configureTestingModule({ + providers: [ + PluginConfigService, + { provide: PluginCacheService, useValue: cacheSpy }, + { provide: HttpClient, useValue: httpSpy }, + { + provide: PluginUserPersistenceService, + useValue: jasmine.createSpyObj('PluginUserPersistenceService', [ + 'loadPluginUserData', + 'persistPluginUserData', + ]), + }, + ], + }); + + service = TestBed.inject(PluginConfigService); + }); + + describe('loadPluginConfigSchema - uploaded:// plugins', () => { + it('should return parsed schema from cache for uploaded plugin', async () => { + cacheSpy.getPlugin.and.resolveTo( + makeCache({ configSchema: JSON.stringify(VALID_SCHEMA) }), + ); + + const result = await service.loadPluginConfigSchema( + BASE_MANIFEST, + 'uploaded://test-plugin', + ); + + expect(cacheSpy.getPlugin).toHaveBeenCalledWith('test-plugin'); + expect(result).toEqual(VALID_SCHEMA as any); + }); + + it('should throw when configSchema is missing from cache', async () => { + cacheSpy.getPlugin.and.resolveTo(makeCache({ configSchema: undefined })); + + await expectAsync( + service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'), + ).toBeRejectedWithError(/No config schema found for uploaded plugin test-plugin/); + }); + + it('should throw when plugin is not in cache at all', async () => { + cacheSpy.getPlugin.and.resolveTo(null); + + await expectAsync( + service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'), + ).toBeRejectedWithError(/No config schema found for uploaded plugin test-plugin/); + }); + + it('should throw when configSchema contains invalid JSON', async () => { + cacheSpy.getPlugin.and.resolveTo(makeCache({ configSchema: '{ not valid json' })); + + await expectAsync( + service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'), + ).toBeRejected(); + }); + }); + + describe('loadPluginConfigSchema - bundled/remote plugins', () => { + it('should fetch schema via HTTP for non-uploaded plugins', async () => { + httpSpy.get.and.returnValue(of(VALID_SCHEMA) as any); + + const result = await service.loadPluginConfigSchema( + BASE_MANIFEST, + 'assets/bundled-plugins/test-plugin', + ); + + expect(httpSpy.get).toHaveBeenCalledWith( + 'assets/bundled-plugins/test-plugin/config-schema.json', + ); + expect(result).toEqual(VALID_SCHEMA as any); + }); + + it('should throw when HTTP fetch fails', async () => { + httpSpy.get.and.returnValue(throwError(() => new Error('404 Not Found')) as any); + + await expectAsync( + service.loadPluginConfigSchema( + BASE_MANIFEST, + 'assets/bundled-plugins/test-plugin', + ), + ).toBeRejected(); + }); + }); + + describe('loadPluginConfigSchema - guard conditions', () => { + it('should throw when manifest has no jsonSchemaCfg', async () => { + const manifest: PluginManifest = { ...BASE_MANIFEST, jsonSchemaCfg: undefined }; + + await expectAsync( + service.loadPluginConfigSchema(manifest, 'uploaded://test-plugin'), + ).toBeRejectedWithError('Plugin does not have a JSON schema configuration'); + }); + + it('should throw when pluginPath is empty', async () => { + await expectAsync( + service.loadPluginConfigSchema(BASE_MANIFEST, ''), + ).toBeRejectedWithError(/Plugin path not provided/); + }); + }); +}); diff --git a/src/app/plugins/plugin-config.service.ts b/src/app/plugins/plugin-config.service.ts index 6ccfc5717e..74e95cd4ff 100644 --- a/src/app/plugins/plugin-config.service.ts +++ b/src/app/plugins/plugin-config.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { JSONSchema7 } from 'json-schema'; import { PluginManifest } from './plugin-api.model'; import { PluginUserPersistenceService } from './plugin-user-persistence.service'; +import { PluginCacheService } from './plugin-cache.service'; import { PluginLog } from '../core/log'; import { first } from 'rxjs/operators'; @@ -17,6 +18,7 @@ interface PluginConfigData { export class PluginConfigService { private readonly _http = inject(HttpClient); private readonly _pluginUserPersistenceService = inject(PluginUserPersistenceService); + private readonly _pluginCacheService = inject(PluginCacheService); /** * Load the JSON schema configuration file for a plugin @@ -36,11 +38,16 @@ export class PluginConfigService { // Build the URL to the config schema file let schemaUrl: string; if (pluginPath.startsWith('uploaded://')) { - // For uploaded plugins, we need to fetch from the cached files - // This would require extending the cache service, but for now we'll throw an error - throw new Error( - 'Loading config schema for uploaded plugins is not yet implemented', - ); + const pluginId = pluginPath.replace('uploaded://', ''); + const cached = await this._pluginCacheService.getPlugin(pluginId); + if (!cached?.configSchema) { + throw new Error(`No config schema found for uploaded plugin ${manifest.id}`); + } + const schema = JSON.parse(cached.configSchema) as JSONSchema7; + if (!schema || typeof schema !== 'object') { + throw new Error('Invalid JSON schema format'); + } + return schema; } else { // For regular plugins, construct the URL schemaUrl = `${pluginPath}/${manifest.jsonSchemaCfg}`; diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index 0554ad017c..d68d999499 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -1072,6 +1072,19 @@ export class PluginService implements OnDestroy { } } + // Extract config schema if specified in manifest + let configSchema: string | undefined; + if (manifest.jsonSchemaCfg && extractedFiles[manifest.jsonSchemaCfg]) { + const schemaBytes = extractedFiles[manifest.jsonSchemaCfg]; + if (schemaBytes.length <= MAX_PLUGIN_MANIFEST_SIZE) { + configSchema = new TextDecoder().decode(schemaBytes); + } else { + PluginLog.err( + `Plugin config schema ${manifest.jsonSchemaCfg} is too large, skipping`, + ); + } + } + // Analyze plugin code (informational only - KISS approach) const codeAnalysis = this._pluginSecurity.analyzePluginCode(pluginCode, manifest); if (codeAnalysis.warnings.length > 0) { @@ -1106,6 +1119,8 @@ export class PluginService implements OnDestroy { pluginCode, indexHtml || undefined, iconContent || undefined, + undefined, + configSchema, ); // Store index.html content if it exists diff --git a/src/app/plugins/ui/plugin-config-dialog/plugin-config-dialog.component.ts b/src/app/plugins/ui/plugin-config-dialog/plugin-config-dialog.component.ts index 5549d48965..a2b1723bd9 100644 --- a/src/app/plugins/ui/plugin-config-dialog/plugin-config-dialog.component.ts +++ b/src/app/plugins/ui/plugin-config-dialog/plugin-config-dialog.component.ts @@ -53,6 +53,7 @@ interface PluginConfigData { [form]="form" [fields]="fields" [model]="model" + (modelChange)="model = $event" > }