138 lines
7.1 KiB
TypeScript
138 lines
7.1 KiB
TypeScript
// @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<string, unknown>,
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|