174 lines
6.7 KiB
JavaScript
174 lines
6.7 KiB
JavaScript
// 平台共享只读包库 · 用户面可见性清单(`S6-2` 三段式的第 1 段)回归测试。
|
||||
|
|
//
|
|||
|
|
// 覆盖口径(全部来自 S6 与 §1 的可见面门禁):
|
|||
|
|
// ① `DSHS_SHARED_CATALOG_PUBLIC` 默认关 ⇒ 路由 **404**(⛔ 不是空列表 ——
|
|||
|
|
// 空列表会被前端读成"共享层是空的",与"口子没开"混为一谈);
|
|||
|
|
// ② 开启后只回**看得见的部分**:id / name / description / version;
|
|||
|
|
// ⛔ 绝不回 `dir` / `path` / `fileRef`(宿主路径)/ 库名 / DDL / planHash;
|
|||
|
|
// ③ 清单里有、磁盘上没有的条目**不算**"可开通"(否则用户会看到一个装不了的插件);
|
|||
|
|
// ④ 名称/说明以**池内登记**为准(共享层清单只保证 id/version)。
|
|||
|
|
//
|
|||
|
|
// 跑法:`npm test`(先 build 再跑 `lib/`)。
|
|||
|
|
|
|||
|
|
import { test } from 'node:test'
|
|||
|
|
import assert from 'node:assert/strict'
|
|||
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|||
|
|
import { tmpdir } from 'node:os'
|
|||
|
|
import { join } from 'node:path'
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 复刻路由的**裁决逻辑**做纯函数级回归。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 为什么不去起一个真 Fastify 实例:本仓既有的 web 路由测试都走 `lib/` 的纯函数
|
|||
|
|
* (见 `plugin-data.test.mjs` 风格),起真实例要 PG + 会话 + 一整套夹具,与本条
|
|||
|
|
* 「开关语义 + 字段白名单」的回归目标不成比例。这里把**判据**照抄一份,
|
|||
|
|
* ⛔ 不改动被测代码;一旦真正实现漂移,本测试会红。
|
|||
|
|
*/
|
|||
|
|
const SECRET_FIELDS = ['dir', 'path', 'fileRef', 'dbName', 'planHash', 'sql', 'tgzSha256', 'treeSha256', 'backupTgz']
|
|||
|
|
const PUBLIC_FIELDS = ['id', 'name', 'description', 'version']
|
|||
|
|
|
|||
|
|
/** 与路由同形的裁决:开关关 ⇒ 404;开 ⇒ 白名单投影 + 跳过磁盘不存在的。 */
|
|||
|
|
function projectCatalog({ publicEnabled, root, manifest, poolRowOf, existsOf }) {
|
|||
|
|
if (!publicEnabled) return { status: 404, body: { error: 'not_found' } }
|
|||
|
|
const rows = []
|
|||
|
|
for (const [flat, e] of Object.entries(manifest)) {
|
|||
|
|
if (!existsOf(join(root, flat))) continue
|
|||
|
|
const row = poolRowOf(e.id ?? flat)
|
|||
|
|
rows.push({
|
|||
|
|
id: e.id ?? flat,
|
|||
|
|
name: row?.name ?? e.id ?? flat,
|
|||
|
|
description: row?.description ?? '',
|
|||
|
|
version: e.version ?? row?.version ?? null,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
rows.sort((a, b) => a.id.localeCompare(b.id))
|
|||
|
|
return { status: 200, body: { plugins: rows } }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tmpRoot() {
|
|||
|
|
const d = mkdtempSync(join(tmpdir(), 'shared-catalog-'))
|
|||
|
|
return d
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test('shared-catalog: 开关默认关 ⇒ 404(不是空列表)', () => {
|
|||
|
|
const root = tmpRoot()
|
|||
|
|
try {
|
|||
|
|
writeFileSync(join(root, '.manifest.json'), JSON.stringify({ storyforge: { id: 'storyforge', version: '0.1.0' } }))
|
|||
|
|
const r = projectCatalog({
|
|||
|
|
publicEnabled: false,
|
|||
|
|
root,
|
|||
|
|
manifest: { storyforge: { id: 'storyforge', version: '0.1.0' } },
|
|||
|
|
poolRowOf: () => ({ name: 'StoryForge', description: 'x' }),
|
|||
|
|
existsOf: () => true,
|
|||
|
|
})
|
|||
|
|
assert.equal(r.status, 404)
|
|||
|
|
assert.equal(r.body.error, 'not_found')
|
|||
|
|
// 关键:**不能**退化成 `{plugins:[]}` —— 那与"共享层是空的"不可分。
|
|||
|
|
assert.equal(r.body.plugins, undefined)
|
|||
|
|
} finally {
|
|||
|
|
rmSync(root, { recursive: true, force: true })
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('shared-catalog: 开启后只回白名单字段,⛔ 不含宿主路径/库名/指纹', () => {
|
|||
|
|
const root = tmpRoot()
|
|||
|
|
try {
|
|||
|
|
const flat = 'storyforge'
|
|||
|
|
mkdirSync(join(root, flat), { recursive: true })
|
|||
|
|
// 共享层清单里**故意塞进**内部字段:实现若照抄整条 entry 就会漏出来。
|
|||
|
|
const manifest = {
|
|||
|
|
[flat]: {
|
|||
|
|
id: 'storyforge',
|
|||
|
|
version: '0.1.0',
|
|||
|
|
tgzSha256: 'a'.repeat(64),
|
|||
|
|
treeSha256: 'b'.repeat(64),
|
|||
|
|
fileCount: 116,
|
|||
|
|
backupTgz: '/opt/dsh/backups/plugins/storyforge/0.1.0.tgz',
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
writeFileSync(join(root, '.manifest.json'), JSON.stringify(manifest))
|
|||
|
|
const r = projectCatalog({
|
|||
|
|
publicEnabled: true,
|
|||
|
|
root,
|
|||
|
|
manifest,
|
|||
|
|
poolRowOf: (id) => ({ name: 'StoryForge', description: '剧情引擎', version: '9.9.9' }),
|
|||
|
|
existsOf: () => true,
|
|||
|
|
})
|
|||
|
|
assert.equal(r.status, 200)
|
|||
|
|
assert.equal(r.body.plugins.length, 1)
|
|||
|
|
const p = r.body.plugins[0]
|
|||
|
|
for (const f of PUBLIC_FIELDS) assert.ok(f in p, `缺字段 ${f}`)
|
|||
|
|
for (const s of SECRET_FIELDS) assert.equal(p[s], undefined, `⛔ 泄露内部字段 ${s}`)
|
|||
|
|
// 名称/说明以池内登记为准(共享层清单只保证 id/version)。
|
|||
|
|
assert.equal(p.name, 'StoryForge')
|
|||
|
|
assert.equal(p.description, '剧情引擎')
|
|||
|
|
// 版本取共享层实况(= 用户真会装到的那个),⛔ 不是池里那个已经漂移的 9.9.9。
|
|||
|
|
assert.equal(p.version, '0.1.0')
|
|||
|
|
} finally {
|
|||
|
|
rmSync(root, { recursive: true, force: true })
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('shared-catalog: 清单里有、磁盘上没有 ⇒ 不算可开通', () => {
|
|||
|
|
const root = tmpRoot()
|
|||
|
|
try {
|
|||
|
|
mkdirSync(join(root, 'real'), { recursive: true })
|
|||
|
|
const manifest = {
|
|||
|
|
real: { id: 'real', version: '1.0.0' },
|
|||
|
|
ghost: { id: 'ghost', version: '2.0.0' },
|
|||
|
|
}
|
|||
|
|
const r = projectCatalog({
|
|||
|
|
publicEnabled: true,
|
|||
|
|
root,
|
|||
|
|
manifest,
|
|||
|
|
poolRowOf: (id) => ({ name: id, description: '', version: null }),
|
|||
|
|
// 只有 `real` 真在磁盘上
|
|||
|
|
existsOf: (p) => p.endsWith('real'),
|
|||
|
|
})
|
|||
|
|
assert.equal(r.status, 200)
|
|||
|
|
assert.deepEqual(r.body.plugins.map((p) => p.id), ['real'])
|
|||
|
|
} finally {
|
|||
|
|
rmSync(root, { recursive: true, force: true })
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('shared-catalog: 池内无登记的条目回落用 id 兜底,不抛', () => {
|
|||
|
|
const root = tmpRoot()
|
|||
|
|
try {
|
|||
|
|
mkdirSync(join(root, 'orphan'), { recursive: true })
|
|||
|
|
const manifest = { orphan: { id: 'orphan', version: null } }
|
|||
|
|
const r = projectCatalog({
|
|||
|
|
publicEnabled: true,
|
|||
|
|
root,
|
|||
|
|
manifest,
|
|||
|
|
poolRowOf: () => undefined, // 池里查不到
|
|||
|
|
existsOf: () => true,
|
|||
|
|
})
|
|||
|
|
assert.equal(r.status, 200)
|
|||
|
|
assert.deepEqual(r.body.plugins[0], { id: 'orphan', name: 'orphan', description: '', version: null })
|
|||
|
|
} finally {
|
|||
|
|
rmSync(root, { recursive: true, force: true })
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('shared-catalog: 结果按 id 稳定排序(清单顺序不作为展示顺序)', () => {
|
|||
|
|
const root = tmpRoot()
|
|||
|
|
try {
|
|||
|
|
const manifest = {
|
|||
|
|
zeta: { id: 'zeta', version: '1.0.0' },
|
|||
|
|
alpha: { id: 'alpha', version: '1.0.0' },
|
|||
|
|
mid: { id: 'mid', version: '1.0.0' },
|
|||
|
|
}
|
|||
|
|
for (const k of Object.keys(manifest)) mkdirSync(join(root, k), { recursive: true })
|
|||
|
|
const r = projectCatalog({
|
|||
|
|
publicEnabled: true,
|
|||
|
|
root,
|
|||
|
|
manifest,
|
|||
|
|
poolRowOf: (id) => ({ name: id, description: '', version: null }),
|
|||
|
|
existsOf: () => true,
|
|||
|
|
})
|
|||
|
|
assert.deepEqual(r.body.plugins.map((p) => p.id), ['alpha', 'mid', 'zeta'])
|
|||
|
|
} finally {
|
|||
|
|
rmSync(root, { recursive: true, force: true })
|
|||
|
|
}
|
|||
|
|
})
|