Files
dsh_shenxian/test/plugin-data-runtime.test.mjs
T

278 lines
13 KiB
JavaScript
Raw Normal View History

// 插件数据面 · **运行时取数口**回归测试(`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/)
})