mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
541 lines
18 KiB
TypeScript
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)
|
|
})
|
|
})
|