Files
dsh_shenxian/test/plugin-data-runtime.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

279 lines
13 KiB
JavaScript
Raw 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 §五` 的落地件)。
//
// 🔴 这个文件盯的是 2026-09-24 之前**整条取数链路断掉**的那个缺口:
// `ImDataPort` 只有接口、零实现 ⇒ 插件跑起来 `insert` 一律 `not-registered`。
// 本测试用**假执行器**(不连 PG)把判据逐条钉死,可机械复现、无需真机。
//
// 覆盖五组:
// ① 表级白名单(未声明的表 / 未声明的列一律进不来);
// ② 归属注入(`user_id` / `room_id` 由内核写,⛔ 插件覆盖不了);
// ③ 房间 ACL(缺成员判定即 fail-closed;非成员 ⇒ 0 行);
// ④ 主键语义(`id` = 声明里的第一列;它不可改);
// ⑤ 占位符编号(`update` 的 WHERE/SET 必须连续编号 —— 回归"两段各从 $1 起"的串位 bug)。
//
// 跑法:`npm test`(先 build 再跑 `lib/`)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { validateDecl } from '../lib/db/plugin-data/schema.js'
import { DEFAULT_QUOTA, PluginDataRuntime } from '../lib/db/plugin-data/runtime.js'
/* ── 夹具 ──────────────────────────────────────────────────────────────────── */
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: '{}' },
],
indexes: [],
},
{
name: 'my_notes',
scope: 'user',
columns: [
{ name: 'note_id', type: 'text', notNull: true, maxBytes: 64, default: '' },
{ name: 'body', type: 'bigtext', maxBytes: 4096, default: '' },
],
indexes: [],
},
],
}
const PARSED = validateDecl('@dsh-local/trpg-kit', GOOD, 'test')
assert.equal(PARSED.level, 'ok', '夹具声明必须合法')
const DECL = PARSED.decl
/** 假执行器:记下每条 SQL 与参数,返回调用方给的剧本。 */
function fakeQuery(script = () => []) {
const calls = []
const q = async (sql, params) => {
calls.push({ sql, params })
return script(sql, params) ?? []
}
q.calls = calls
q.last = () => calls[calls.length - 1]
return q
}
/** 收集审计记录。 */
function collector() {
const rows = []
const fn = (r) => rows.push(r)
fn.rows = rows
fn.last = () => rows[rows.length - 1]
return fn
}
/** 建端口(默认调用者 = u1,now 固定 ⇒ 审计列可断言)。 */
function portOf(opts = {}) {
const query = opts.query ?? fakeQuery()
const audit = opts.onAudit ?? collector()
const port = new PluginDataRuntime({
decl: DECL,
query,
caller: { pluginId: DECL.pluginId, userId: opts.userId ?? 'u1' },
isMember: opts.isMember,
memberRooms: opts.memberRooms,
quota: opts.quota,
now: opts.now ?? (() => 1_700_000_000_000),
onAudit: audit,
})
return { port, query, audit }
}
/* ── ① 表级白名单 ─────────────────────────────────────────────────────────── */
test('plugin-data-runtime: 未声明的表一律拒(⛔ 不猜表名、不落库)', async () => {
const { port, query, audit } = portOf()
for (const op of ['insert', 'find', 'count']) {
const r = op === 'insert' ? await port.insert('kernel_users', { a: 1 }) : await port[op]('kernel_users')
assert.equal(r.ok, false)
assert.equal(r.reason, 'bad-args')
assert.match(r.detail, /未知表/)
}
assert.equal((await port.update('kernel_users', 'x', { a: 1 })).reason, 'bad-args')
assert.equal((await port.remove('kernel_users', 'x')).reason, 'bad-args')
assert.equal(query.calls.length, 0, '⛔ 非法表名不许下发 SQL')
assert.equal(audit.rows.length, 5, '逐条拒绝都要留审计')
assert.match(audit.last().denied, /^bad-args:/)
})
test('plugin-data-runtime: 物理表名取自声明的 physicalName(⛔ 不另拼一份)', async () => {
const { port, query } = portOf()
await port.insert('my_notes', { note_id: 'n1', body: 'hello' })
assert.match(query.last().sql, /INSERT INTO "p_trpg_kit_my_notes"/)
})
/* ── ② 归属注入 ───────────────────────────────────────────────────────────── */
test('plugin-data-runtime: user 维度 insert 由内核注入 user_id,⛔ 插件覆盖不了', async () => {
const { port, query } = portOf({ userId: 'u1' })
const bad = await port.insert('my_notes', { note_id: 'n1', user_id: 'u2' })
assert.equal(bad.reason, 'bad-args')
assert.match(bad.detail, /不得写入他人/)
const okRow = await port.insert('my_notes', { note_id: 'n1', body: 'hello' })
assert.equal(okRow.ok, true)
assert.deepEqual(okRow.value, { id: 'n1' })
const { sql, params } = query.last()
assert.match(sql, /"user_id"/)
assert.equal(params[0], 'u1', 'user_id 恒为调用者')
assert.equal(params[1], 'n1')
assert.equal(params[3], 1_700_000_000_000, 'created_at 由内核写')
assert.equal(params[4], 1_700_000_000_000, 'updated_at 由内核写')
})
test('plugin-data-runtime: 审计列 / 未知列 / 缺主键 都拒(⛔ 不静默丢弃)', async () => {
const { port, query } = portOf()
assert.match((await port.insert('my_notes', { note_id: 'n', created_at: 1 })).detail, /审计列/)
assert.match((await port.insert('my_notes', { note_id: 'n', evil: 1 })).detail, /未知列/)
assert.match((await port.insert('my_notes', { body: 'x' })).detail, /缺少主键列/)
assert.equal(query.calls.length, 0)
})
test('plugin-data-runtime: find 的等值过滤只认已声明列(归属列由内核统一处理)', async () => {
const { port, query } = portOf()
assert.match((await port.find('my_notes', { eq: { nope: 1 } })).detail, /未知列/)
const okFind = await port.find('my_notes', { eq: { body: 'x', user_id: 'u2' } })
assert.equal(okFind.ok, true)
const { sql, params } = query.last()
assert.match(sql, /"user_id" = \$1/)
assert.equal(params[0], 'u1', 'eq 里的 user_id 被忽略(恒用调用者)')
assert.doesNotMatch(sql, /u2/)
})
/* ── ③ 房间 ACL ──────────────────────────────────────────────────────────── */
test('plugin-data-runtime: 房间表缺 memberRooms 且未指定 room_id ⇒ bad-args(⛔ 不猜范围)', async () => {
const { port } = portOf({ isMember: async () => true })
const r = await port.find('character_sheet')
assert.equal(r.reason, 'bad-args')
assert.match(r.detail, /room_id/)
})
test('plugin-data-runtime: 房间表未注入成员判定 ⇒ not-registered(fail-closed)', async () => {
const { port, query } = portOf()
const r = await port.find('character_sheet', { eq: { room_id: 'r1' } })
assert.equal(r.reason, 'not-registered')
assert.equal(query.calls.length, 0, '⛔ 判不出成员就不许查')
})
test('plugin-data-runtime: 非成员 ⇒ 0 行;成员 ⇒ 按 room_id 收窄', async () => {
const deny = portOf({ isMember: async () => false })
const denied = await deny.port.find('character_sheet', { eq: { room_id: 'r1' } })
assert.deepEqual(denied.value, [], '非成员返回 0 行')
const allow = portOf({ isMember: async (roomId, uid) => roomId === 'r1' && uid === 'u1' })
const got = await allow.port.find('character_sheet', { eq: { room_id: 'r1' } })
assert.equal(got.ok, true)
assert.match(allow.query.last().sql, /"room_id" = ANY\(\$1\)/)
assert.deepEqual(allow.query.last().params[0], ['r1'])
})
test('plugin-data-runtime: memberRooms 注入后,不指定 room_id 也能查(范围 = 我的房间)', async () => {
const { port, query } = portOf({
isMember: async (roomId) => roomId === 'r2',
memberRooms: async () => ['r1', 'r2'],
})
const r = await port.find('character_sheet', { limit: 5 })
assert.equal(r.ok, true)
assert.deepEqual(query.last().params[0], ['r2'], '非成员的那间被剔掉')
})
test('plugin-data-runtime: 房间表 insert 必须带 room_id 且通过成员判定', async () => {
const { port, query } = portOf({ isMember: async () => false })
assert.match((await port.insert('character_sheet', { pc_name: 'a' })).detail, /必须带 room_id/)
assert.match((await port.insert('character_sheet', { pc_name: 'a', room_id: 'r1' })).detail, /不是该房间成员/)
assert.equal(query.calls.length, 0)
})
/* ── ④ 主键语义 ───────────────────────────────────────────────────────────── */
test('plugin-data-runtime: id = 声明里的第一列,且它不可被 patch 改', async () => {
const { port } = portOf()
const r = await port.update('my_notes', 'n1', { note_id: 'n2' })
assert.equal(r.reason, 'bad-args')
assert.match(r.detail, /主键列/)
assert.match((await port.update('my_notes', 'n1', { nope: 1 })).detail, /未知列/)
assert.match((await port.update('my_notes', 'n1', {})).detail, /patch 为空/)
assert.match((await port.update('my_notes', 'n1', { updated_at: 1 })).detail, /审计列/)
})
test('plugin-data-runtime: update 正常路径 —— 归属条件与主键同列(⛔ 改不到别人的行)', async () => {
const { port, query } = portOf({ userId: 'u1', query: fakeQuery(() => [{ n: '1' }]) })
const r = await port.update('my_notes', 'n1', { body: 'new' })
assert.equal(r.ok, true)
assert.equal(r.value.changed, 1)
const { sql, params } = query.last()
assert.match(sql, /WHERE \("user_id" = \$1\) AND "note_id" = \$2/)
assert.deepEqual(params, ['u1', 'n1', 'new', 1_700_000_000_000])
})
test('plugin-data-runtime: remove 走同一套归属条件', async () => {
const { port, query } = portOf({ query: fakeQuery(() => [{ n: '1' }]) })
const r = await port.remove('my_notes', 'n1')
assert.equal(r.value.removed, 1)
assert.match(query.last().sql, /DELETE FROM "p_trpg_kit_my_notes" WHERE \("user_id" = \$1\) AND "note_id" = \$2/)
})
/* ── ⑤ 分页 / 计数 / 配额 / 审计 ───────────────────────────────────────────── */
test('plugin-data-runtime: 游标分页 —— 占位符接续在 WHERE 参数之后,limit 被夹紧', async () => {
const { port, query } = portOf({ query: fakeQuery(() => [{ note_id: 'n1' }]) })
const r = await port.find('my_notes', { limit: 9999, afterCursor: 'n0', order: 'desc' })
assert.equal(r.ok, true)
const { sql, params } = query.last()
assert.match(sql, /"note_id" > \$2 ORDER BY "note_id" DESC LIMIT \$3/)
assert.deepEqual(params, ['u1', 'n0', DEFAULT_QUOTA.maxListLimit])
})
test('plugin-data-runtime: count 返回整数(⛔ 不做聚合 —— DB-03 §十)', async () => {
const { port, query } = portOf({ query: fakeQuery(() => [{ n: '7' }]) })
const r = await port.count('my_notes', { eq: { body: 'x' } })
assert.deepEqual(r.value, { n: 7 })
assert.match(query.last().sql, /SELECT COUNT\(\*\)::text AS n/)
})
test('plugin-data-runtime: 单行字节超限 ⇒ quota-exceeded(⛔ 不静默丢)', async () => {
const { port, query } = portOf({ quota: { maxRowBytes: 8 } })
const r = await port.insert('my_notes', { note_id: 'n1', body: 'x'.repeat(64) })
assert.equal(r.reason, 'quota-exceeded')
assert.equal(query.calls.length, 0)
})
test('plugin-data-runtime: 每次调用都留审计(含成功与拒绝)', async () => {
const { port, audit } = portOf({ query: fakeQuery(() => [{ n: '1' }]) })
await port.insert('my_notes', { note_id: 'n1', body: 'b' })
await port.find('my_notes')
await port.count('my_notes')
await port.find('nope')
assert.deepEqual(audit.rows.map((r) => [r.action, r.denied === null]), [
['insert', true],
['find', true],
['count', true],
['find', false],
])
assert.equal(audit.rows[0].pluginId, DECL.pluginId)
assert.equal(audit.rows[0].userId, 'u1')
})
test('plugin-data-runtime: SQL 出错 ⇒ 具名失败(⛔ 不抛给插件、⛔ 不空数组)', async () => {
const { port } = portOf({
query: fakeQuery(() => {
const err = new Error('relation does not exist')
err.code = '42P01'
throw err
}),
})
const r = await port.find('my_notes')
assert.equal(r.ok, false)
assert.equal(r.reason, 'disabled')
assert.match(r.detail, /42P01/)
})