mirror of
https://git.openapi.site/https://github.com/desirecore/registry.git
synced 2026-09-05 16:54:16 +08:00
feat: 建立统一目录元数据与获取契约 (#5)
* feat: 建立 Registry 目录元数据验证基础 * feat: 迁移 Registry 应用目录元数据 * test: 解耦目录迁移进度断言 * feat: 迁移 Registry 服务目录元数据 * fix(catalog): 强制 Registry 目录元数据 * fix(catalog): 兼容 Registry v4 外部集成
This commit is contained in:
415
scripts/catalog/validator.test.mjs
Normal file
415
scripts/catalog/validator.test.mjs
Normal file
@@ -0,0 +1,415 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { afterEach, test } from 'node:test'
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateRegistry } from './validator.mjs'
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const temporaryRoots = []
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryRoots.length > 0) rmSync(temporaryRoots.pop(), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeJson(file, value) {
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function legacyApp(overrides = {}) {
|
||||
return {
|
||||
id: 'demo-app',
|
||||
name: 'Demo App',
|
||||
type: 'docker-app',
|
||||
version: '1.2.3',
|
||||
author: 'Demo Org',
|
||||
description: '一个用于测试的应用',
|
||||
tags: ['demo'],
|
||||
icon: 'box',
|
||||
iconLetter: 'D',
|
||||
platformSupport: ['macos', 'windows', 'linux'],
|
||||
category: 'tools',
|
||||
shortDesc: '一个用于测试的应用',
|
||||
fullDesc: '这是用于验证 Registry 契约的测试应用。',
|
||||
install: {
|
||||
method: 'docker',
|
||||
requirements: { docker: true, minMemory: '1GB', minDisk: '1GB', ports: [8080] },
|
||||
configNeeded: ['Docker'],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function legacyMcp(overrides = {}) {
|
||||
return {
|
||||
id: 'demo-mcp',
|
||||
name: 'Demo MCP',
|
||||
type: 'mcp',
|
||||
version: '2026.8.31',
|
||||
author: 'Demo Org',
|
||||
description: '一个用于测试的 MCP 服务',
|
||||
tags: ['demo'],
|
||||
icon: 'terminal',
|
||||
platformSupport: ['macos', 'windows', 'linux'],
|
||||
capabilities: ['read'],
|
||||
toolCount: 1,
|
||||
install: { method: 'npx', packageName: '@demo/mcp', command: 'npx', args: ['-y', '@demo/mcp@1.0.0'] },
|
||||
connection: { transport: 'stdio', command: 'npx', args: ['-y', '@demo/mcp@1.0.0'] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function legacyHttp(overrides = {}) {
|
||||
return {
|
||||
id: 'demo-http',
|
||||
name: 'Demo HTTP',
|
||||
type: 'http-api',
|
||||
version: '3.0',
|
||||
author: 'Demo Org',
|
||||
description: '一个用于测试的 HTTP 服务',
|
||||
tags: ['demo'],
|
||||
icon: 'globe',
|
||||
platformSupport: ['macos', 'windows', 'linux'],
|
||||
endpoint: 'https://api.example.com/v1',
|
||||
capabilities: ['query'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function unknownTimestamps(overrides = {}) {
|
||||
return {
|
||||
catalogUpdatedAt: { state: 'unknown' },
|
||||
releasePublishedAt: { state: 'unknown' },
|
||||
reviewedAt: { state: 'unknown' },
|
||||
upstreamObservedAt: { state: 'unknown' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function appSidecar(overrides = {}) {
|
||||
return {
|
||||
$schema: '../../schemas/catalog-metadata.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
identity: { kind: 'app', id: 'demo-app' },
|
||||
presentation: {
|
||||
defaultLocale: 'zh-CN',
|
||||
i18n: {
|
||||
'zh-CN': {
|
||||
name: 'Demo App',
|
||||
summary: '一个用于测试的应用',
|
||||
description: '这是用于验证 Registry 契约的测试应用。',
|
||||
},
|
||||
},
|
||||
tags: ['demo'],
|
||||
},
|
||||
release: { state: 'known', version: '1.2.3', versionScheme: 'semver' },
|
||||
timestamps: unknownTimestamps(),
|
||||
provenance: {},
|
||||
governance: { availability: 'listing-only', license: { state: 'unknown' }, redistribution: 'verify-package-terms' },
|
||||
compatibility: { platforms: { state: 'all' } },
|
||||
spec: { kind: 'app', category: 'tools' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mcpSidecar(overrides = {}) {
|
||||
return {
|
||||
$schema: '../../schemas/catalog-metadata.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
identity: { kind: 'service', id: 'demo-mcp' },
|
||||
presentation: {
|
||||
defaultLocale: 'zh-CN',
|
||||
i18n: { 'zh-CN': { name: 'Demo MCP', summary: '一个用于测试的 MCP 服务' } },
|
||||
tags: ['demo'],
|
||||
},
|
||||
release: { state: 'known', version: '2026.8.31', versionScheme: 'calver' },
|
||||
timestamps: unknownTimestamps(),
|
||||
provenance: {},
|
||||
governance: { availability: 'listing-only', license: { state: 'unknown' }, redistribution: 'verify-package-terms' },
|
||||
compatibility: { platforms: { state: 'known', values: ['macos', 'windows', 'linux'] } },
|
||||
spec: { kind: 'service', protocol: 'mcp', capabilities: ['read'], toolCount: 1 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function httpSidecar(overrides = {}) {
|
||||
return {
|
||||
$schema: '../../schemas/catalog-metadata.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
identity: { kind: 'service', id: 'demo-http' },
|
||||
presentation: {
|
||||
defaultLocale: 'zh-CN',
|
||||
i18n: { 'zh-CN': { name: 'Demo HTTP', summary: '一个用于测试的 HTTP 服务' } },
|
||||
tags: ['demo'],
|
||||
},
|
||||
release: { state: 'known', version: '3.0', versionScheme: 'opaque' },
|
||||
timestamps: unknownTimestamps(),
|
||||
provenance: {},
|
||||
governance: { availability: 'listing-only', license: { state: 'unknown' }, redistribution: 'verify-package-terms' },
|
||||
compatibility: { platforms: { state: 'all' } },
|
||||
spec: { kind: 'service', protocol: 'http', authType: 'unknown', capabilities: ['query'] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRegistry(entries, sidecars = new Map(), rootOverrides = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'registry-validator-'))
|
||||
temporaryRoots.push(root)
|
||||
cpSync(join(repoRoot, 'schemas'), join(root, 'schemas'), { recursive: true })
|
||||
const counts = {
|
||||
totalEntries: entries.length,
|
||||
dockerApps: entries.filter((entry) => entry.type === 'docker-app').length,
|
||||
mcpServices: entries.filter((entry) => entry.type === 'mcp').length,
|
||||
httpApis: entries.filter((entry) => entry.type === 'http-api').length,
|
||||
}
|
||||
writeFileSync(join(root, 'SCHEMA_VERSION'), '3.1.0\n')
|
||||
writeJson(join(root, 'manifest.json'), {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
id: 'desirecore-registry-manifest',
|
||||
version: '3.1.0',
|
||||
name: 'Test Registry',
|
||||
description: 'Test Registry',
|
||||
maintainer: 'Test',
|
||||
repository: 'https://example.com/registry',
|
||||
lastUpdated: '2026-08-31',
|
||||
stats: counts,
|
||||
dataVersion: '3.1.0',
|
||||
catalogMetadata: {
|
||||
version: '1.0.0',
|
||||
schema: 'schemas/catalog-metadata.v1.schema.json',
|
||||
sidecarPath: 'entries/<id>/catalog-metadata.v1.json',
|
||||
required: false,
|
||||
legacyFallback: true,
|
||||
},
|
||||
...rootOverrides,
|
||||
})
|
||||
for (const entry of entries) {
|
||||
const entryDir = join(root, 'entries', entry.id)
|
||||
writeJson(join(entryDir, 'manifest.json'), entry)
|
||||
if (sidecars.has(entry.id)) writeJson(join(entryDir, 'catalog-metadata.v1.json'), sidecars.get(entry.id))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function codes(report) {
|
||||
return report.errors.map((item) => item.code)
|
||||
}
|
||||
|
||||
test('当前 v4 条目保持可读,21 个 App/Service 具备 sidecar', () => {
|
||||
const report = validateRegistry(repoRoot)
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
assert.deepEqual({
|
||||
totalEntries: report.counts.totalEntries,
|
||||
dockerApps: report.counts.dockerApps,
|
||||
mcpServices: report.counts.mcpServices,
|
||||
httpApis: report.counts.httpApis,
|
||||
externalIntegrations: report.counts.externalIntegrations,
|
||||
}, {
|
||||
totalEntries: 22,
|
||||
dockerApps: 8,
|
||||
mcpServices: 8,
|
||||
httpApis: 5,
|
||||
externalIntegrations: 1,
|
||||
})
|
||||
assert.equal(report.counts.sidecars + report.counts.legacyOnly, report.counts.totalEntries - report.counts.externalIntegrations)
|
||||
assert.equal(report.warnings.filter((item) => item.code === 'missing-sidecar').length, report.counts.legacyOnly)
|
||||
})
|
||||
|
||||
test('合法 listing-only App sidecar 通过', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', appSidecar()]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
assert.equal(report.counts.sidecars, 1)
|
||||
})
|
||||
|
||||
test('合法 MCP sidecar 保留 CalVer、能力和 toolCount', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyMcp()], new Map([['demo-mcp', mcpSidecar()]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
})
|
||||
|
||||
test('合法 HTTP sidecar 保留 opaque 版本、协议、鉴权 unknown 和能力', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyHttp()], new Map([['demo-http', httpSidecar()]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
})
|
||||
|
||||
test('stats 与实际目录不一致时失败', () => {
|
||||
const root = makeRegistry([legacyApp()], new Map(), { stats: { totalEntries: 99, dockerApps: 8, mcpServices: 0, httpApis: 0 } })
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('stats-mismatch'))
|
||||
})
|
||||
|
||||
test('目录名与 manifest.id 不一致时失败', () => {
|
||||
const root = makeRegistry([legacyApp()])
|
||||
const source = join(root, 'entries', 'demo-app')
|
||||
const target = join(root, 'entries', 'wrong-directory')
|
||||
cpSync(source, target, { recursive: true })
|
||||
rmSync(source, { recursive: true, force: true })
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('directory-id-mismatch'))
|
||||
})
|
||||
|
||||
test('legacy type-specific 必需字段缺失时失败', () => {
|
||||
const entry = legacyMcp()
|
||||
delete entry.connection
|
||||
const report = validateRegistry(makeRegistry([entry]))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('schema'))
|
||||
})
|
||||
|
||||
test('sidecar 严格拒绝额外字段和正文 sourceId', () => {
|
||||
const sidecar = appSidecar({ sourceId: 'registry:official', unexpected: true })
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(report.errors.filter((item) => item.code === 'schema').length >= 2)
|
||||
})
|
||||
|
||||
test('sidecar 拒绝正文 official 治理声明', () => {
|
||||
const sidecar = appSidecar({
|
||||
governance: {
|
||||
availability: 'listing-only',
|
||||
stewardship: 'official',
|
||||
license: { state: 'unknown' },
|
||||
redistribution: 'verify-package-terms',
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('schema'))
|
||||
})
|
||||
|
||||
test('i18n 必须包含默认语言且不能伪造重复翻译', () => {
|
||||
const missingDefault = appSidecar({
|
||||
presentation: { defaultLocale: 'en-US', i18n: { 'zh-CN': appSidecar().presentation.i18n['zh-CN'] } },
|
||||
})
|
||||
let report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', missingDefault]])))
|
||||
assert.ok(codes(report).includes('missing-default-locale'))
|
||||
|
||||
const duplicated = appSidecar()
|
||||
duplicated.presentation.i18n['en-US'] = { ...duplicated.presentation.i18n['zh-CN'] }
|
||||
report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', duplicated]])))
|
||||
assert.ok(codes(report).includes('duplicate-translation'))
|
||||
})
|
||||
|
||||
test('证据路径拒绝绝对路径和目录穿越', () => {
|
||||
const sidecar = appSidecar({
|
||||
provenance: { content: { kind: 'git', url: 'https://example.com/repo.git', ref: 'a'.repeat(40), path: '../secret' } },
|
||||
governance: {
|
||||
availability: 'listing-only',
|
||||
license: { state: 'unknown' },
|
||||
redistribution: 'verify-package-terms',
|
||||
compliance: {
|
||||
licenseEvidencePath: '/tmp/LICENSE',
|
||||
reviewedRef: 'a'.repeat(40),
|
||||
reviewedAt: '2026-08-31',
|
||||
reviewedBy: 'test',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.equal(codes(report).filter((code) => code === 'unsafe-evidence-path').length, 2)
|
||||
})
|
||||
|
||||
test('mutable source 可 listing-only,但不能 installable', () => {
|
||||
const mutableSource = { kind: 'git', url: 'https://example.com/repo.git', ref: 'main' }
|
||||
let sidecar = appSidecar({ provenance: { content: mutableSource } })
|
||||
let report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
|
||||
sidecar = appSidecar({
|
||||
provenance: { content: mutableSource },
|
||||
timestamps: unknownTimestamps({ reviewedAt: { state: 'known', value: '2026-08-31', precision: 'day' } }),
|
||||
governance: {
|
||||
availability: 'installable',
|
||||
stewardship: 'pointer',
|
||||
license: { state: 'known', value: 'MIT', evidencePath: 'LICENSE' },
|
||||
redistribution: 'source-pointer-only',
|
||||
listingMaintainer: { name: 'DesireCore Team', verified: true },
|
||||
upstreamMaintainer: { name: 'Demo Org', verified: false },
|
||||
branding: { relationship: 'independent-listing', nameUsage: 'nominative', logoStatus: 'not-used' },
|
||||
compliance: {
|
||||
licenseEvidencePath: 'LICENSE',
|
||||
reviewedRef: 'main',
|
||||
reviewedAt: '2026-08-31',
|
||||
reviewedBy: 'catalog-review-v1',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('installable-without-evidence'))
|
||||
})
|
||||
|
||||
test('完整不可变来源与审核证据允许 installable', () => {
|
||||
const ref = 'a'.repeat(40)
|
||||
const sidecar = appSidecar({
|
||||
provenance: { content: { kind: 'git', url: 'https://example.com/repo.git', ref } },
|
||||
timestamps: unknownTimestamps({ reviewedAt: { state: 'known', value: '2026-08-31', precision: 'day' } }),
|
||||
governance: {
|
||||
availability: 'installable',
|
||||
stewardship: 'pointer',
|
||||
license: { state: 'known', value: 'MIT', evidencePath: 'LICENSE' },
|
||||
redistribution: 'source-pointer-only',
|
||||
listingMaintainer: { name: 'DesireCore Team', verified: true },
|
||||
upstreamMaintainer: { name: 'Demo Org', verified: false },
|
||||
branding: { relationship: 'independent-listing', nameUsage: 'nominative', logoStatus: 'not-used' },
|
||||
compliance: {
|
||||
licenseEvidencePath: 'LICENSE',
|
||||
reviewedRef: ref,
|
||||
reviewedAt: '2026-08-31',
|
||||
reviewedBy: 'catalog-review-v1',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
})
|
||||
|
||||
test('审核 ref 和审核时间必须绑定同一来源与 timestamps', () => {
|
||||
const sidecar = appSidecar({
|
||||
provenance: { content: { kind: 'git', url: 'https://example.com/repo.git', ref: 'a'.repeat(40) } },
|
||||
timestamps: unknownTimestamps({ reviewedAt: { state: 'known', value: '2026-08-31', precision: 'day' } }),
|
||||
governance: {
|
||||
availability: 'listing-only',
|
||||
license: { state: 'unknown' },
|
||||
redistribution: 'verify-package-terms',
|
||||
compliance: {
|
||||
licenseEvidencePath: 'LICENSE',
|
||||
reviewedRef: 'b'.repeat(40),
|
||||
reviewedAt: '2026-08-30',
|
||||
reviewedBy: 'catalog-review-v1',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.ok(codes(report).includes('review-ref-mismatch'))
|
||||
assert.ok(codes(report).includes('review-time-mismatch'))
|
||||
})
|
||||
|
||||
test('--require-sidecars 等价策略会让 legacy-only 失败', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyApp()]), { requireSidecars: true })
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('missing-sidecar'))
|
||||
})
|
||||
|
||||
test('根 manifest 声明 required 时默认拒绝缺失 sidecar', () => {
|
||||
const root = makeRegistry([legacyApp()], new Map(), {
|
||||
catalogMetadata: {
|
||||
version: '1.0.0',
|
||||
schema: 'schemas/catalog-metadata.v1.schema.json',
|
||||
sidecarPath: 'entries/<id>/catalog-metadata.v1.json',
|
||||
required: true,
|
||||
legacyFallback: true,
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('missing-sidecar'))
|
||||
})
|
||||
Reference in New Issue
Block a user