fix: close marketplace client review findings
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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('版本尚未就绪;不会替换此前可用版本');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user