cc-haha/src/server/__tests__/skill-market.test.ts
2026-07-03 23:32:00 +08:00

541 lines
18 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
import { createSkillMarketService } from '../services/skillMarket/service.js'
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
import {
CLAWHUB_SCAN_RESPONSE,
CLAWHUB_TOP_SKILLS_RESPONSE,
SKILLHUB_DETAIL_RESPONSE,
SKILLHUB_TOP_SKILLS_RESPONSE,
} from './fixtures/skill-market.js'
describe('skill market fixtures', () => {
it('keeps representative ClawHub fixture shape stable', () => {
expect(CLAWHUB_TOP_SKILLS_RESPONSE.items[0]).toMatchObject({
slug: 'skill-vetter',
displayName: 'Skill Vetter',
stats: expect.objectContaining({ downloads: expect.any(Number) }),
})
})
it('keeps representative SkillHub fixture shape stable', () => {
expect(SKILLHUB_TOP_SKILLS_RESPONSE.data.skills[0]).toMatchObject({
slug: 'skill-vetter',
source: 'clawhub',
labels: expect.objectContaining({ requires_api_key: 'false' }),
})
})
})
describe('skill market source normalization', () => {
it('normalizes ClawHub catalog items as primary clean candidates', () => {
const result = normalizeClawHubList(CLAWHUB_TOP_SKILLS_RESPONSE)
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
source: 'clawhub',
sourceMode: 'primary',
slug: 'skill-vetter',
displayName: 'Skill Vetter',
canonicalUrl: 'https://clawhub.ai/skill-vetter',
trustState: 'clean',
installed: false,
requiresApiKey: false,
})
})
it('normalizes ClawHub scan responses into trust metadata', () => {
expect(normalizeClawHubScan(CLAWHUB_SCAN_RESPONSE)).toEqual({
trustState: 'clean',
trustSummary: 'No dangerous patterns detected.',
packageSha256: 'a'.repeat(64),
})
})
it('keeps malicious ClawHub scan responses blocked even with warnings', () => {
expect(normalizeClawHubScan({ status: 'malicious', hasWarnings: true })).toMatchObject({
trustState: 'blocked',
})
})
it('uses malicious ClawHub scanner summaries for malicious scans', () => {
expect(normalizeClawHubScan({
status: 'malicious',
scanners: {
metadata: { status: 'clean', summary: 'No dangerous patterns detected.' },
staticAnalysis: { status: 'malicious', summary: 'Credential exfiltration detected.' },
},
})).toMatchObject({
trustState: 'blocked',
trustSummary: 'Credential exfiltration detected.',
})
})
it('prioritizes malicious ClawHub scanner results over clean top-level status', () => {
expect(normalizeClawHubScan({
status: 'clean',
scanners: {
metadata: { status: 'clean', summary: 'No dangerous patterns detected.' },
staticAnalysis: { status: 'malicious', summary: 'Credential exfiltration detected.' },
},
})).toMatchObject({
trustState: 'blocked',
trustSummary: 'Credential exfiltration detected.',
})
})
it('does not use clean ClawHub scanner summaries for blocked scans', () => {
expect(normalizeClawHubScan({
status: 'malicious',
scanners: {
metadata: { status: 'clean', summary: 'No dangerous patterns detected.' },
},
})).toEqual({
trustState: 'blocked',
trustSummary: undefined,
packageSha256: undefined,
})
})
it('does not use clean ClawHub scanner summaries for warning scans', () => {
expect(normalizeClawHubScan({
status: 'suspicious',
scanners: {
metadata: { status: 'clean', summary: 'No dangerous patterns detected.' },
},
})).toEqual({
trustState: 'warning',
trustSummary: undefined,
packageSha256: undefined,
})
})
it('prioritizes warning ClawHub scanner results over clean top-level status', () => {
expect(normalizeClawHubScan({
status: 'clean',
scanners: {
metadata: { status: 'clean', summary: 'No dangerous patterns detected.' },
staticAnalysis: { status: 'warning', summary: 'Reads shell profile files.' },
},
})).toMatchObject({
trustState: 'warning',
trustSummary: 'Reads shell profile files.',
})
})
it('maps ClawHub top-level warning status to warning trust state', () => {
expect(normalizeClawHubScan({
status: 'warning',
scanners: {
staticAnalysis: { status: 'warning', summary: 'Reads shell profile files.' },
},
})).toMatchObject({
trustState: 'warning',
trustSummary: 'Reads shell profile files.',
})
})
it('does not use clean ClawHub scanner summaries for unknown scans', () => {
expect(normalizeClawHubScan({
status: 'unknown',
scanners: {
metadata: { status: 'clean', summary: 'No dangerous patterns detected.' },
},
})).toEqual({
trustState: 'unknown',
trustSummary: undefined,
packageSha256: undefined,
})
})
it('does not use unscored ClawHub scanner summaries for unknown scans', () => {
expect(normalizeClawHubScan({
status: 'unknown',
scanners: {
metadata: { summary: 'No dangerous patterns detected.' },
},
})).toEqual({
trustState: 'unknown',
trustSummary: undefined,
packageSha256: undefined,
})
})
it('normalizes SkillHub list items as fallback candidates with Chinese summary', () => {
const result = normalizeSkillHubList(SKILLHUB_TOP_SKILLS_RESPONSE)
expect(result.items[0]).toMatchObject({
source: 'skillhub',
sourceMode: 'fallback',
slug: 'skill-vetter',
summaryZh: 'AI智能体技能安全预审工具。',
canonicalUrl: 'https://clawhub.ai/spclaudehome/skill-vetter',
license: 'Apache-2.0',
tags: ['GitHub', 'Permission'],
trustState: 'unknown',
requiresApiKey: false,
})
})
it('normalizes verified SkillHub list items as signed', () => {
const result = normalizeSkillHubList({
code: 0,
data: {
skills: [
{
slug: 'verified-skill',
name: 'Verified Skill',
upstream_url: 'https://github.com/example/verified-skill',
verified: true,
},
],
},
})
expect(result.items[0]).toMatchObject({
slug: 'verified-skill',
canonicalUrl: 'https://github.com/example/verified-skill',
upstreamUrl: 'https://github.com/example/verified-skill',
trustState: 'signed',
})
})
it('falls back when SkillHub external URLs are invalid', () => {
const list = normalizeSkillHubList({
code: 0,
data: {
skills: [
{
slug: 'unsafe/slug',
name: 'Unsafe URL Skill',
upstream_url: 'http://evil.test/unsafe/slug',
},
],
},
})
expect(list.items[0]).toMatchObject({
canonicalUrl: 'https://skillhub.cn/skills/unsafe%2Fslug',
upstreamUrl: 'https://skillhub.cn/skills/unsafe%2Fslug',
})
const detail = normalizeSkillHubDetail({
securityReports: {
keen: { status: 'benign', statusText: 'safe' },
},
skill: {
slug: 'unsafe/slug',
displayName: 'Unsafe URL Skill',
sourceUrl: 'https://evil.test/unsafe/slug',
},
})
expect(detail).toMatchObject({
canonicalUrl: 'https://skillhub.cn/skills/unsafe%2Fslug',
trustState: 'benign',
})
})
it('rejects SkillHub external URLs with userinfo', () => {
const list = normalizeSkillHubList({
code: 0,
data: {
skills: [
{
slug: 'skill-vetter',
name: 'Skill Vetter',
upstream_url: 'https://evil.test@github.com/path',
},
],
},
})
expect(list.items[0]).toMatchObject({
canonicalUrl: 'https://skillhub.cn/skills/skill-vetter',
upstreamUrl: 'https://skillhub.cn/skills/skill-vetter',
})
const detail = normalizeSkillHubDetail({
skill: {
slug: 'skill-vetter',
displayName: 'Skill Vetter',
sourceUrl: 'https://user:password@github.com/path',
},
})
expect(detail).toMatchObject({
canonicalUrl: 'https://skillhub.cn/skills/skill-vetter',
})
})
it('normalizes SkillHub detail security reports', () => {
const detail = normalizeSkillHubDetail(SKILLHUB_DETAIL_RESPONSE)
expect(detail).toMatchObject({
source: 'skillhub',
sourceMode: 'fallback',
slug: 'skill-vetter',
version: '1.0.1',
license: 'Apache-2.0',
tags: ['GitHub', 'Permission'],
trustState: 'benign',
trustSummary: '安全,无风险',
installEligibility: { status: 'installable' },
})
})
it('falls back to SkillHub skill version when latestVersion is missing', () => {
const detail = normalizeSkillHubDetail({
securityReports: {
keen: { status: 'benign', statusText: 'safe' },
},
skill: {
slug: 'legacy-version-skill',
displayName: 'Legacy Version Skill',
version: '0.9.0',
},
})
expect(detail.version).toBe('0.9.0')
})
it('blocks SkillHub details with warning security reports', () => {
for (const status of ['warning', 'suspicious']) {
const detail = normalizeSkillHubDetail({
securityReports: {
staticAnalysis: { status, statusText: 'Potentially risky tool use.' },
},
skill: {
slug: `${status}-skill`,
displayName: `${status} Skill`,
},
})
expect(detail.trustState).toBe('warning')
expect(detail.trustSummary).toBe('Potentially risky tool use.')
expect(detail.installEligibility).toEqual({
status: 'blocked',
reason: 'SkillHub security report returned warnings.',
})
}
})
it('blocks SkillHub details when security reports are missing', () => {
const detail = normalizeSkillHubDetail({
skill: {
slug: 'unreviewed-skill',
displayName: 'Unreviewed Skill',
},
})
expect(detail.trustState).toBe('unknown')
expect(detail.installEligibility.status).toBe('blocked')
expect(detail.installEligibility.reason).toMatch(/security report is missing or inconclusive/i)
})
it('blocks SkillHub details when security reports are mixed or inconclusive', () => {
const detail = normalizeSkillHubDetail({
securityReports: {
community: { status: 'benign', statusText: 'safe' },
staticAnalysis: { status: 'pending-review', statusText: 'Scanner still reviewing.' },
},
skill: {
slug: 'mixed-report-skill',
displayName: 'Mixed Report Skill',
},
})
expect(detail.trustState).toBe('unknown')
expect(detail.trustSummary).toBeUndefined()
expect(detail.installEligibility.status).toBe('blocked')
expect(detail.installEligibility.reason).toMatch(/security report is missing or inconclusive/i)
})
it('does not use unscored SkillHub report summaries for unknown details', () => {
const detail = normalizeSkillHubDetail({
securityReports: {
community: { status: 'benign', statusText: 'safe' },
staticAnalysis: { statusText: 'No issues detected.' },
},
skill: {
slug: 'unscored-report-skill',
displayName: 'Unscored Report Skill',
},
})
expect(detail.trustState).toBe('unknown')
expect(detail.trustSummary).toBeUndefined()
expect(detail.installEligibility.status).toBe('blocked')
expect(detail.installEligibility.reason).toMatch(/security report is missing or inconclusive/i)
})
it('uses malicious SkillHub report summaries for blocked details', () => {
const detail = normalizeSkillHubDetail({
securityReports: {
community: { status: 'benign', statusText: 'safe' },
staticAnalysis: { status: 'malicious', statusText: 'Credential exfiltration detected.' },
},
skill: {
slug: 'skill-vetter',
displayName: 'Skill Vetter',
},
})
expect(detail).toMatchObject({
trustState: 'blocked',
trustSummary: 'Credential exfiltration detected.',
})
})
})
describe('skill market service source selection', () => {
it('uses ClawHub only in auto mode when ClawHub succeeds and marks installed skills', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: async () => new Set(['skill-vetter']),
})
const result = await service.listSkills({ source: 'auto', limit: 12, query: 'vetter', cursor: 'next-page' })
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
expect(fetchCalls[0]).toContain('sort=downloads')
expect(fetchCalls[0]).toContain('nonSuspiciousOnly=true')
expect(fetchCalls[0]).toContain('limit=12')
expect(fetchCalls[0]).toContain('query=vetter')
expect(fetchCalls[0]).toContain('cursor=next-page')
expect(result).toMatchObject({
source: 'clawhub',
sourceStatus: 'ok',
items: [
{
source: 'clawhub',
slug: 'skill-vetter',
installed: true,
},
],
})
})
it('falls back to SkillHub in auto mode when ClawHub fails and marks installed skills', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
if (String(url).startsWith('https://clawhub.ai/')) {
return new Response('temporarily unavailable', { status: 503 })
}
return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: new Set(['skill-vetter']),
})
const result = await service.listSkills({ source: 'auto', limit: 10, query: 'vetter', cursor: '3' })
expect(fetchCalls).toHaveLength(2)
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
expect(fetchCalls[1]).toStartWith('https://api.skillhub.cn/api/skills')
expect(fetchCalls[1]).toContain('sortBy=downloads')
expect(fetchCalls[1]).toContain('order=desc')
expect(fetchCalls[1]).toContain('limit=10')
expect(fetchCalls[1]).toContain('query=vetter')
expect(fetchCalls[1]).toContain('cursor=3')
expect(result.source).toBe('skillhub')
expect(result.sourceStatus).toBe('fallback')
expect(result.message).toContain('ClawHub unavailable')
expect(result.items[0]).toMatchObject({
source: 'skillhub',
slug: 'skill-vetter',
installed: true,
})
})
it('falls back to SkillHub in auto mode when ClawHub fetch throws', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
if (String(url).startsWith('https://clawhub.ai/')) {
throw new Error('network down')
}
return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: async () => new Set(),
})
const result = await service.listSkills({ source: 'auto' })
expect(fetchCalls).toHaveLength(2)
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
expect(fetchCalls[1]).toStartWith('https://api.skillhub.cn/api/skills')
expect(result.source).toBe('skillhub')
expect(result.sourceStatus).toBe('fallback')
expect(result.message).toContain('ClawHub unavailable')
expect(result.message).toContain('network down')
})
it("does not fallback to SkillHub when source is 'clawhub'", async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return new Response('temporarily unavailable', { status: 503 })
},
installedSkillNames: async () => new Set(),
})
await expect(service.listSkills({ source: 'clawhub' })).rejects.toThrow('ClawHub request failed')
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
})
it("uses SkillHub only when source is 'skillhub'", async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: async () => new Set(),
})
const result = await service.listSkills({ source: 'skillhub', limit: 6, query: 'vetter', cursor: '2' })
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0]).toStartWith('https://api.skillhub.cn/api/skills')
expect(fetchCalls[0]).toContain('sortBy=downloads')
expect(fetchCalls[0]).toContain('order=desc')
expect(fetchCalls[0]).toContain('limit=6')
expect(fetchCalls[0]).toContain('query=vetter')
expect(fetchCalls[0]).toContain('cursor=2')
expect(result.source).toBe('skillhub')
expect(result.items[0]).toMatchObject({
source: 'skillhub',
slug: 'skill-vetter',
installed: false,
})
})
it('rejects unsupported v1 sources instead of treating them as auto', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: async () => new Set(),
})
await expect(
service.listSkills({ source: 'future-source' as 'auto' }),
).rejects.toThrow('Unsupported skill market source')
expect(fetchCalls).toHaveLength(0)
})
})