fix: close marketplace client review findings

This commit is contained in:
2026-08-28 21:01:51 +08:00
parent 8dfa542860
commit 1614f7efc1
25 changed files with 1224 additions and 143 deletions

View File

@@ -1,7 +1,7 @@
// @vitest-environment node
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import AdmZip from 'adm-zip';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -117,6 +117,7 @@ function signedGrant(
archive: Buffer,
options: {
readonly releaseId?: string;
readonly signingKeyId?: string;
readonly minMakeloreVersion?: string;
readonly maxMakeloreVersion?: string | null;
} = {},
@@ -146,7 +147,7 @@ function signedGrant(
maxMakeloreVersion: options.maxMakeloreVersion ?? null,
sizeBytes: archive.byteLength,
sha256,
signingKeyId: 'test-key',
signingKeyId: options.signingKeyId ?? 'test-key',
descriptorSignature: signature,
expiresAt: '2026-08-29T00:00:00Z',
contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`,
@@ -321,6 +322,36 @@ describe('Marketplace client and account cache', () => {
await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_response_too_large' });
});
it('applies one deadline to response headers and a body that never completes', async () => {
const neverBody = new ReadableStream<Uint8Array>({ start() { /* intentionally never closes */ } });
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(neverBody, { status: 200 }));
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
requestTimeoutMs: 20,
getAccessToken: async () => null,
subscribeSession: () => () => undefined,
});
await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_request_failed' });
});
it('preserves a bounded server release status from the response body', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({
success: false,
code: 'plugin_release_yanked',
error: 'Release is no longer available',
}, { status: 409 }));
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async () => null,
subscribeSession: () => () => undefined,
});
await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({
code: 'plugin_release_yanked', status: 409,
});
});
it('rejects a malformed authenticated response with a stable client error', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({ items: [] }));
const client = createMarketplaceClient({
@@ -471,6 +502,92 @@ describe('PluginPackageStore', () => {
await expect(failingStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: 'plugin_install_failed' });
await expect(failingStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID });
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-2')))
.resolves.toMatchObject({ isDirectory: expect.any(Function) });
const recoveredStore = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace: replacementMarketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', replacement.publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await expect(recoveredStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.resolves.toMatchObject({ status: 'installed', releaseId: 'release-2' });
await expect(recoveredStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-2' });
});
it('uninstalls a device package without removing the account Library snapshot', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
const accountCache = new AccountPluginCache();
const library: MarketplaceLibrarySnapshot = {
items: [{
pluginId: PLUGIN_ID, title: 'Example', summary: 'Example', category: 'tools',
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active',
runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null,
stableVersion: '1.0.0', betaVersion: null,
}], total: 1, stale: false, fetchedAt: 1,
};
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256, sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
readLibrary: vi.fn(async () => library),
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot, marketplace, accountCache, getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([['test-key', publicKey]]), clientVersion: '1.0.0',
});
await store.syncLibrary();
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ status: 'removed', pluginId: PLUGIN_ID });
expect(accountCache.getLibrary(ACCOUNT_A)).toEqual(library);
expect(accountCache.referencedReleaseIds()).toEqual(new Set());
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
});
it('makes a cached rollback the Package Store current selection while retaining both immutable releases', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const first = signedGrant(archive, { releaseId: 'release-1' });
const second = signedGrant(archive, { releaseId: 'release-2', signingKeyId: 'test-key-2' });
let current = second;
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: current.grant.releaseId,
sha256: current.grant.sha256,
sizeBytes: current.grant.sizeBytes,
})),
issueDownload: vi.fn(async () => current.grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot, marketplace, getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([['test-key', first.publicKey], ['test-key-2', second.publicKey]]),
clientVersion: '1.0.0', now: (() => { let value = 1; return () => value++ * 1_000; })(),
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
current = first;
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await expect(store.readInstalledIndex()).resolves.toHaveLength(2);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' });
const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as {
releases: Array<{ release_id: string }>;
};
expect(index.releases.map(({ release_id }) => release_id)).toEqual(['release-2', 'release-1']);
store.registerActiveWorker('release-1');
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
status: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0',
});
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' });
});
it('preserves the old release across download, signature, and extraction failures', async () => {
@@ -664,4 +781,34 @@ describe('PluginPackageStore', () => {
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, channel: 'beta', makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: 'plugin_beta_selection_required' });
});
it.each([
'plugin_release_yanked',
'plugin_incompatible_client',
'plugin_signature_invalid',
] as const)('preserves bounded resolve unavailable code %s for the UI projection', async (reason) => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
action: 'unavailable',
releaseId: null,
version: null,
sha256: null,
sizeBytes: null,
releaseAdmissionId: null,
reason,
})),
issueDownload: vi.fn(),
downloadContent: vi.fn(),
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
getAccountBinding: () => ACCOUNT_A,
clientVersion: '1.0.0',
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: reason });
});
});

View File

@@ -325,13 +325,14 @@ describe('Marketplace public Library projection', () => {
},
];
it('rebuilds fresh-process installation state and exposes only the latest safe release', async () => {
it('rebuilds fresh-process installation state from the Package Store current selection', async () => {
const marketplace = {
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot),
acquire: vi.fn(), remove: vi.fn(),
};
const packageStore = {
readInstalledIndex: vi.fn().mockResolvedValue(records),
getInstalled: vi.fn().mockResolvedValue({ ...records[0], packageRoot: 'ignored', definition: {} }),
resolveAndInstall: vi.fn(), removeUnused: vi.fn(),
};
const service = createCodingPluginMarketplaceService({
@@ -343,7 +344,7 @@ describe('Marketplace public Library projection', () => {
library: snapshot,
installations: [{
status: 'installed', pluginId: 'makelore.notes',
releaseId: 'release-new', version: '1.5.0',
releaseId: 'release-old', version: '1.0.0',
}],
});
expect(JSON.stringify(result)).not.toMatch(/sha256|sizeBytes|installedAt|packageRoot|definition|account|admission|token/i);
@@ -356,6 +357,7 @@ describe('Marketplace public Library projection', () => {
};
const packageStore = {
readInstalledIndex: vi.fn().mockResolvedValue(records.slice(1, 2)),
getInstalled: vi.fn().mockResolvedValue({ ...records[1], packageRoot: 'ignored', definition: {} }),
resolveAndInstall: vi.fn(), removeUnused: vi.fn(),
};
const service = createCodingPluginMarketplaceService({
@@ -367,6 +369,6 @@ describe('Marketplace public Library projection', () => {
installations: [{ status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-new', version: '1.5.0' }],
});
expect(marketplace[action]).toHaveBeenCalledWith('makelore.notes');
expect(packageStore.readInstalledIndex).toHaveBeenCalledOnce();
expect(packageStore.getInstalled).toHaveBeenCalledOnce();
});
});

View File

@@ -141,6 +141,32 @@ describe('host-api', () => {
);
});
it('preserves a bounded Marketplace status through browser fallback', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 409,
statusText: 'Conflict',
json: async () => ({
success: false,
code: 'plugin_release_yanked',
error: 'Release is no longer available',
}),
});
vi.stubGlobal('fetch', fetchMock);
window.localStorage.setItem('niancode:allow-localhost-fallback', '1');
invokeIpcMock.mockResolvedValueOnce({
ok: false,
error: { message: 'No handler registered for hostapi:fetch' },
});
const { hostApiFetch } = await import('@/lib/host-api');
await expect(hostApiFetch('/api/coding/plugin-marketplace/install/notes'))
.rejects.toMatchObject({
message: 'Release is no longer available',
details: { backendCode: 'plugin_release_yanked', status: 409 },
});
});
it('throws message from legacy non-ok envelope', async () => {
invokeIpcMock.mockResolvedValueOnce({
success: true,

View File

@@ -14,6 +14,7 @@ import {
collectForbiddenResourcePaths,
verifyBundledCodingPluginResources,
defaultProductExecutable,
readPackagedMarketplaceTrustSource,
validatePiArtifactMetadata,
verifyMarketplaceClientArtifact,
} from '../../scripts/lib/pi-product-artifact.mjs';
@@ -198,7 +199,8 @@ describe('final Pi product artifact verification', () => {
const trustSource = `export const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze(
{} as Readonly<Record<string, string>>,
);`;
);
export const sourceMarker = 'makelore.plugin-trust.code-owned.v1';`;
expect(verifyMarketplaceClientArtifact(artifact, trustSource)).toMatchObject({
schema2SkillOnly: true,
productionTrust: 'official-key-absent-fail-closed',
@@ -229,6 +231,50 @@ describe('final Pi product artifact verification', () => {
)).toThrow('empty code-owned fail-closed store');
});
it('proves Marketplace trust from the packaged app.asar rather than checkout source', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-trust-asar-'));
roots.push(root);
const source = path.join(root, 'source');
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
'const unrelatedConfiguration = process.env.NIANCODE_E2E;',
'export const marketplace = true;',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toBe(
'CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({})',
);
});
it('recognizes a minified trust table only when its packaged provenance marker is present', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-compiled-trust-asar-'));
roots.push(root);
const source = path.join(root, 'source');
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
'const dC = Object.freeze({});',
'function createTrust() { return Object.freeze({ get: load, sourceMarker: "makelore.plugin-trust.code-owned.v1" }); }',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toContain(
'makelore.plugin-trust.code-owned.v1',
);
expect(verifyMarketplaceClientArtifact(
Buffer.from([
'makelore-plugin-release.v1', 'skill_only', 'plugin_signature_invalid',
'signing key is not trusted', '/api/coding/plugin-marketplace',
'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/',
'effectiveSkillIds', 'pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library',
'免费获取', '我的插件',
].join('\n')),
'const dC = Object.freeze({}); sourceMarker: makelore.plugin-trust.code-owned.v1',
)).toMatchObject({ productionTrust: 'official-key-absent-fail-closed' });
});
it('rejects a packaged plugin tree that drops an SDK asset or catalog marker', async () => {
const fixture = await bundledResourceFixture();
await rm(path.join(

View File

@@ -166,6 +166,33 @@ describe('Pi managed resource loader', () => {
expect(resources.catalogRevision).toBe(13);
});
it('keeps each effective Skill paired with its verified package root when relative paths collide', async () => {
const fixture = await fixtureRoot();
const firstRoot = path.join(fixture.root, 'packages', 'first');
const secondRoot = path.join(fixture.root, 'packages', 'second');
const relativeEntry = 'skills/shared/SKILL.md';
await Promise.all([
mkdir(path.join(firstRoot, 'skills', 'shared'), { recursive: true }),
mkdir(path.join(secondRoot, 'skills', 'shared'), { recursive: true }),
]);
await Promise.all([
writeFile(path.join(firstRoot, relativeEntry), 'first package', 'utf8'),
writeFile(path.join(secondRoot, relativeEntry), 'second package', 'utf8'),
]);
const resolved = await resolveExplicitCodingSkillPaths(fixture.skillsDir, [
{ id: 'first-skill', entryPath: relativeEntry, packageRoot: firstRoot },
{ id: 'second-skill', entryPath: relativeEntry, packageRoot: secondRoot },
]);
expect(resolved.skillPaths).toEqual([
path.join(firstRoot, relativeEntry),
path.join(secondRoot, relativeEntry),
]);
await expect(readFile(resolved.skillPaths[0]!, 'utf8')).resolves.toBe('first package');
await expect(readFile(resolved.skillPaths[1]!, 'utf8')).resolves.toBe('second package');
});
it('filters a known disabled plugin Skill for the next worker and restores it after re-enable', async () => {
const fixture = await fixtureRoot();
const assignedSkillIds = ['grilling', 'data-service'];

View File

@@ -68,7 +68,7 @@ describe('My Plugins', () => {
const library: MarketplaceLibrarySnapshot = {
total: 4, stale: false, fetchedAt: 1,
items: [
{ pluginId: 'makelore.notes', title: '灵感笔记', summary: 'Notes', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: null },
{ pluginId: 'makelore.notes', title: '灵感笔记', summary: 'Notes', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: '2.1.0-beta.1' },
{ pluginId: 'makelore.removed', title: '旧插件', summary: 'Removed', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-27T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null },
{ pluginId: 'makelore.retired', title: '已退役插件', summary: 'Retired', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'retired', runtimeStatus: 'enabled', acquiredAt: '2026-08-26T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null },
{ pluginId: 'makelore.system', title: '开发数据服务', summary: 'Data', category: '系统', acquisition: 'system_included', acquisitionMode: 'system_included', catalogStatus: 'active', runtimeStatus: 'suspended', acquiredAt: null, removedAt: null, stableVersion: '1.0.0', betaVersion: null },
@@ -82,7 +82,7 @@ describe('My Plugins', () => {
render(<MemoryRouter><MyPluginsView
library={library} installations={{ 'makelore.notes': { status: 'installed', pluginId: 'makelore.notes', version: '1.5.0', releaseId: 'release-1' } }}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={onUpdate}
onUninstall={vi.fn()} onRemove={onRemove} onReacquire={onReacquire}
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={onRemove} onReacquire={onReacquire}
/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '更新灵感笔记' }));
@@ -98,15 +98,29 @@ describe('My Plugins', () => {
expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible();
});
it('offers an explicit Beta action without conflating it with stable updates', () => {
const onInstallBeta = vi.fn();
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }}
installations={{ 'makelore.notes': { status: 'installed', pluginId: 'makelore.notes', version: '2.0.0', releaseId: 'stable-2' } }}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
onInstallBeta={onInstallBeta} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
const beta = screen.getByRole('button', { name: '安装 Beta灵感笔记' });
fireEvent.click(beta);
expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes');
expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
});
it('names signature, yanked/not-ready, and incompatible failures without hiding the old install', () => {
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }}
installations={{ 'makelore.notes': { status: 'unavailable', pluginId: 'makelore.notes', version: '1.5.0', reason: 'plugin_signature_invalid plugin_release_yanked plugin_incompatible_client' } }}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByText(/签名校验失败/)).toBeVisible();
expect(screen.getByText(/版本已撤回或未就绪/)).toBeVisible();
expect(screen.getByText(/此版本已撤回/)).toBeVisible();
expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible();
expect(screen.getByText('设备版本 1.5.0')).toBeVisible();
});
@@ -115,14 +129,14 @@ describe('My Plugins', () => {
const { rerender } = render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }} installations={{}} state="ready"
error="plugin_artifact_invalid: Plugin artifact is invalid" pending={{}} onRefresh={vi.fn()}
onInstall={vi.fn()} onUpdate={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
onInstall={vi.fn()} onUpdate={vi.fn()} onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('签名或包校验失败');
rerender(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }} installations={{}} state="ready"
error="plugin_release_not_ready: Plugin Release is not ready" pending={{}} onRefresh={vi.fn()}
onInstall={vi.fn()} onUpdate={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
onInstall={vi.fn()} onUpdate={vi.fn()} onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('已撤回、尚未就绪或与当前 MakeLore 不兼容');
expect(screen.getByRole('alert')).toHaveTextContent('版本尚未就绪;不会替换此前可用版本');
});
});

View File

@@ -5,6 +5,8 @@ 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();
@@ -85,21 +87,24 @@ describe('Main-owned plugin Marketplace routes', () => {
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, update, uninstall,
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');
});
@@ -117,4 +122,16 @@ describe('Main-owned plugin Marketplace routes', () => {
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);
});
});

