Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions src/profiles/marketplace/stellar/__tests__/marketplace.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Soroban Analysis Profile Marketplace — Tests (Issue #484)
*/

import {
AnalysisProfile,
PROFILE_ENVELOPE_KIND,
StellarProfileMarketplace,
computeChecksum,
exportProfile,
exportProfileToJson,
importProfile,
isSchemaCompatible,
} from '../index';

function makeProfile(overrides: Partial<AnalysisProfile> = {}): AnalysisProfile {
return {
id: 'soroban-strict',
name: 'Soroban Strict',
description: 'Strict security scan configuration for Soroban contracts',
target: 'stellar-soroban',
tags: ['security', 'strict'],
rules: [
{ id: 'SOR-001', enabled: true, severity: 'high', category: 'reentrancy' },
{ id: 'SOR-002', enabled: true, severity: 'critical', category: 'auth' },
],
...overrides,
};
}

describe('schema compatibility', () => {
it('accepts the same major version', () => {
expect(isSchemaCompatible('1.0.0')).toBe(true);
expect(isSchemaCompatible('1.4.2')).toBe(true);
});

it('rejects a different major version or garbage', () => {
expect(isSchemaCompatible('2.0.0')).toBe(false);
expect(isSchemaCompatible('not-a-version')).toBe(false);
});
});

describe('export', () => {
it('wraps a profile in a self-describing envelope with a checksum', () => {
const profile = makeProfile();
const env = exportProfile(profile, { author: { name: 'team-sec' } });

expect(env.kind).toBe(PROFILE_ENVELOPE_KIND);
expect(env.schemaVersion).toBe('1.0.0');
expect(env.profile).toEqual(profile);
expect(env.metadata.author).toEqual({ name: 'team-sec' });
expect(env.metadata.checksum).toBe(computeChecksum(profile));
expect(new Date(env.metadata.exportedAt).toString()).not.toBe('Invalid Date');
});

it('produces an order-independent, stable checksum', () => {
const a = makeProfile({ tags: ['security', 'strict'] });
// Same data, different key insertion order.
const b: AnalysisProfile = {
rules: a.rules,
tags: ['security', 'strict'],
target: 'stellar-soroban',
description: a.description,
name: a.name,
id: a.id,
};
expect(computeChecksum(a)).toBe(computeChecksum(b));
});
});

describe('import (round-trip)', () => {
it('imports a freshly exported profile', () => {
const profile = makeProfile();
const json = exportProfileToJson(profile);

const result = importProfile(json);
expect(result.ok).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.profile).toEqual(profile);
});

it('accepts an already-parsed envelope object', () => {
const env = exportProfile(makeProfile());
const result = importProfile(env);
expect(result.ok).toBe(true);
});
});

describe('import (rejection)', () => {
it('rejects malformed JSON', () => {
const result = importProfile('{ not valid json');
expect(result.ok).toBe(false);
expect(result.errors[0].code).toBe('INVALID_JSON');
});

it('rejects an artifact with the wrong kind', () => {
const result = importProfile({ kind: 'something-else', schemaVersion: '1.0.0' });
expect(result.ok).toBe(false);
expect(result.errors[0].code).toBe('KIND_INVALID');
});

it('rejects an incompatible schema version', () => {
const env = exportProfile(makeProfile());
const result = importProfile({ ...env, schemaVersion: '2.0.0' });
expect(result.ok).toBe(false);
expect(result.errors.some((e) => e.code === 'SCHEMA_INCOMPATIBLE')).toBe(true);
});

it('rejects a tampered profile (checksum mismatch)', () => {
const env = exportProfile(makeProfile());
// Mutate the profile after the checksum was computed.
const tampered = {
...env,
profile: { ...env.profile, name: 'Backdoored Profile' },
};
const result = importProfile(tampered);
expect(result.ok).toBe(false);
expect(result.errors.some((e) => e.code === 'CHECKSUM_MISMATCH')).toBe(true);
});

it('reports structural validation errors', () => {
const env = exportProfile(makeProfile());
const broken = { ...env, profile: { ...env.profile, id: '', target: 'evm' } };
const result = importProfile(broken);
expect(result.ok).toBe(false);
const codes = result.errors.map((e) => e.code);
expect(codes).toContain('ID_REQUIRED');
expect(codes).toContain('TARGET_INVALID');
});

it('warns (but succeeds) when no checksum is present', () => {
const env = exportProfile(makeProfile());
const noChecksum = {
...env,
metadata: { ...env.metadata, checksum: '' },
};
const result = importProfile(noChecksum);
expect(result.ok).toBe(true);
expect(result.warnings.some((w) => w.code === 'CHECKSUM_MISSING')).toBe(true);
});
});

