skillhub/cli/test/unit/services/install-service.test.ts
dongmucat e50140272b fix(security): close review hardening gaps
Signed-off-by: dongmucat <1127093059@qq.com>
2026-06-16 17:48:15 +08:00

190 lines
6.8 KiB
TypeScript

import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync } from 'fflate'
import { installSkill } from '../../../src/services/install-service'
const originalFetch = globalThis.fetch
async function exists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
function installFetch(zipEntries: Record<string, string>): typeof fetch {
const archive = zipSync(Object.fromEntries(
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
))
return installFetchWithDownloadResponse(new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
))
}
function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch {
const fakeFetch = async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
return Response.json({
code: 0,
data: {
namespace: 'global',
slug: 'demo',
version: '1.0.0',
versionId: 1,
fingerprint: 'fp',
downloadUrl: '/download'
}
})
}
if (path.endsWith('/download')) {
return downloadResponse.clone()
}
return Response.json({ code: 404 }, { status: 404 })
}
return fakeFetch as unknown as typeof fetch
}
describe('installSkill', () => {
afterEach(() => {
globalThis.fetch = originalFetch
})
test('fails when target skill directory already exists without metadata', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'local.txt'), 'keep')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('skill already installed')
})
test('force replaces the old skill directory instead of overlaying files', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'stale.txt'), 'old')
await installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# New')
expect(await exists(join(skillDir, 'stale.txt'))).toBe(false)
})
test('force removes stale inventory records that point at the replaced install directory', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Team Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-04-20T00:00:00.000Z'
}]
}]
}))
await installSkill({
registry: 'http://registry.test',
namespace: 'team',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'team', slug: 'demo' })
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('force keeps old installation and inventory when replacement extraction fails', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 }))
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-04-20T00:00:00.000Z'
}]
}]
}, null, 2))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('invalid zip central directory')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' })
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('rejects downloads whose content-length exceeds the package limit', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
status: 200,
headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) }
}))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('download exceeds maximum package size')
})
})