View File

@@ -70,6 +70,53 @@ describe('plugin Marketplace store', () => {
expect(store.getState().library?.items[0].title).toBe('Acquired');
});
it('keeps the newest same-account Library read when an older response completes last', async () => {
const first = deferred<MarketplaceLibraryProjection>();
const second = deferred<MarketplaceLibraryProjection>();
const readLibrary = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise);
const store = createPluginMarketplaceStore({ readLibrary });
store.getState().activateAccount('account-a');
const oldest = store.getState().loadLibrary();
const newest = store.getState().loadLibrary();
second.resolve(projection('newest')); await newest;
first.resolve(projection('oldest')); await oldest;
expect(store.getState().library?.items[0].title).toBe('newest');
});
it('does not let an older mutation response overwrite a newer mutation for another plugin', async () => {
const make = (pluginId: string, title: string): MarketplaceLibraryProjection => ({
library: {
total: 1, stale: false, fetchedAt: 1,
items: [{
pluginId, title, summary: title, category: 'tools', acquisition: 'free',
acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
}],
},
installations: [],
});
for (const order of ['a-then-b', 'b-then-a'] as const) {
const a = deferred<MarketplaceLibraryProjection>();
const b = deferred<MarketplaceLibraryProjection>();
const store = createPluginMarketplaceStore({
acquire: vi.fn((pluginId: string) => pluginId === 'makelore.a' ? a.promise : b.promise),
});
store.getState().activateAccount('account-a');
const aRequest = store.getState().acquire('makelore.a');
const bRequest = store.getState().acquire('makelore.b');
if (order === 'a-then-b') {
a.resolve(make('makelore.a', 'A older')); await aRequest;
b.resolve(make('makelore.b', 'B newer')); await bRequest;
} else {
b.resolve(make('makelore.b', 'B newer')); await bRequest;
a.resolve(make('makelore.a', 'A older')); await aRequest;
}
expect(store.getState().library?.items[0].title).toBe('B newer');
}
});
it('calls only the selected state mutation', async () => {
const acquire = vi.fn().mockResolvedValue(projection('Acquired'));
const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', version: '1.0.0' });
@@ -86,4 +133,21 @@ describe('plugin Marketplace store', () => {
expect(install).toHaveBeenCalledOnce();
expect(setProjectEnabled).not.toHaveBeenCalled();
});
it('projects a bounded installation failure on the affected My Plugins entry', async () => {
const error = Object.assign(new Error('Release signature is invalid'), {
code: 'plugin_signature_invalid',
});
const store = createPluginMarketplaceStore({
install: vi.fn().mockRejectedValue(error),
});
store.getState().activateAccount('account-a');
await expect(store.getState().install('makelore.notes')).rejects.toBe(error);
expect(store.getState().installations['makelore.notes']).toMatchObject({
status: 'unavailable',
pluginId: 'makelore.notes',
reason: 'plugin_signature_invalid: Release signature is invalid',
});
});
});