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