Files
dsh_shenxian/test/plugin-data.test.mjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

617 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 插件数据面 · 静态声明解析与校验(`DB-03 §三` 的落地件)回归测试。
//
// 覆盖三件事:
// ① 库名换算与白名单复校(B 档"三点兜底"里的生成 + 正则两点);
// ② YAML 子集解析 + 声明校验(9 种中性类型 / 规模上限 / 归属列禁令);
// ③ diff 与「只增不减」禁令(加表/加列/加索引放行;删列/改类型/无默认值非空列拒绝)。
//
// 跑法:`npm test`(先 build 再跑 `lib/`)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
NEUTRAL_TYPES,
PLUGIN_DB_RE,
assertPluginDbName,
declarationDdl,
parseDeclFromDir,
parseYamlSubset,
pluginDbNameOf,
pluginIdOf,
validateDecl,
PluginDataError,
} from '../lib/db/plugin-data/schema.js'
import {
isDuplicateDatabase,
isInsufficientPrivilege,
metaTableDdl,
pluginDbUrlOf,
quoteIdent,
reconcilePluginDatabases,
backupSchemaOnly,
pgDumpEnv,
} from '../lib/db/plugin-data/datastore.js'
import {
FORBIDDEN_KINDS,
dataPlaneStateOf,
diffDecl,
emptyCurrent,
planHashOf,
stateAllowsCreate,
stateAllowsEnable,
stateAllowsMigrate,
} from '../lib/db/plugin-data/diff.js'
// ── ① 库名换算与复校 ───────────────────────────────────────────────────────
test('plugin-data: pluginIdOf 去 scope / 小写 / 折非法字符', () => {
assert.equal(pluginIdOf('@dsh-local/trpg-kit'), 'trpg_kit')
assert.equal(pluginIdOf('mcn-suite'), 'mcn_suite')
assert.equal(pluginIdOf('@scope/a.b'), 'a_b')
assert.equal(pluginIdOf('Plain'), 'plain')
})
test('plugin-data: 库名 = dshs_pl_ 前缀 + pluginId,且通过正则', () => {
assert.equal(pluginDbNameOf('@dsh-local/trpg-kit'), 'dshs_pl_trpg_kit')
assert.match(pluginDbNameOf('mcn-suite'), PLUGIN_DB_RE)
// 无 scope 的名字同样成立(候选池里既有带 scope 的也有不带的)。
assert.match(pluginDbNameOf('storyforge'), PLUGIN_DB_RE)
})
test('plugin-data: assertPluginDbName 拦下非法库名(B 档白名单的第二道兜底)', () => {
// ⚠️ 这是「PG 没有库名前缀级授权」的补偿:非法名字**必须在 DDL 之前**被拒。
// 长度上限:标识部分 `[a-z][a-z0-9_]{0,40}` ⇒ 41 个字符是上限(40+1)。
const maxIdent = 'a'.repeat(41)
for (const bad of [
'evil_probe', // 无前缀
'dshs_pl_9bad', // 数字开头
'dshs_pl_Bad', // 大写
'dshs_pl_', // 空标识
'dshs_pl_a-b', // 连字符(换算是 `_`,非法字符进不来)
'dshs_pl_' + 'a'.repeat(42), // 超长(42 > 41)
'public', // 保留名
"dshs_pl_x'; DROP DATABASE dshs; --", // 注入形态
]) {
assert.throws(() => assertPluginDbName(bad), PluginDataError, `应拒绝:${bad}`)
}
// 边界:41 位标识**通过**(上限之内)。
assert.equal(assertPluginDbName('dshs_pl_' + maxIdent), 'dshs_pl_' + maxIdent)
assert.equal(assertPluginDbName('dshs_pl_trpg_kit'), 'dshs_pl_trpg_kit')
})
// ── ② YAML 子集解析 ────────────────────────────────────────────────────────
test('plugin-data: YAML 子集能解出 DB-03 §三 的示例(块状序列 + 行内流式映射)', () => {
const text = [
'pluginId: trpg_kit',
'schemaVersion: 3',
'tables:',
' - name: character_sheet',
' scope: room',
' columns:',
' - { name: pc_name, type: text, notNull: true, maxBytes: 128 }',
' - { name: sheet, type: json, notNull: true, maxBytes: 16384 }',
' - { name: sheet_rev,type: integer, default: 1 }',
' indexes:',
' - { columns: [room_id, pc_name], unique: true }',
].join('\n')
const v = parseYamlSubset(text)
assert.equal(v.schemaVersion, 3)
assert.equal(v.tables.length, 1)
assert.equal(v.tables[0].name, 'character_sheet')
assert.equal(v.tables[0].scope, 'room')
assert.equal(v.tables[0].columns.length, 3)
assert.equal(v.tables[0].columns[0].name, 'pc_name')
assert.equal(v.tables[0].columns[0].notNull, true)
assert.equal(v.tables[0].columns[0].maxBytes, 128)
assert.deepEqual(v.tables[0].indexes[0].columns, ['room_id', 'pc_name'])
assert.equal(v.tables[0].indexes[0].unique, true)
})
test('plugin-data: YAML 子集拒绝超范围语法(⛔ 不静默猜)', () => {
assert.throws(() => parseYamlSubset('---\na: 1'), /多文档/)
assert.throws(() => parseYamlSubset('\tkey: 1'), /Tab/)
assert.throws(() => parseYamlSubset('a: &anchor 1'), /不支持/)
})
// ── ③ 声明校验 ────────────────────────────────────────────────────────────
const GOOD = {
schemaVersion: 3,
tables: [
{
name: 'character_sheet',
scope: 'room',
columns: [
{ name: 'pc_name', type: 'text', notNull: true, maxBytes: 128, default: '' },
{ name: 'sheet', type: 'json', notNull: true, maxBytes: 16384, default: '{}' },
{ name: 'sheet_rev', type: 'integer', default: 1 },
],
indexes: [{ columns: ['room_id', 'pc_name'], unique: true }],
},
],
}
test('plugin-data: 合法声明通过,9 种中性类型齐备', () => {
const r = validateDecl('@dsh-local/trpg-kit', GOOD, 'test')
assert.equal(r.level, 'ok')
assert.equal(r.decl.dbName, 'dshs_pl_trpg_kit')
assert.equal(r.decl.tables[0].physicalName, 'p_trpg_kit_character_sheet')
assert.deepEqual(r.decl.tables[0].columns.map((c) => c.type), ['text', 'json', 'integer'])
assert.equal(NEUTRAL_TYPES.length, 9)
})
test('plugin-data: 声明里自带归属列 / 审计列 ⇒ 拒绝(DB-03 §一 归属列由内核补)', () => {
for (const reserved of ['user_id', 'room_id', 'created_at', 'updated_at']) {
const r = validateDecl('p', {
schemaVersion: 1,
tables: [{ name: 't', scope: 'user', columns: [{ name: reserved, type: 'text', default: '' }] }],
}, 'test')
assert.equal(r.level, 'invalid', reserved)
assert.ok(r.findings.some((f) => f.code === 'column_reserved'), reserved)
}
})
test('plugin-data: 每表 ≤20 列 / 每插件 ≤20 表(DB-03 §三 规模上限)', () => {
const many = (n) => Array.from({ length: n }, (_, i) => ({ name: `c${i}`, type: 'text', default: '' }))
const over = validateDecl('p', {
schemaVersion: 1,
tables: [{ name: 't', scope: 'user', columns: many(21) }],
}, 'test')
assert.ok(over.findings.some((f) => f.code === 'columns_over_limit'))
const tables = Array.from({ length: 21 }, (_, i) => ({ name: `t${i}`, scope: 'user', columns: many(1) }))
const overT = validateDecl('p', { schemaVersion: 1, tables }, 'test')
assert.ok(overT.findings.some((f) => f.code === 'tables_over_limit'))
})
test('plugin-data: 未知类型 / json 缺 maxBytes / 无默认值非空列 均被拒', () => {
const bad = validateDecl('p', {
schemaVersion: 1,
tables: [{
name: 't',
scope: 'user',
columns: [
{ name: 'a', type: 'varchar(255)' }, // 非中性类型
{ name: 'b', type: 'json', default: '{}' }, // json 缺 maxBytes
{ name: 'c', type: 'text', notNull: true }, // notNull 无默认值
],
}],
}, 'test')
assert.equal(bad.level, 'invalid')
const codes = bad.findings.map((f) => f.code)
assert.ok(codes.includes('column_type_unknown'))
assert.ok(codes.includes('json_maxbytes_required'))
assert.ok(codes.includes('notnull_no_default'))
// 收集式:三条一次全报(不"改一条重传一次")。
assert.equal(bad.findings.length, 3)
})
test('plugin-data: scope 必填且只能是 user / room', () => {
for (const scope of [undefined, 'plugin', 'USER']) {
const r = validateDecl('p', {
schemaVersion: 1,
tables: [{ name: 't', scope, columns: [{ name: 'a', type: 'text', default: '' }] }],
}, 'test')
assert.ok(r.findings.some((f) => f.code === 'scope_invalid'), String(scope))
}
})
// ── ④ 从包目录解析(package.json 的 dsh.data → 内联 / yaml 文件)───────────
function withPkgDir(pkg, files = {}) {
const dir = mkdtempSync(join(tmpdir(), 'dsh-data-decl-'))
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 2))
for (const [rel, text] of Object.entries(files)) {
const abs = join(dir, rel)
mkdirSync(join(abs, '..'), { recursive: true })
writeFileSync(abs, text)
}
return dir
}
test('plugin-data: 无 dsh.data ⇒ level none(不建库、不受门禁)', () => {
const dir = withPkgDir({ name: '@dsh-local/plain', version: '1.0.0' })
try {
const r = parseDeclFromDir(dir, '@dsh-local/plain')
assert.equal(r.level, 'none')
assert.equal(r.origin, 'none')
} finally { rmSync(dir, { recursive: true, force: true }) }
})
test('plugin-data: dsh.data.schema 指向 YAML 文件 ⇒ 解析成功', () => {
const yaml = [
'schemaVersion: 2',
'tables:',
' - name: card',
' scope: user',
' columns:',
' - { name: title, type: text, notNull: true, default: "" }',
].join('\n') + '\n'
const dir = withPkgDir({ name: 'mcn-suite', version: '1.0.0', dsh: { data: { schema: './dsh.data.yaml' } } }, { 'dsh.data.yaml': yaml })
try {
const r = parseDeclFromDir(dir, 'mcn-suite')
assert.equal(r.level, 'ok', JSON.stringify(r.findings))
assert.equal(r.origin, 'yaml-file')
assert.equal(r.decl.dbName, 'dshs_pl_mcn_suite')
assert.equal(r.decl.tables[0].physicalName, 'p_mcn_suite_card')
} finally { rmSync(dir, { recursive: true, force: true }) }
})
test('plugin-data: dsh.data.schema 内联对象 ⇒ 解析成功', () => {
const dir = withPkgDir({
name: 'inline-plug',
version: '1.0.0',
dsh: { data: { schema: { schemaVersion: 1, tables: [{ name: 't', scope: 'user', columns: [{ name: 'a', type: 'bigint', default: 0 }] }] } } },
})
try {
const r = parseDeclFromDir(dir, 'inline-plug')
assert.equal(r.level, 'ok')
assert.equal(r.origin, 'package-json-inline')
} finally { rmSync(dir, { recursive: true, force: true }) }
})
test('plugin-data: 声明文件逃出包目录 / 不存在 ⇒ 拒绝(路径穿越防护)', () => {
const dir = withPkgDir({ name: 'x', version: '1.0.0', dsh: { data: { schema: '../../../etc/passwd' } } })
try {
const r = parseDeclFromDir(dir, 'x')
assert.equal(r.level, 'invalid')
assert.equal(r.findings[0].code, 'schema_escapes_package')
} finally { rmSync(dir, { recursive: true, force: true }) }
const dir2 = withPkgDir({ name: 'y', version: '1.0.0', dsh: { data: { schema: './missing.yaml' } } })
try {
const r = parseDeclFromDir(dir2, 'y')
assert.equal(r.level, 'invalid')
assert.equal(r.findings[0].code, 'schema_file_missing')
} finally { rmSync(dir2, { recursive: true, force: true }) }
})
// ── ⑤ DDL 生成 ────────────────────────────────────────────────────────────
test('plugin-data: DDL 带上库内归属列 / 审计列,表名带 p_<pluginId>_ 前缀', () => {
const r = validateDecl('@dsh-local/trpg-kit', GOOD, 'test')
const ddl = declarationDdl(r.decl)
assert.equal(ddl.length, 2, '一张表 + 一个索引')
assert.match(ddl[0], /CREATE TABLE IF NOT EXISTS "p_trpg_kit_character_sheet"/)
assert.match(ddl[0], /"room_id" TEXT NOT NULL/)
assert.match(ddl[0], /"created_at" BIGINT NOT NULL/)
assert.match(ddl[0], /"updated_at" BIGINT NOT NULL/)
assert.match(ddl[0], /"sheet" JSONB NOT NULL DEFAULT '\{\}'/)
assert.match(ddl[0], /"sheet" JSONB/)
assert.match(ddl[1], /CREATE UNIQUE INDEX IF NOT EXISTS "p_trpg_kit_character_sheet_room_id_pc_name_uq_1"/)
})
test('plugin-data: 默认值里的单引号被转义(SQL 字面量)', () => {
const r = validateDecl('p', {
schemaVersion: 1,
tables: [{ name: 't', scope: 'user', columns: [{ name: 'a', type: 'text', notNull: true, default: "it's" }] }],
}, 'test')
const ddl = declarationDdl(r.decl).join('\n')
assert.match(ddl, /DEFAULT 'it''s'/)
})
// ── ⑥ diff 与「只增不减」禁令 ─────────────────────────────────────────────
const DECL_OK = validateDecl('@dsh-local/trpg-kit', GOOD, 'test').decl
test('plugin-data: 空库 ⇒ 计划含建库 + 建表 + 建索引,无禁令', () => {
const plan = diffDecl(DECL_OK, emptyCurrent())
assert.deepEqual(plan.items.map((i) => i.kind), ['create_database', 'create_table', 'add_index'])
assert.equal(plan.forbidden.length, 0)
assert.deepEqual(plan.summary, { tables: 1, columns: 0, indexes: 1 })
})
test('plugin-data: 库已就绪(结构一致)⇒ 零待做项、零禁令', () => {
const current = {
exists: true,
schemaVersion: 3,
tables: [{
name: 'p_trpg_kit_character_sheet',
columns: [
{ name: 'room_id', dataType: 'text' },
{ name: 'created_at', dataType: 'int8' },
{ name: 'updated_at', dataType: 'int8' },
{ name: 'pc_name', dataType: 'text' },
{ name: 'sheet', dataType: 'jsonb' },
{ name: 'sheet_rev', dataType: 'int4' },
],
indexes: ['p_trpg_kit_character_sheet_room_id_pc_name_uq_1'],
}],
}
const plan = diffDecl(DECL_OK, current)
assert.equal(plan.items.length, 0)
assert.equal(plan.forbidden.length, 0)
assert.equal(dataPlaneStateOf({ hasDeclaration: true, current, plan }), 'ready')
})
test('plugin-data: 现状有、声明无的列 ⇒ forbidden drop_column(⛔ 只增不减)', () => {
const current = {
exists: true,
schemaVersion: 3,
tables: [{
name: 'p_trpg_kit_character_sheet',
columns: [
{ name: 'room_id', dataType: 'text' },
{ name: 'pc_name', dataType: 'text' },
{ name: 'sheet', dataType: 'jsonb' },
{ name: 'sheet_rev', dataType: 'int4' },
{ name: 'legacy_note', dataType: 'text' }, // ← 声明里没有它
],
indexes: [],
}],
}
const plan = diffDecl(DECL_OK, current)
assert.ok(plan.forbidden.some((f) => f.kind === 'drop_column' && f.target.endsWith('legacy_note')))
assert.equal(dataPlaneStateOf({ hasDeclaration: true, current, plan }), 'blocked')
})
test('plugin-data: 类型变化 ⇒ forbidden alter_type(int ↔ text 之类的静默转换被拦)', () => {
const current = {
exists: true,
schemaVersion: 3,
tables: [{
name: 'p_trpg_kit_character_sheet',
columns: [
{ name: 'room_id', dataType: 'text' },
{ name: 'pc_name', dataType: 'text' },
{ name: 'sheet', dataType: 'jsonb' },
{ name: 'sheet_rev', dataType: 'text' }, // 声明是 integer
],
indexes: [],
}],
}
const plan = diffDecl(DECL_OK, current)
assert.ok(plan.forbidden.some((f) => f.kind === 'alter_type' && f.target.endsWith('sheet_rev')))
})
test('plugin-data: 声明新增列 ⇒ add_column 放行(必须带默认值)', () => {
const decl2 = validateDecl('@dsh-local/trpg-kit', {
...GOOD,
schemaVersion: 4,
tables: [{
...GOOD.tables[0],
columns: [...GOOD.tables[0].columns, { name: 'level', type: 'integer', default: 1 }],
}],
}, 'test').decl
const current = {
exists: true,
schemaVersion: 3,
tables: [{
name: 'p_trpg_kit_character_sheet',
columns: [
{ name: 'room_id', dataType: 'text' },
{ name: 'pc_name', dataType: 'text' },
{ name: 'sheet', dataType: 'jsonb' },
{ name: 'sheet_rev', dataType: 'int4' },
],
indexes: ['p_trpg_kit_character_sheet_room_id_pc_name_uq_1'],
}],
}
const plan = diffDecl(decl2, current)
assert.equal(plan.forbidden.length, 0)
assert.deepEqual(plan.items.map((i) => i.kind), ['add_column'])
assert.match(plan.items[0].sql, /ADD COLUMN IF NOT EXISTS "level" INTEGER DEFAULT 1/)
assert.equal(dataPlaneStateOf({ hasDeclaration: true, current, plan }), 'drift')
})
test('plugin-data: planHash 覆盖声明 / 现状 / 库名(防 TOCTOU)', () => {
const cur = emptyCurrent()
const plan = diffDecl(DECL_OK, cur)
const h1 = planHashOf({ dbName: DECL_OK.dbName, schemaVersion: 3, items: plan.items, forbidden: [], current: cur })
assert.equal(h1, plan.planHash, '同一份输入 ⇒ 同一指纹')
// 现状变了(有人手工改过库)⇒ 指纹必须变(否则"确认过的计划"会失真)。
const drifted = { exists: true, schemaVersion: 3, tables: [{ name: 'p_trpg_kit_x', columns: [{ name: 'a', dataType: 'text' }] }] }
const h2 = planHashOf({ dbName: DECL_OK.dbName, schemaVersion: 3, items: plan.items, forbidden: [], current: drifted })
assert.notEqual(h1, h2)
// 库名变了 ⇒ 指纹变。
const h3 = planHashOf({ dbName: 'dshs_pl_other', schemaVersion: 3, items: plan.items, forbidden: [], current: cur })
assert.notEqual(h1, h3)
})
test('plugin-data: 状态机 —— 门禁判据(未建库不许开启 / 检测不过不许建库 / 未确认不许迁移)', () => {
// 门禁 ① 未建库不许「开启」:只有 none / ready 放行。
assert.equal(stateAllowsEnable('none'), true, '无数据面 ⇒ 不受约束')
assert.equal(stateAllowsEnable('ready'), true)
for (const s of ['pending', 'created', 'drift', 'blocked', 'error']) {
assert.equal(stateAllowsEnable(s), false, `不许开启:${s}`)
}
// 门禁 ② 检测不通过不许建库:blocked 不在可建集合内。
assert.equal(stateAllowsCreate('pending'), true)
assert.equal(stateAllowsCreate('blocked'), false, '声明非法 ⇒ 不许建库')
assert.equal(stateAllowsCreate('ready'), false)
// 门禁 ③ 未确认 planHash 不许迁移:只有 drift 可执行(blocked 必须改声明)。
assert.equal(stateAllowsMigrate('drift'), true)
for (const s of ['pending', 'blocked', 'error', 'ready', 'none', 'created']) {
assert.equal(stateAllowsMigrate(s), false, `不许迁移:${s}`)
}
})
test('plugin-data: forbidden 清单只增不减(四种禁令齐备)', () => {
// ⛔ 这条断言是给后人看的:要加"允许的操作"必须先来这里改这份清单。
assert.deepEqual([...FORBIDDEN_KINDS], ['drop_column', 'alter_type', 'rename', 'notnull_no_default'])
})
test('plugin-data: 声明非法时 diff 不参与(level invalid ⇒ 无 decl)', () => {
const bad = validateDecl('p', { schemaVersion: 0, tables: [] }, 'test')
assert.equal(bad.level, 'invalid')
assert.equal(bad.decl, null)
})
// ── ⑦ 建库口(`datastore.ts`)—— B 档"三点兜底"的可执行判据 ───────────────────
//
// ⚠️ 本节的判据**不连库**:库名复校 / 标识符引用 / 错误码映射 / 对账集合逻辑
// 全是纯函数或可用假池替代 ⇒ 能在 CI 里把 B 档最危险的那条(库名拼进 SQL)钉死。
// 真机取证(建库 + 台账号双写一致)另见交付物,不进 `npm test`。
test('plugin-data: 标识符引用把双引号翻倍(⛔ 库名永不裸拼进 SQL)', () => {
// 标准 PG 引用:标识符里的 `"` 翻倍。配合 `assertPluginDbName` 成两重防线。
assert.equal(quoteIdent('dshs_pl_trpg_kit'), '"dshs_pl_trpg_kit"')
assert.equal(quoteIdent('a"b'), '"a""b"')
// 注入形态:引号被翻倍 ⇒ 整串仍是**单个标识符**,不可能逃出 `CREATE DATABASE "…"`。
// 期望值用模板串写(含单引号,⛔ 不转义成 `\'` 那种易错形态)。
const INJECT = "x'; DROP DATABASE dshs; --"
assert.equal(quoteIdent(INJECT), `"x'; DROP DATABASE dshs; --"`)
// 双引号形态:翻倍后仍是单标识符(PG 的转义规则)。
assert.equal(quoteIdent('a"b"' + 'c'), '"a""b""c"')
})
test('plugin-data: pluginDbUrlOf 只改库名、拒绝非法库名(⛔ 不裸拼连接串)', () => {
const base = 'postgres://dshs:[email protected]:15432/dshs'
// 正常:path 换成插件库,其余(user / host / port / 口令)逐字不变。
assert.equal(pluginDbUrlOf(base, 'dshs_pl_trpg_kit'), 'postgres://dshs:[email protected]:15432/dshs_pl_trpg_kit')
// ⚠️ 带 query 的连接串也不能丢 query —— 用 URL 解析正是为此(字符串替换会丢)。
assert.equal(
pluginDbUrlOf('postgres://u:p@h:5432/dshs?sslmode=require', 'dshs_pl_x'),
'postgres://u:p@h:5432/dshs_pl_x?sslmode=require',
)
// 🔴 非法库名 ⇒ 在建连接**之前**就抛(B 档第二点),⛔ 不下发。
for (const bad of ['evil_probe', 'dshs_pl_9bad', 'dshs_pl_a-b', "dshs_pl_x'; --"]) {
assert.throws(() => pluginDbUrlOf(base, bad), PluginDataError, `应拒绝:${bad}`)
}
// 连接串不是 URL 形态 ⇒ 抛(⛔ 不降级猜测成"指向控制面库")。
assert.throws(() => pluginDbUrlOf('host=127.0.0.1 dbname=dshs', 'dshs_pl_x'), PluginDataError)
})
test('plugin-data: PG 错误码分类(幂等 vs 未授 CREATEDB)', () => {
// 42P04 duplicate_database ⇒ 幂等成功(§二-② 验收断言)。
assert.equal(isDuplicateDatabase({ code: '42P04' }), true)
assert.equal(isDuplicateDatabase({ code: '42501' }), false)
assert.equal(isDuplicateDatabase(new Error('boom')), false)
assert.equal(isDuplicateDatabase(null), false)
// 42501 insufficient_privilege ⇒ 503 PG_CREATEDB_MISSING(未被 ALTER ROLE 授权 / 已回滚)。
assert.equal(isInsufficientPrivilege({ code: '42501' }), true)
assert.equal(isInsufficientPrivilege({ code: '42P04' }), false)
})
test('plugin-data: 库内台账表 DDL 幂等且带版本列(diff 判 ready 的依据)', () => {
const ddl = metaTableDdl()
assert.match(ddl, /CREATE TABLE IF NOT EXISTS "p_meta_schema"/)
assert.match(ddl, /schema_version BIGINT NOT NULL/)
// ⛔ 不带 `p_<pluginId>_` 前缀 —— 它是库级台账,不是某插件的业务表。
assert.ok(!/p_[a-z]+_[a-z_]+ /.test(ddl.replace('p_meta_schema', '')))
})
test('plugin-data: 台账 × pg_database 对账 —— 绕过平台建的库必须被抓出来', () => {
// 假池:只实现本函数用到的 `query`(`datastore.ts` 对本函数只调这一处)。
const pool = {
query: async () => ({ rows: [{ datname: 'dshs_pl_trpg_kit' }, { datname: 'dshs_pl_evil_probe' }, { datname: 'dshs_pl_gone' }] }),
}
return reconcilePluginDatabases(pool, ['dshs_pl_trpg_kit', 'dshs_pl_deleted']).then((r) => {
// ⚠️ `databases` 的**顺序**由 SQL 的 `ORDER BY datname` 负责,假池不排序 ⇒
// 这里只断言集合成员(顺序归真机取证管,⛔ 不让假池的返回序污染判据)。
assert.deepEqual([...r.databases].sort(), ['dshs_pl_evil_probe', 'dshs_pl_gone', 'dshs_pl_trpg_kit'])
assert.deepEqual(r.ledgered, ['dshs_pl_deleted', 'dshs_pl_trpg_kit'])
// 🔴 orphans = 库里在、台账不在 ⇒ 绕过平台建的(B 档第三点要抓的正是这个)。
assert.deepEqual([...r.orphans].sort(), ['dshs_pl_evil_probe', 'dshs_pl_gone'])
// 反向:台账在、库里没了 ⇒ 也被平台外动过。
assert.deepEqual(r.missing, ['dshs_pl_deleted'])
})
})
test('plugin-data: 对账一致时两个差异集合都为空(clean 判据)', () => {
const pool = { query: async () => ({ rows: [{ datname: 'dshs_pl_a' }] }) }
return reconcilePluginDatabases(pool, ['dshs_pl_a']).then((r) => {
assert.deepEqual(r.orphans, [])
assert.deepEqual(r.missing, [])
})
})
// ── ④ 迁移前结构备份(设计件 §七-3:备份失败 ⇒ ⛔ 不执行)────────────────────
test('plugin-data: 结构备份 —— 非法库名在下发 pg_dump 之前就被拒', async () => {
const root = mkdtempSync(join(tmpdir(), 'bk-'))
try {
const r = await backupSchemaOnly('postgres://u:[email protected]:5432/x', 'evil_probe', root, 'pkg', 3)
assert.equal(r.ok, false)
assert.match(r.error, /invalid_plugin_db_name/)
// 关键:⛔ 没落任何文件(连目录都不该建)。
assert.equal(r.path, '')
assert.deepEqual(readdirSync(root), [])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('plugin-data: 结构备份 —— 包名折叠成安全路径,⛔ 不越出 backupRoot', async () => {
const root = mkdtempSync(join(tmpdir(), 'bk-'))
try {
// 用不存在的库名(合法)逼出 pg_dump 失败路径 —— 但仍要先看**目标目录**是否安全。
const r = await backupSchemaOnly(
'postgres://u:[email protected]:1/none',
'dshs_pl_probe',
root,
'../../etc/passwd',
2,
)
assert.equal(r.ok, false) // 连不上 ⇒ 失败,但路径必须已被折叠
// 折叠后不含 `..` / 不含 `/`:目录层最多是 <root>/plugin-db/<flat>
const top = readdirSync(root)
assert.deepEqual(top, ['plugin-db'])
const sub = readdirSync(join(root, 'plugin-db'))
assert.equal(sub.length, 1)
assert.ok(!sub[0].includes('..'), '⛔ 包名里的 `..` 未被折叠')
assert.ok(!sub[0].includes('/'), '⛔ 包名里的 `/` 未被折叠')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('plugin-data: 结构备份 —— pg_dump 不可用/连不上时 ok=false 且不留半截文件', async () => {
const root = mkdtempSync(join(tmpdir(), 'bk-'))
try {
// 指到一个必然连不上的端口 ⇒ 走失败分支。
const r = await backupSchemaOnly('postgres://u:[email protected]:1/x', 'dshs_pl_probe', root, 'probe', 1)
assert.equal(r.ok, false)
assert.equal(r.bytes, 0)
assert.ok(r.error !== null && r.error.length > 0)
// 🔴 「⛔ 不留一个看起来像备份的空壳」= 目录里不能有 .sql
const dir = join(root, 'plugin-db', 'probe')
const files = existsSync(dir) ? readdirSync(dir) : []
assert.equal(files.filter((f) => f.endsWith('.sql')).length, 0)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* 🔴 回归钉:`pg_dump` 的**主机/端口/用户/口令必须真的进到子进程 env**。
*
* 起因(2026-09-23 第 8 棒真机 E2E 抓出的真缺陷):`pgEnvFrom()` 的返回值被摊在
* `execFileSync` 的 **options 层**(与 `env:` 平级)而不是并进 `env` ⇒ 子进程一个 `PGHOST`
* 都拿不到 ⇒ `pg_dump` 回落 Unix socket `/var/run/postgresql/.s.PGSQL.5432`
* ⇒ 47 上必报 `could not connect to server: No such file or directory`,
* **两条真机 E2E 判据(备份落盘 / 反证 500)全被它挡住**。
*
* 本用例直接钉住**被测函数**的返回值形态:它必须是一份含 `PGHOST`/`PGPORT`/`PGUSER`/
* `PGPASSWORD` 的**完整 env**(⇒ 调用处只能整体交给 `env:`,摊在 options 层就不成立)。
* ⚠️ 不走"生成假 pg_dump 再 spawn"的路子:Windows 的 `execFileSync` 不从 PATH 解析
* `.cmd`/`.bat`(实测 `ENOENT`/`EINVAL`),跨平台会假红 —— 判据应钉在不变式上。
*/
test('plugin-data: 结构备份 —— pg_dump 的 env 必须含 PGHOST/PGPORT/PGUSER/PGPASSWORD', () => {
const env = pgDumpEnv('postgres://dshs:[email protected]:15432/dshs', 'secret')
assert.equal(env.PGHOST, '127.0.0.1', '🔴 PGHOST 必须下发到 pg_dump 子进程')
assert.equal(env.PGPORT, '15432', '🔴 PGPORT 必须下发到 pg_dump 子进程')
assert.equal(env.PGUSER, 'dshs', '🔴 PGUSER 必须下发到 pg_dump 子进程')
assert.equal(env.PGPASSWORD, 'secret', '口令必须经 PGPASSWORD 下发(⛔ 不进 argv)')
assert.ok(env.PGCONNECT_TIMEOUT !== undefined, '应带连接超时,避免挂死')
// ⛔ 口令不许出现在 argv 里 —— 本函数只负责 env,故 argv 由调用处的字面量保证;
// 这里反向钉一下:返回值**只有** env 语义的键,不许混进 execFileSync 的选项名。
for (const k of Object.keys(env)) {
assert.ok(!['stdio', 'timeout', 'cwd', 'encoding'].includes(k), `⛔ env 里混进了选项名 ${k}`)
}
})
test('plugin-data: 结构备份 —— 连接串不可解析时 env 仍可用(不抛)', () => {
const env = pgDumpEnv('not-a-url', 'pw')
assert.equal(env.PGPASSWORD, 'pw')
assert.equal(env.PGHOST, undefined)
})