fix: complete marketplace client remediation
This commit is contained in:
@@ -33,6 +33,13 @@ const RELEASE_ID = 'release-1';
|
||||
const ADMISSION_ID = 'admission-1';
|
||||
const SHA256 = 'a'.repeat(64);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const catalogPage = {
|
||||
items: [{
|
||||
plugin_id: PLUGIN_ID,
|
||||
@@ -160,6 +167,27 @@ function signedGrant(
|
||||
describe('Marketplace client and account cache', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function rawLibraryEntry(pluginId: string, title: string) {
|
||||
return {
|
||||
plugin_id: pluginId,
|
||||
title,
|
||||
summary: title,
|
||||
category: 'tools',
|
||||
acquisition: 'free',
|
||||
acquisition_mode: 'user_acquired',
|
||||
catalog_status: 'active',
|
||||
runtime_status: 'enabled',
|
||||
acquired_at: '2026-08-28T00:00:00Z',
|
||||
removed_at: null,
|
||||
stable_version: '1.0.0',
|
||||
beta_version: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rawLibrary(entries: readonly Record<string, unknown>[]) {
|
||||
return { items: entries, total: entries.length };
|
||||
}
|
||||
|
||||
it('keeps Library and admission snapshots isolated by account and invalidates on logout', () => {
|
||||
const cache = new AccountPluginCache();
|
||||
const aLibrary: MarketplaceLibrarySnapshot = {
|
||||
@@ -199,6 +227,64 @@ describe('Marketplace client and account cache', () => {
|
||||
expect(cache.referencedReleaseIds()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('does not let an older Library read overwrite a newer mutation/read intent', async () => {
|
||||
const oldRead = deferred<Response>();
|
||||
const mutation = deferred<Response>();
|
||||
const newestRead = deferred<Response>();
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockImplementationOnce(() => oldRead.promise)
|
||||
.mockImplementationOnce(() => mutation.promise)
|
||||
.mockImplementationOnce(() => newestRead.promise);
|
||||
const cache = new AccountPluginCache();
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
accountCache: cache,
|
||||
});
|
||||
const old = client.readLibrary();
|
||||
const acquired = client.acquire(PLUGIN_ID);
|
||||
mutation.resolve(response(rawLibraryEntry(PLUGIN_ID, 'acquired')));
|
||||
await Promise.resolve();
|
||||
newestRead.resolve(response(rawLibrary([rawLibraryEntry(PLUGIN_ID, 'newest')])));
|
||||
oldRead.resolve(response(rawLibrary([rawLibraryEntry(PLUGIN_ID, 'old')])));
|
||||
await Promise.all([old, acquired]);
|
||||
expect(cache.getLibrary(ACCOUNT_A)?.items[0]?.title).toBe('newest');
|
||||
});
|
||||
|
||||
it('keeps the latest same-account mutation intent when mutation responses complete out of order', async () => {
|
||||
const firstMutation = deferred<Response>();
|
||||
const secondMutation = deferred<Response>();
|
||||
const firstRead = deferred<Response>();
|
||||
const secondRead = deferred<Response>();
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockImplementationOnce(() => firstMutation.promise)
|
||||
.mockImplementationOnce(() => secondMutation.promise)
|
||||
.mockImplementationOnce(() => secondRead.promise)
|
||||
.mockImplementationOnce(() => firstRead.promise);
|
||||
const cache = new AccountPluginCache();
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
accountCache: cache,
|
||||
});
|
||||
const first = client.acquire('makelore.first');
|
||||
const second = client.acquire('makelore.second');
|
||||
secondMutation.resolve(response(rawLibraryEntry('makelore.second', 'second mutation')));
|
||||
await Promise.resolve();
|
||||
secondRead.resolve(response(rawLibrary([rawLibraryEntry('makelore.second', 'second newest')])));
|
||||
firstMutation.resolve(response(rawLibraryEntry('makelore.first', 'first mutation')));
|
||||
await Promise.resolve();
|
||||
firstRead.resolve(response(rawLibrary([rawLibraryEntry('makelore.first', 'first stale')])));
|
||||
await Promise.all([first, second]);
|
||||
expect(cache.getLibrary(ACCOUNT_A)?.items[0]?.title).toBe('second newest');
|
||||
});
|
||||
|
||||
it('parses bounded catalog metadata, refreshes exactly once after a 401, and marks stale data', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
fetcher
|
||||
@@ -584,12 +670,91 @@ describe('PluginPackageStore', () => {
|
||||
|
||||
store.registerActiveWorker('release-1');
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
|
||||
status: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0',
|
||||
status: 'kept', 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('persists the installed channel and client range, and fails closed after a client upgrade', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const stable = signedGrant(archive, { releaseId: 'release-stable', minMakeloreVersion: '1.0.0', maxMakeloreVersion: '1.5.0' });
|
||||
let current = stable;
|
||||
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,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', stable.publicKey]]),
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
|
||||
|
||||
const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as {
|
||||
releases: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(index.releases[0]).toMatchObject({
|
||||
channel: 'stable',
|
||||
min_makelore_version: '1.0.0',
|
||||
max_makelore_version: '1.5.0',
|
||||
});
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
|
||||
channel: 'stable', minMakeloreVersion: '1.0.0', maxMakeloreVersion: '1.5.0',
|
||||
});
|
||||
|
||||
const upgradedStore = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
clientVersion: '2.0.0',
|
||||
keyStore: new Map([['test-key', stable.publicKey]]),
|
||||
});
|
||||
await expect(upgradedStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
|
||||
unavailableReason: 'plugin_incompatible_client',
|
||||
});
|
||||
await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes only old releases and never guesses a new current selection from installedAt', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const first = signedGrant(archive, { releaseId: 'release-old' });
|
||||
const second = signedGrant(archive, { releaseId: 'release-current', signingKeyId: 'test-key-2' });
|
||||
let current = first;
|
||||
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',
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
current = second;
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ status: 'kept', releaseId: 'release-current' });
|
||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-current' });
|
||||
await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('release-current');
|
||||
});
|
||||
|
||||
it('preserves the old release across download, signature, and extraction failures', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const oldArchive = buildSkillOnlyArchive();
|
||||
|
||||
Reference in New Issue
Block a user