feat: add marketplace client package store
This commit is contained in:
619
tests/unit/coding-plugin-marketplace-client.test.ts
Normal file
619
tests/unit/coding-plugin-marketplace-client.test.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AccountPluginCache,
|
||||
type AccountBinding,
|
||||
type MarketplaceLibrarySnapshot,
|
||||
} from '../../electron/coding-plugins/account-plugin-cache';
|
||||
import {
|
||||
MarketplaceClientError,
|
||||
createMarketplaceClient,
|
||||
type MarketplaceClient,
|
||||
type ResolveRequest,
|
||||
} from '../../electron/coding-plugins/marketplace-client';
|
||||
import {
|
||||
PluginPackageStore,
|
||||
type DownloadGrant,
|
||||
type ResolveSnapshot,
|
||||
} from '../../electron/coding-plugins/package-store';
|
||||
import {
|
||||
buildPluginReleaseDescriptor,
|
||||
serializePluginReleaseDescriptor,
|
||||
} from '../../electron/coding-plugins/release-descriptor';
|
||||
|
||||
const ACCOUNT_A: AccountBinding = { accountKey: 'a'.repeat(64), epoch: 1 };
|
||||
const ACCOUNT_B: AccountBinding = { accountKey: 'b'.repeat(64), epoch: 2 };
|
||||
const PLUGIN_ID = 'makelore.example';
|
||||
const RELEASE_ID = 'release-1';
|
||||
const ADMISSION_ID = 'admission-1';
|
||||
const SHA256 = 'a'.repeat(64);
|
||||
|
||||
const catalogPage = {
|
||||
items: [{
|
||||
plugin_id: PLUGIN_ID,
|
||||
title: 'Example',
|
||||
summary: 'Example Skill',
|
||||
category: 'tools',
|
||||
tags: ['example'],
|
||||
provider_display_name: 'MakeLore',
|
||||
runtime_kind: 'skill_only',
|
||||
runtime_status: 'enabled',
|
||||
acquisition: 'free',
|
||||
usage_billing: 'included',
|
||||
included_operation_count: 0,
|
||||
metered_operation_count: 0,
|
||||
stable_version: '1.0.0',
|
||||
beta_version: null,
|
||||
}],
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
catalog_generation: 7,
|
||||
};
|
||||
|
||||
function response(body: unknown, init: ResponseInit = {}, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function requestBody(fetcher: ReturnType<typeof vi.fn>, index: number): Record<string, unknown> {
|
||||
return JSON.parse(fetcher.mock.calls[index]?.[1]?.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function makeResolveResult(input: ResolveRequest, itemOverrides: Record<string, unknown> = {}): ResolveSnapshot {
|
||||
return {
|
||||
resolveRequestId: input.resolveRequestId ?? 'generated',
|
||||
resolveRequestDigest: input.resolveRequestDigest ?? SHA256,
|
||||
items: [{
|
||||
pluginId: PLUGIN_ID,
|
||||
action: 'install',
|
||||
releaseId: RELEASE_ID,
|
||||
version: '1.0.0',
|
||||
sha256: SHA256,
|
||||
sizeBytes: 1,
|
||||
releaseAdmissionId: ADMISSION_ID,
|
||||
expiresAt: '2026-08-29T00:00:00Z',
|
||||
channel: input.channel,
|
||||
reason: null,
|
||||
...itemOverrides,
|
||||
}],
|
||||
catalogGeneration: 7,
|
||||
etag: '"plugins-7-tp-none"',
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkillOnlyArchive(): Buffer {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('plugin.json', Buffer.from(JSON.stringify({
|
||||
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
|
||||
name: PLUGIN_ID,
|
||||
version: '1.0.0',
|
||||
description: 'Example Skill',
|
||||
author: { name: 'MakeLore' },
|
||||
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
|
||||
})));
|
||||
zip.addFile('com.makelore/capability.json', Buffer.from(JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
pluginId: PLUGIN_ID,
|
||||
contractVersion: 1,
|
||||
scope: 'project',
|
||||
runtime: { kind: 'skill_only' },
|
||||
skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }],
|
||||
tools: [],
|
||||
})));
|
||||
zip.addFile('skills/example-skill/SKILL.md', Buffer.from('# Example\n'));
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
function signedGrant(
|
||||
archive: Buffer,
|
||||
options: {
|
||||
readonly releaseId?: string;
|
||||
readonly minMakeloreVersion?: string;
|
||||
readonly maxMakeloreVersion?: string | null;
|
||||
} = {},
|
||||
): { grant: DownloadGrant; publicKey: Buffer; signature: string } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const sha256 = createHash('sha256').update(archive).digest('hex');
|
||||
const releaseId = options.releaseId ?? RELEASE_ID;
|
||||
const descriptor = buildPluginReleaseDescriptor({
|
||||
pluginId: PLUGIN_ID,
|
||||
version: '1.0.0',
|
||||
packageSchemaVersion: 2,
|
||||
contractVersion: 1,
|
||||
minMakeloreVersion: options.minMakeloreVersion ?? '1.0.0',
|
||||
maxMakeloreVersion: options.maxMakeloreVersion ?? null,
|
||||
artifact: { sha256, sizeBytes: archive.byteLength },
|
||||
});
|
||||
const signature = sign(null, serializePluginReleaseDescriptor(descriptor), privateKey).toString('base64url');
|
||||
return {
|
||||
grant: {
|
||||
releaseAdmissionId: ADMISSION_ID,
|
||||
releaseId,
|
||||
pluginId: PLUGIN_ID,
|
||||
version: '1.0.0',
|
||||
packageSchemaVersion: 2,
|
||||
contractVersion: 1,
|
||||
minMakeloreVersion: options.minMakeloreVersion ?? '1.0.0',
|
||||
maxMakeloreVersion: options.maxMakeloreVersion ?? null,
|
||||
sizeBytes: archive.byteLength,
|
||||
sha256,
|
||||
signingKeyId: 'test-key',
|
||||
descriptorSignature: signature,
|
||||
expiresAt: '2026-08-29T00:00:00Z',
|
||||
contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`,
|
||||
},
|
||||
publicKey: publicKey.export({ type: 'spki', format: 'der' }) as Buffer,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Marketplace client and account cache', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('keeps Library and admission snapshots isolated by account and invalidates on logout', () => {
|
||||
const cache = new AccountPluginCache();
|
||||
const aLibrary: MarketplaceLibrarySnapshot = {
|
||||
items: [{
|
||||
pluginId: PLUGIN_ID,
|
||||
title: 'A',
|
||||
summary: 'A',
|
||||
category: 'tools',
|
||||
acquisition: 'free',
|
||||
acquisitionMode: 'user_acquired',
|
||||
catalogStatus: 'active',
|
||||
runtimeStatus: 'enabled',
|
||||
acquiredAt: null,
|
||||
removedAt: null,
|
||||
stableVersion: '1.0.0',
|
||||
betaVersion: null,
|
||||
}],
|
||||
total: 1,
|
||||
stale: false,
|
||||
fetchedAt: 1,
|
||||
};
|
||||
cache.setLibrary(ACCOUNT_A, aLibrary);
|
||||
cache.setResolve(ACCOUNT_A, 'resolve-a', {
|
||||
resolveRequestId: 'resolve-a',
|
||||
resolveRequestDigest: SHA256,
|
||||
items: [{ pluginId: PLUGIN_ID, action: 'install', releaseId: RELEASE_ID }],
|
||||
catalogGeneration: 1,
|
||||
etag: null,
|
||||
stale: false,
|
||||
});
|
||||
expect(cache.getLibrary(ACCOUNT_A)).toEqual(aLibrary);
|
||||
expect(cache.getLibrary(ACCOUNT_B)).toBeNull();
|
||||
expect(cache.getResolve(ACCOUNT_B, 'resolve-a')).toBeNull();
|
||||
expect(cache.referencedReleaseIds()).toEqual(new Set([RELEASE_ID]));
|
||||
cache.invalidateAll();
|
||||
expect(cache.getLibrary(ACCOUNT_A)).toBeNull();
|
||||
expect(cache.referencedReleaseIds()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('parses bounded catalog metadata, refreshes exactly once after a 401, and marks stale data', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
fetcher
|
||||
.mockResolvedValueOnce(new Response('', { status: 401 }))
|
||||
.mockResolvedValueOnce(response(catalogPage, {}, {
|
||||
ETag: '"plugins-7-tp-none"',
|
||||
'X-Plugin-Catalog-Generation': '7',
|
||||
'X-Token-Point-Pricing-Version': 'none',
|
||||
}));
|
||||
const refresh = vi.fn(async () => 'refreshed-token');
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async (options) => options?.forceRefresh ? refresh() : 'initial-token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
await expect(client.readCatalog({ limit: 10 })).resolves.toMatchObject({
|
||||
total: 1,
|
||||
etag: '"plugins-7-tp-none"',
|
||||
stale: false,
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer refreshed-token' });
|
||||
|
||||
fetcher.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(client.readCatalog({ limit: 10 })).resolves.toMatchObject({ stale: true, total: 1 });
|
||||
});
|
||||
|
||||
it('refreshes download authentication at most once before accepting the artifact', async () => {
|
||||
const archive = Buffer.from('signed-artifact');
|
||||
const { grant } = signedGrant(archive);
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response(archive, {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(archive.byteLength) },
|
||||
}));
|
||||
const refresh = vi.fn(async () => 'refreshed-token');
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async (options) => options?.forceRefresh ? refresh() : 'initial-token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
const downloaded = await client.downloadContent(grant);
|
||||
expect(Buffer.from(downloaded)).toEqual(archive);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer refreshed-token' });
|
||||
});
|
||||
|
||||
it('parses the detail DTO with its nested release projection', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({
|
||||
...catalogPage.items[0],
|
||||
description_markdown: 'Example details',
|
||||
permissions: ['plugin.example.read'],
|
||||
operations: [],
|
||||
stable_release: null,
|
||||
beta_release: null,
|
||||
}, {}, {
|
||||
ETag: '"plugins-7-tp-none"',
|
||||
'X-Plugin-Catalog-Generation': '7',
|
||||
'X-Token-Point-Pricing-Version': 'none',
|
||||
}));
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => null,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
await expect(client.readDetail(PLUGIN_ID)).resolves.toMatchObject({
|
||||
pluginId: PLUGIN_ID,
|
||||
descriptionMarkdown: 'Example details',
|
||||
permissions: ['plugin.example.read'],
|
||||
etag: '"plugins-7-tp-none"',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives stable resolve identity from one logical request and changes it when installed state changes', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_input, init) => {
|
||||
const request = JSON.parse(init?.body as string) as Record<string, unknown>;
|
||||
return response({
|
||||
resolve_request_id: request.resolve_request_id,
|
||||
resolve_request_digest: request.resolve_request_digest,
|
||||
items: [],
|
||||
catalog_generation: 7,
|
||||
}, {}, { ETag: '"plugins-7-tp-none"' });
|
||||
});
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
const base: ResolveRequest = {
|
||||
makeloreVersion: '1.0.0',
|
||||
channel: 'stable',
|
||||
installed: [],
|
||||
};
|
||||
await client.resolve(base);
|
||||
await client.resolve(base);
|
||||
const firstId = requestBody(fetcher, 0).resolve_request_id;
|
||||
const secondId = requestBody(fetcher, 1).resolve_request_id;
|
||||
expect(firstId).toBe(secondId);
|
||||
await client.resolve({ ...base, installed: [{ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, sha256: SHA256 }] });
|
||||
expect(requestBody(fetcher, 2).resolve_request_id).not.toBe(firstId);
|
||||
});
|
||||
|
||||
it('rejects a response body above the bounded DTO limit', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response('x'.repeat(2_100_000), { status: 200 }));
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => null,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_response_too_large' });
|
||||
});
|
||||
|
||||
it('rejects a malformed authenticated response with a stable client error', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({ items: [] }));
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
await expect(client.readLibrary()).rejects.toBeInstanceOf(MarketplaceClientError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginPackageStore', () => {
|
||||
let temporaryRoot: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (temporaryRoot) await rm(temporaryRoot, { recursive: true, force: true });
|
||||
temporaryRoot = null;
|
||||
});
|
||||
|
||||
it('verifies and atomically installs a signed Skill-only package without account data in index', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive);
|
||||
const resolve = vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
sha256: grant.sha256,
|
||||
sizeBytes: grant.sizeBytes,
|
||||
}));
|
||||
const issueDownload = vi.fn(async () => grant);
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve,
|
||||
issueDownload,
|
||||
downloadContent: async () => archive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
accountCache: new AccountPluginCache(),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
});
|
||||
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
|
||||
.resolves.toMatchObject({ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, status: 'installed' });
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
|
||||
pluginId: PLUGIN_ID,
|
||||
releaseId: RELEASE_ID,
|
||||
version: '1.0.0',
|
||||
});
|
||||
const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as Record<string, unknown>;
|
||||
expect(JSON.stringify(index)).not.toContain('account');
|
||||
expect(JSON.stringify(index)).not.toContain('admission');
|
||||
expect(JSON.stringify(index)).not.toContain('token');
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
expect(issueDownload).toHaveBeenCalledWith({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID });
|
||||
});
|
||||
|
||||
it('preserves the old immutable release when index replacement fails', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive);
|
||||
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,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
const replacement = signedGrant(archive, { releaseId: 'release-2' });
|
||||
const replacementMarketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
releaseId: replacement.grant.releaseId,
|
||||
sha256: replacement.grant.sha256,
|
||||
sizeBytes: replacement.grant.sizeBytes,
|
||||
})),
|
||||
issueDownload: vi.fn(async () => replacement.grant),
|
||||
downloadContent: async () => archive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const failingStore = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace: replacementMarketplace,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', replacement.publicKey]]),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
writeIndex: async () => { throw new Error('simulated index interruption'); },
|
||||
});
|
||||
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 });
|
||||
});
|
||||
|
||||
it('preserves the old release across download, signature, and extraction failures', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const oldArchive = buildSkillOnlyArchive();
|
||||
const old = signedGrant(oldArchive, { releaseId: RELEASE_ID });
|
||||
const oldMarketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
sha256: old.grant.sha256,
|
||||
sizeBytes: old.grant.sizeBytes,
|
||||
})),
|
||||
issueDownload: vi.fn(async () => old.grant),
|
||||
downloadContent: async () => oldArchive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const oldStore = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace: oldMarketplace,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', old.publicKey]]),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
});
|
||||
await oldStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
|
||||
const invalidArchive = Buffer.from('not a ZIP archive');
|
||||
const cases = [
|
||||
{
|
||||
releaseId: 'release-download-failure',
|
||||
expectedCode: 'plugin_install_failed',
|
||||
artifact: oldArchive,
|
||||
},
|
||||
{
|
||||
releaseId: 'release-signature-failure',
|
||||
expectedCode: 'plugin_signature_invalid',
|
||||
artifact: oldArchive,
|
||||
},
|
||||
{
|
||||
releaseId: 'release-extraction-failure',
|
||||
expectedCode: 'plugin_artifact_invalid',
|
||||
artifact: invalidArchive,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const [index, scenario] of cases.entries()) {
|
||||
const signed = signedGrant(scenario.artifact, { releaseId: scenario.releaseId });
|
||||
const grant = scenario.expectedCode === 'plugin_signature_invalid'
|
||||
? { ...signed.grant, descriptorSignature: signed.grant.descriptorSignature[0] === 'A'
|
||||
? `B${signed.grant.descriptorSignature.slice(1)}`
|
||||
: `A${signed.grant.descriptorSignature.slice(1)}` }
|
||||
: signed.grant;
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
releaseId: grant.releaseId,
|
||||
sha256: grant.sha256,
|
||||
sizeBytes: grant.sizeBytes,
|
||||
})),
|
||||
issueDownload: vi.fn(async () => grant),
|
||||
downloadContent: async () => {
|
||||
if (index === 0) throw new Error('simulated download interruption');
|
||||
return scenario.artifact;
|
||||
},
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', signed.publicKey]]),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
});
|
||||
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
|
||||
.rejects.toMatchObject({ code: scenario.expectedCode });
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
|
||||
releaseId: RELEASE_ID,
|
||||
version: '1.0.0',
|
||||
});
|
||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a Release outside the MakeLore client range before installation', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive, { maxMakeloreVersion: '1.5.0' });
|
||||
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,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
clientVersion: '2.0.0',
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
});
|
||||
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '2.0.0' }))
|
||||
.rejects.toMatchObject({ code: 'plugin_incompatible_client' });
|
||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('aborts an in-flight install when the Main account changes', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive);
|
||||
let binding: AccountBinding | null = ACCOUNT_A;
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
sha256: grant.sha256,
|
||||
sizeBytes: grant.sizeBytes,
|
||||
})),
|
||||
issueDownload: vi.fn(async () => grant),
|
||||
downloadContent: vi.fn(async () => {
|
||||
binding = ACCOUNT_B;
|
||||
return archive;
|
||||
}),
|
||||
getCurrentAccountBinding: () => binding,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
getAccountBinding: () => binding,
|
||||
});
|
||||
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
|
||||
.rejects.toMatchObject({ code: 'plugin_account_changed' });
|
||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects archive traversal before materializing a package', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('C:/outside.txt', Buffer.from('outside'));
|
||||
const archive = zip.toBuffer();
|
||||
const { grant, publicKey } = signedGrant(archive);
|
||||
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,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
});
|
||||
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
|
||||
.rejects.toMatchObject({ code: 'plugin_artifact_invalid' });
|
||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('requires explicit beta selection and rejects content paths outside the server route', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({
|
||||
release_admission_id: ADMISSION_ID,
|
||||
release_id: RELEASE_ID,
|
||||
plugin_id: PLUGIN_ID,
|
||||
version: '1.0.0',
|
||||
package_schema_version: 2,
|
||||
contract_version: 1,
|
||||
min_makelore_version: '1.0.0',
|
||||
max_makelore_version: null,
|
||||
size_bytes: 1,
|
||||
sha256: SHA256,
|
||||
signing_key_id: 'test-key',
|
||||
descriptor_signature: 'a'.repeat(86),
|
||||
expires_at: '2026-08-29T00:00:00Z',
|
||||
content_url: 'https://evil.example/archive.zip',
|
||||
}));
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
});
|
||||
await expect(client.issueDownload({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID }))
|
||||
.rejects.toMatchObject({ code: 'marketplace_response_invalid' });
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input)),
|
||||
issueDownload: vi.fn(),
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({ rootDir: await mkdtemp(path.join(process.cwd(), '.marketplace-test-')), marketplace });
|
||||
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, channel: 'beta', makeloreVersion: '1.0.0' }))
|
||||
.rejects.toMatchObject({ code: 'plugin_beta_selection_required' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user