// @vitest-environment node import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; import type { HostApiContext } from '../../electron/api/context'; import { handlePluginMarketplaceRoutes } from '../../electron/api/routes/plugin-marketplace'; import { MarketplaceClientError } from '../../electron/coding-plugins/marketplace-client'; import { PluginPackageStoreError } from '../../electron/coding-plugins/package-store'; function request(method: string, body?: unknown): IncomingMessage { const req = new EventEmitter(); const raw = body === undefined ? undefined : JSON.stringify(body); Object.assign(req, { method, headers: raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw)) }, [Symbol.asyncIterator]: async function* () { if (raw !== undefined) yield Buffer.from(raw); }, }); return req as IncomingMessage; } function response() { const chunks: string[] = []; const res = new EventEmitter(); Object.assign(res, { statusCode: 0, setHeader: vi.fn(), end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), }); return { res: res as unknown as ServerResponse, get status() { return (res as { statusCode: number }).statusCode; }, json: () => JSON.parse(chunks.join('')) as Record, }; } async function invoke(ctx: HostApiContext, method: string, target: string, body?: unknown) { const output = response(); const handled = await handlePluginMarketplaceRoutes( request(method, body), output.res, new URL(`http://localhost${target}`), ctx, ); return { handled, status: output.status, payload: output.json() }; } describe('Main-owned plugin Marketplace routes', () => { it('returns the joined Library/install projection without device or account authority', async () => { const projection = { library: { items: [], total: 0, stale: false, fetchedAt: 1 }, installations: [{ status: 'installed', pluginId: 'notes', releaseId: 'release-1', version: '1.0.0' }], }; const readLibrary = vi.fn().mockResolvedValue(projection); const ctx = { codingProducts: { pluginMarketplace: { readLibrary, readCatalog: vi.fn(), readDetail: vi.fn(), acquire: vi.fn(), remove: vi.fn(), install: vi.fn(), update: vi.fn(), uninstall: vi.fn(), } } } as unknown as HostApiContext; const result = await invoke(ctx, 'GET', '/api/coding/plugin-marketplace/library'); expect(result).toMatchObject({ handled: true, status: 200, payload: projection }); expect(JSON.stringify(result.payload)).not.toMatch(/path|hash|token|account|admission|definition/i); }); it('forwards only bounded catalog query fields and detail IDs', async () => { const catalog = vi.fn().mockResolvedValue({ items: [], total: 0 }); const detail = vi.fn().mockResolvedValue({ pluginId: 'notes' }); const ctx = { codingProducts: { pluginMarketplace: { readCatalog: catalog, readDetail: detail, } } } as unknown as HostApiContext; await expect(invoke(ctx, 'GET', '/api/coding/plugin-marketplace/catalog?query=notes&featured=true&limit=3')) .resolves.toMatchObject({ handled: true, status: 200 }); expect(catalog).toHaveBeenCalledWith({ query: 'notes', featured: true, limit: 3 }); await expect(invoke(ctx, 'GET', '/api/coding/plugin-marketplace/plugins/notes')) .resolves.toMatchObject({ handled: true, status: 200 }); expect(detail).toHaveBeenCalledWith('notes'); const rejected = await invoke(ctx, 'GET', '/api/coding/plugin-marketplace/catalog?accountKey=forged'); expect(rejected).toMatchObject({ status: 400, payload: { code: 'plugin_request_invalid' } }); expect(catalog).toHaveBeenCalledOnce(); }); it('keeps acquire, install, update, and uninstall as explicit separate Main actions', async () => { const acquire = vi.fn().mockResolvedValue({ items: [] }); const remove = vi.fn().mockResolvedValue({ items: [] }); const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes' }); const installBeta = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes', version: '2.0.0-beta.1' }); const update = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes' }); const uninstall = vi.fn().mockResolvedValue({ status: 'removed', pluginId: 'notes' }); const ctx = { codingProducts: { pluginMarketplace: { acquire, remove, install, installBeta, update, uninstall, readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(), } } } as unknown as HostApiContext; expect((await invoke(ctx, 'PUT', '/api/coding/plugin-marketplace/library/notes')).status).toBe(200); expect((await invoke(ctx, 'DELETE', '/api/coding/plugin-marketplace/library/notes')).status).toBe(200); expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes')).status).toBe(200); expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes/beta')).status).toBe(200); expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/update/notes')).status).toBe(200); expect((await invoke(ctx, 'DELETE', '/api/coding/plugin-marketplace/install/notes')).status).toBe(200); expect(acquire).toHaveBeenCalledWith('notes'); expect(remove).toHaveBeenCalledWith('notes'); expect(install).toHaveBeenCalledWith('notes'); expect(installBeta).toHaveBeenCalledWith('notes'); expect(update).toHaveBeenCalledWith('notes'); expect(uninstall).toHaveBeenCalledWith('notes'); }); it('rejects Renderer authority fields and never exposes backend errors', async () => { const acquire = vi.fn(); const ctx = { codingProducts: { pluginMarketplace: { acquire, readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(), remove: vi.fn(), install: vi.fn(), update: vi.fn(), uninstall: vi.fn(), } } } as unknown as HostApiContext; const forged = await invoke(ctx, 'PUT', '/api/coding/plugin-marketplace/library/notes', { accountId: 'forged', installRoot: 'C:\\untrusted', }); expect(forged).toMatchObject({ status: 400, payload: { code: 'plugin_request_invalid' } }); expect(acquire).not.toHaveBeenCalled(); }); it.each([ { error: new MarketplaceClientError('plugin_release_yanked', 409), status: 409, code: 'plugin_release_yanked' }, { error: new PluginPackageStoreError('plugin_incompatible_client'), status: 409, code: 'plugin_incompatible_client' }, { error: new PluginPackageStoreError('plugin_signature_invalid'), status: 422, code: 'plugin_signature_invalid' }, ])('keeps bounded release status $code visible through the Main route', async ({ error, status, code }) => { const install = vi.fn().mockRejectedValue(error); const ctx = { codingProducts: { pluginMarketplace: { install } } } as unknown as HostApiContext; const result = await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes'); expect(result).toMatchObject({ status, payload: { success: false, code } }); expect(JSON.stringify(result.payload)).not.toMatch(/path|token|private|secret|stack/i); }); });