describe('marketplace registry', () => {
let market: StellarProfileMarketplace;

beforeEach(() => {
market = new StellarProfileMarketplace();
});

it('publishes, lists, and gets profiles', () => {
market.publish(makeProfile());
expect(market.size).toBe(1);
expect(market.has('soroban-strict')).toBe(true);
expect(market.get('soroban-strict')?.name).toBe('Soroban Strict');
expect(market.list()).toHaveLength(1);
});

it('dedups on id (publishing the same id upserts)', () => {
market.publish(makeProfile());
market.publish(makeProfile({ name: 'Soroban Strict v2' }));
expect(market.size).toBe(1);
expect(market.get('soroban-strict')?.name).toBe('Soroban Strict v2');
});

it('imports a shared artifact and registers it', () => {
const json = exportProfileToJson(makeProfile());
const result = market.importFrom(json);
expect(result.ok).toBe(true);
expect(market.size).toBe(1);
});

it('does not register an invalid artifact', () => {
const result = market.importFrom('{ bad');
expect(result.ok).toBe(false);
expect(market.size).toBe(0);
});

it('round-trips through export() from the registry', () => {
market.publish(makeProfile());
const env = market.export('soroban-strict', { author: { name: 'team-sec' } });
const other = new StellarProfileMarketplace();
expect(other.importFrom(env).ok).toBe(true);
expect(other.get('soroban-strict')).toEqual(market.get('soroban-strict'));
});

it('throws when exporting an unknown profile', () => {
expect(() => market.export('missing')).toThrow();
});

it('searches by text, tag, and category', () => {
market.publish(makeProfile());
market.publish(
makeProfile({
id: 'soroban-gas',
name: 'Soroban Gas',
description: 'Gas-focused checks',
tags: ['performance'],
rules: [{ id: 'SOR-100', enabled: true, severity: 'low', category: 'gas' }],
}),
);

expect(market.search()).toHaveLength(2);
expect(market.search({ text: 'gas-focused' }).map((p) => p.id)).toEqual(['soroban-gas']);
expect(market.search({ tags: ['security'] }).map((p) => p.id)).toEqual(['soroban-strict']);
expect(market.search({ category: 'gas' }).map((p) => p.id)).toEqual(['soroban-gas']);
expect(market.search({ text: 'soroban', tags: ['performance'] }).map((p) => p.id)).toEqual([
'soroban-gas',
]);
});
});
37 changes: 37 additions & 0 deletions src/profiles/marketplace/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Soroban Analysis Profile Marketplace (Issue #484)
*
* Public entry point for sharing and importing Soroban analysis profiles.
*
* @example
* ```ts
* import {
* StellarProfileMarketplace,
* exportProfileToJson,
* type AnalysisProfile,
* } from 'src/profiles/marketplace/stellar';
*
* const market = new StellarProfileMarketplace();
* market.publish(myProfile);
*
* // Share it
* const json = exportProfileToJson(myProfile, { author: { name: 'team-sec' } });
*
* // Someone else imports it
* const other = new StellarProfileMarketplace();
* const result = other.importFrom(json);
* if (result.ok) console.log('imported', result.profile?.name);
* ```
*/

export * from './types';
export {
MARKETPLACE_VERSION,
canonicalize,
computeChecksum,
exportProfile,
exportProfileToJson,
serializeEnvelope,
} from './profile-exporter';
export { importProfile, isSchemaCompatible, validateProfile } from './profile-importer';
export { StellarProfileMarketplace } from './marketplace';
125 changes: 125 additions & 0 deletions src/profiles/marketplace/stellar/marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Soroban Analysis Profile Marketplace — Registry (Issue #484)
*
* An in-memory marketplace that teams use to share and reuse Soroban analysis
* profiles: publish/import profiles, list and search them, and export any
* registered profile back to a portable artifact for sharing elsewhere.
*
* Storage is intentionally pluggable-friendly (a simple Map keyed by profile
* id); a persistent backend can be layered on without changing this API.
*/

import { exportProfile } from './profile-exporter';
import { importProfile } from './profile-importer';
import {
AnalysisProfile,
ExportOptions,
MarketplaceSearchQuery,
ProfileImportResult,
SharedProfileEnvelope,
} from './types';

export class StellarProfileMarketplace {
private readonly profiles = new Map<string, AnalysisProfile>();

/** Number of profiles currently registered. */
get size(): number {
return this.profiles.size;
}

/** Whether a profile with the given id is registered. */
has(id: string): boolean {
return this.profiles.has(id);
}

/** Get a registered profile by id, or `undefined`. */
get(id: string): AnalysisProfile | undefined {
return this.profiles.get(id);
}

/** List all registered profiles (sorted by name for stable output). */
list(): AnalysisProfile[] {
return [...this.profiles.values()].sort((a, b) => a.name.localeCompare(b.name));
}

/**
* Publish a profile directly into the marketplace.
*
* Registration is idempotent on `id`: publishing a profile whose id already
* exists replaces the previous entry (an upsert), so re-sharing an updated
* profile does not create duplicates.
*/
publish(profile: AnalysisProfile): void {
this.profiles.set(profile.id, profile);
}

/** Remove a profile by id. Returns `true` if one was removed. */
remove(id: string): boolean {
return this.profiles.delete(id);
}

/**
* Import a shared artifact (JSON string or envelope object) and, when valid,
* register it. The import result is returned so callers can surface any
* validation errors/warnings; nothing is registered on failure.
*/
importFrom(raw: string | object): ProfileImportResult {
const result = importProfile(raw);
if (result.ok && result.profile) {
this.publish(result.profile);
}
return result;
}

/**
* Export a registered profile to a shareable envelope.
*
* @throws if no profile with `id` is registered.
*/
export(id: string, options: ExportOptions = {}): SharedProfileEnvelope {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Profile '${id}' is not registered in the marketplace`);
}
return exportProfile(profile, options);
}

/**
* Search registered profiles. With no criteria, returns all profiles.
* Multiple criteria are combined with AND; within `tags`, match is any-of.
*/
search(query: MarketplaceSearchQuery = {}): AnalysisProfile[] {
const text = query.text?.toLowerCase().trim();
const tags = query.tags?.filter((t) => t.length > 0);
const category = query.category?.toLowerCase().trim();

return this.list().filter((profile) => {
if (text) {
const haystack = `${profile.name} ${profile.description}`.toLowerCase();
if (!haystack.includes(text)) {
return false;
}
}
if (tags && tags.length > 0) {
const profileTags = profile.tags ?? [];
if (!tags.some((t) => profileTags.includes(t))) {
return false;
}
}
if (category) {
const hasCategory = (profile.rules ?? []).some(
(rule) => typeof rule.category === 'string' && rule.category.toLowerCase() === category,
);
if (!hasCategory) {
return false;
}
}
return true;
});
}

/** Remove all registered profiles. */
clear(): void {
this.profiles.clear();
}
}
Loading
Loading