chore(仓库对齐): 文档库结构治理 + IM/插件线落地
build / build-and-scan (push) Canceled after 0s

文档库:目录改为编号制(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/;交接单不入库(政策)。
This commit is contained in:
admin committed 2026-09-24 07:25:16 +08:00
1 parent 3d8f50e366
commit e6207aa691
239 files changed
+34477 -14633

No files matched your search

+100
View File
@@ -302,6 +302,106 @@ function register(backend, makeAdapter) {
await db.close()
}
})
// ── v12 · 设备台账(序㊻ 步骤 6 / S3)────────────────────────────────────────
test(`${backend}: upsertOverlayDevice —— 首签 = 1、续签 = +1(同一行)、租约被刷新`, async () => {
const db = await makeAdapter()
try {
await db.createUser(user('u1', 'u1'))
const t0 = 1_700_000_000_000
const base = { network: 'u:u1', hostId: 'd-u1-abcdef12', userId: 'u1', nodeKey: 'ab'.repeat(32) }
const first = await db.upsertOverlayDevice({ ...base, leaseExpiresAt: t0 + 1000, at: t0 })
assert.equal(first.grantRenewals, 1, '首次插入 = 1')
assert.equal(first.status, 'active', '首次插入 = active')
assert.equal(first.createdAt, t0)
assert.equal(first.grantIssuedAt, t0)
const second = await db.upsertOverlayDevice({ ...base, leaseExpiresAt: t0 + 2000, at: t0 + 500 })
assert.equal(second.grantRenewals, 2, '续签 = 旧值 + 1(⛔ 不是恒为 1,也不是 2 卡住)')
assert.equal(second.leaseExpiresAt, t0 + 2000, '租约必须被刷新')
assert.equal(second.createdAt, t0, 'created_at 不被续签改动')
const third = await db.upsertOverlayDevice({ ...base, leaseExpiresAt: t0 + 3000, at: t0 + 900 })
assert.equal(third.grantRenewals, 3, '第三次 = 3(计数真的在累加)')
assert.equal((await db.listOverlayDevices('u:u1')).length, 1, '续签 = 同一行,⛔ 不是插新行')
// 按网过滤 / 按 userId 归属
await db.createUser(user('u2', 'u2'))
await db.upsertOverlayDevice({
network: 'u:u2',
hostId: 'd-u2-11111111',
userId: 'u2',
nodeKey: 'cd'.repeat(32),
leaseExpiresAt: t0 + 1000,
at: t0,
})
assert.equal((await db.listOverlayDevices()).length, 2, '不带过滤 = 全部')
assert.equal((await db.listOverlayDevices('u:u2')).length, 1, '按网过滤')
assert.equal((await db.findOverlayDevice('u:u2', 'd-u2-11111111')).userId, 'u2')
assert.equal(await db.findOverlayDevice('u:u2', 'd-u2-22222222'), undefined, '不存在 ⇒ undefined(⛔ 不抛)')
} finally {
await db.close()
}
})
test(`${backend}: setOverlayDeviceStatus —— 停发是**黏性**的(续签不复活)+ 不存在的行 = false`, async () => {
const db = await makeAdapter()
try {
await db.createUser(user('u3', 'u3'))
const row = {
network: 'u:u3',
hostId: 'd-u3-abcdef12',
userId: 'u3',
nodeKey: 'ef'.repeat(32),
leaseExpiresAt: Date.now() + 1000,
}
await db.upsertOverlayDevice(row)
assert.equal(await db.setOverlayDeviceStatus('u:u3', 'd-u3-abcdef12', 'revoked'), true)
assert.equal((await db.findOverlayDevice('u:u3', 'd-u3-abcdef12')).status, 'revoked')
// 🔴 关键:续签**不许**把它复活(SQL 层的 DO UPDATE SET 里没有 status 这一列)
const renewed = await db.upsertOverlayDevice({ ...row, leaseExpiresAt: Date.now() + 9999 })
assert.equal(renewed.status, 'revoked', '⚠️ 续签不得让被停发的设备自动复活')
assert.equal(renewed.grantRenewals, 2, '但它确实是一次续签(计数照常累加)')
// 恢复只能显式来
assert.equal(await db.setOverlayDeviceStatus('u:u3', 'd-u3-abcdef12', 'active'), true)
assert.equal((await db.findOverlayDevice('u:u3', 'd-u3-abcdef12')).status, 'active')
// 不存在的行 ⇒ false(路由据此回 404)
assert.equal(await db.setOverlayDeviceStatus('u:u3', 'd-u3-00000000', 'revoked'), false)
} finally {
await db.close()
}
})
test(`${backend}: overlay_devices 归属 user(删除用户即级联清理台账)`, async () => {
const db = await makeAdapter()
try {
await db.createUser(user('u4', 'u4'))
await db.upsertOverlayDevice({
network: 'u:u4',
hostId: 'd-u4-abcdef12',
userId: 'u4',
nodeKey: 'aa'.repeat(32),
leaseExpiresAt: Date.now() + 1000,
})
assert.equal((await db.listOverlayDevices()).length, 1)
await db.deleteUser('u4')
assert.equal((await db.listOverlayDevices()).length, 0, '删用户应当把台账一起带走(ON DELETE CASCADE)')
// 未知用户 ⇒ 外键违例(与其它表同一口径)
await assert.rejects(
() =>
db.upsertOverlayDevice({
network: 'u:ghost',
hostId: 'd-ghost-abcdef12',
userId: 'missing',
nodeKey: 'bb'.repeat(32),
leaseExpiresAt: Date.now(),
}),
ForeignKeyViolationError,
)
} finally {
await db.close()
}
})
}
register('sqlite', () => new SqliteAdapter(':memory:', 100000))
+541
View File
@@ -0,0 +1,541 @@
// IM agent 接入回归测试(node:test)—— C 单 `交接单/IM群组-C-Agent接入插件.md` §六 ∪ §六.1。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// 覆盖面:凭据 200/401/不注入不启动(判据 1)· 只拨出无监听(判据 2/10)· 防自激(判据 3/9)·
// 上下文含窗口内非 @ 那条(判据 4)· 可见性(判据 4)· 四条硬约束(判据 5/9)·
// 代答标识 author/via(判据 6)· 故障隔离(判据 8)· 区判据(§〇-4③)。
//
// ⚠️ 本文件**不碰网络、不碰 DB**:所有判据都断 `lib/im/agent-bridge.js` 与
// `lib/im/instance-token.js` 的纯逻辑,以及插件包 `poc/im-agent-bridge/lib/index.js`
// 的静态形状(无 `listen(`)。端到端(真起 socket)由 B 单的验收脚本覆盖。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
AGENT_CONTEXT_MAX_MESSAGES,
AGENT_CONTEXT_WINDOW_MS,
RoomRateLimiter,
buildAgentContext,
mentionsOf,
replyMarkerOf,
replyPayloadOf,
shouldTrigger,
visibleTo,
} from '../lib/im/agent-bridge.js'
import {
IM_INSTANCE_TOKEN_BYTES,
ImInstanceTokenRegistry,
INSTANCE_TOKEN_REASONS,
looksLikeImInstanceToken,
} from '../lib/im/instance-token.js'
import { IM_MAX_CONN_PER_USER } from '../lib/im/hub.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = join(HERE, '..')
const PLUGIN_ENTRY = join(REPO, 'poc/im-agent-bridge/lib/index.js')
/** Windows 上 `import()` 必须用 file:// URL(⛔ 不能传 `D:\...` 裸路径)。 */
function pathToFileUrl(p) {
return `file:///${p.replace(/\\/g, '/')}`
}
/* ── 夹具 ────────────────────────────────────────────────────────────────── */
/** 造一条消息(形状 = A 单 `MessageEnvelope`)。 */
function msg(seq, over = {}) {
return {
id: `h${seq}`,
roomId: 'imr_test',
seq,
ts: over.ts ?? 1_000_000 + seq,
authorId: over.authorId ?? 'u_alice',
authorKind: over.authorKind ?? 'human',
via: over.via ?? 'web',
visibility: over.visibility ?? 'room',
payload: over.payload ?? { text: `第 ${seq} 条` },
}
}
/** 造一个成员(形状 = A 单 `RoomMember`)。 */
function member(memberId, over = {}) {
return {
roomId: 'imr_test',
memberId,
kind: over.kind ?? 'human',
role: over.role ?? 'member',
autoReply: over.autoReply ?? false,
agentRef: over.agentRef ?? null,
joinedAt: 0,
}
}
/* ── 判据 1:凭据 200 / 401 / 不注入则不启动 ─────────────────────────────── */
test('判据 1 / §五-1:正确 token 通过校验', () => {
const reg = new ImInstanceTokenRegistry()
const { token, record } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', rooms: ['imr_a'] })
assert.ok(looksLikeImInstanceToken(token), 'token 外形应为 43 字符 base64url')
assert.ok(token.length >= Math.ceil((IM_INSTANCE_TOKEN_BYTES * 4) / 3) - 1)
const v = reg.verify(token)
assert.equal(v.ok, true)
assert.equal(v.record.userId, 'u_alice', '必须能答出"这是谁的实例"(§〇-4②)')
assert.equal(v.record.instanceId, 'inst_1')
assert.equal(record.expiresAt > record.issuedAt, true)
})
test('判据 1:错误 / 缺失 / 过期 token 一律拒绝(fail-closed,⛔ 不是放行)', () => {
let clock = 1000
const reg = new ImInstanceTokenRegistry({ now: () => clock })
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', ttlMs: 500 })
// 缺失
assert.deepEqual(reg.verify(undefined), { ok: false, reason: 'missing' })
assert.deepEqual(reg.verify(''), { ok: false, reason: 'missing' })
// 外形不对(长度不对 ⇒ 不认,且⛔不走定长比较)
assert.equal(reg.verify('short').ok, false)
assert.equal(reg.verify('short').reason, 'unknown')
// 未签发过的(合法外形)
assert.equal(reg.verify('A'.repeat(43)).reason, 'unknown')
// 有效期内 ⇒ 通过
assert.equal(reg.verify(token).ok, true)
// 时钟推过 TTL ⇒ 具名过期(⛔ 不是 unknown —— 客户端要能区分)
clock = 1600
const expired = reg.verify(token)
assert.equal(expired.ok, false)
assert.equal(expired.reason, 'expired')
assert.equal(typeof INSTANCE_TOKEN_REASONS.expired, 'string')
// sweepExpired 之后变成 unknown(登记簿不留过期行)
assert.equal(reg.sweepExpired(), 1)
assert.equal(reg.verify(token).reason, 'unknown')
})
test('判据 1 / §〇-4③:跨区一律拒(本期单区)', () => {
const reg = new ImInstanceTokenRegistry()
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', region: 'cn-east' })
// 不声明区 ⇒ 不触发区判据
assert.equal(reg.verify(token).ok, true)
// 声明同区 ⇒ 通过
assert.equal(reg.verify(token, { region: 'cn-east' }).ok, true)
// 声明异区 ⇒ 拒(具名)
const bad = reg.verify(token, { region: 'cn-west' })
assert.equal(bad.ok, false)
assert.equal(bad.reason, 'region_mismatch')
})
test('判据 1:实例重启即换 token(旧 token 失效)', () => {
const reg = new ImInstanceTokenRegistry()
const first = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' }).token
assert.equal(reg.verify(first).ok, true)
// 实例重启:先撤后签(编排器的动作,见 issueImInstanceToken)
assert.equal(reg.revokeByInstance('inst_1'), 1)
const second = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' }).token
assert.equal(reg.verify(first).reason, 'unknown', '旧 token 必须失效')
assert.equal(reg.verify(second).ok, true)
assert.notEqual(first, second)
})
test('判据 1 / §五-1:登记簿只存哈希(token 原文不落登记簿)', () => {
const reg = new ImInstanceTokenRegistry()
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' })
// 白盒:内部 Map 的键、以及整对象的字符串化里都不得出现 token 原文
const keys = Object.keys(reg)
assert.equal(keys.includes('byHash'), true, '内部表名变了 ⇒ 重新审计存储口径')
assert.equal(JSON.stringify(reg).includes(token), false, '⛔ 登记簿里不得有 token 原文')
assert.equal(reg.size(), 1)
assert.equal(reg.sweepExpired(), 0, '未过期 ⇒ 清 0')
})
test('判据 1:可达房间是"索引"而非授权判据(未覆盖 ⇒ 具名拒绝)', () => {
const reg = new ImInstanceTokenRegistry()
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', rooms: ['imr_a'] })
assert.equal(reg.verify(token, { roomId: 'imr_a' }).ok, true)
const bad = reg.verify(token, { roomId: 'imr_b' })
assert.equal(bad.ok, false)
assert.equal(bad.reason, 'not_in_room')
// 补一个房(成员变更时调用方同步)
assert.equal(reg.allowRoom('inst_1', 'imr_b'), true)
assert.equal(reg.verify(token, { roomId: 'imr_b' }).ok, true)
})
/* ── 判据 3:防自激(硬约束 1)──────────────────────────────────────────── */
test('判据 3 / §五-3:agent 的消息**不触发**任何 agent', () => {
const snapshot = {
roomId: 'imr_test',
members: [
member('u_alice'),
member('bot_a', { kind: 'bot', agentRef: 'agent_a' }),
],
messages: [],
}
// bot 自己发的消息 @ 了 bot_a ⇒ 仍不触发
const fromBot = msg(1, { authorKind: 'bot', authorId: 'bot_a', payload: { text: '@bot_a 你好' } })
const d = shouldTrigger(fromBot, snapshot)
assert.equal(d.trigger, false)
assert.equal(d.reason, 'not-human-author')
})
test('判据 3 / §五-3:human 的 @bot ⇒ 恰好触发 1 次(形态 A)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [],
}
const d = shouldTrigger(msg(2, { payload: { text: '@bot_a 帮我看下' } }), snapshot)
assert.equal(d.trigger, true)
assert.equal(d.form, 'bot')
assert.equal(d.memberId, 'bot_a')
assert.equal(d.agentRef, 'agent_a')
})
test('判据 3 / §五-3:无 @ ⇒ 不触发;结构性 mentions 优先于文本扫描', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [],
}
assert.equal(shouldTrigger(msg(3, { payload: { text: '随便说说' } }), snapshot).reason, 'no-mention')
// 结构化 mentions:文本里没 @,也应触发
const structured = msg(4, { payload: { text: '帮我看下', mentions: ['bot_a'] } })
assert.equal(shouldTrigger(structured, snapshot).trigger, true)
// mentions 为空数组 ⇒ 不回落到文本(显式声明了"没有 @")
assert.equal(mentionsOf({ text: '@x', mentions: [] }).length, 0)
})
/* ── 判据 3:形态 B(组员代理)──────────────────────────────────────────── */
test('判据 3 / §五-3:形态 B 在 autoReply=0 时**不触发**', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: false })],
messages: [],
}
const d = shouldTrigger(msg(5, { payload: { text: '@u_alice 在吗' } }), snapshot)
assert.equal(d.trigger, false)
assert.equal(d.reason, 'proxy-autoreply-off')
})
test('判据 3 / §四-1:形态 B 触发时 memberId = **组员本人**(代理不占位)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: true })],
messages: [],
}
const d = shouldTrigger(msg(6, { payload: { text: '@u_alice 帮我总结下' } }), snapshot)
assert.equal(d.trigger, true)
assert.equal(d.form, 'proxy')
assert.equal(d.memberId, 'u_alice', '形态 B 的成员落点 = 组员本人')
assert.equal(d.agentRef, 'agent_alice')
})
test('判据 3:开启 agent 互相 @ 时受跳数上限约束(默认关闭)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [],
}
const fromBot = msg(7, { authorKind: 'bot', authorId: 'bot_a', payload: { text: '@bot_a hi' } })
// 默认关 ⇒ 不触发
assert.equal(shouldTrigger(fromBot, snapshot).trigger, false)
// 开启且 hop=0 ⇒ 触发
assert.equal(shouldTrigger(fromBot, snapshot, { allowAgentToAgent: true, hopLimit: 2, hop: 0 }).trigger, true)
// 达到跳数上限 ⇒ 停(切断自激环)
assert.equal(shouldTrigger(fromBot, snapshot, { allowAgentToAgent: true, hopLimit: 2, hop: 2 }).trigger, false)
})
/* ── 判据 4:上下文(窗口内非 @ 那条 + 可见性)───────────────────────────── */
test('判据 4 / §五-4①:能引用**窗口内、但没有 @ 它**的消息', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [
msg(1, { payload: { text: '项目代号是 ALPHA' } }), // ← 无 @,但窗口内
msg(2, { payload: { text: '排期在周五' } }),
msg(3, { payload: { text: '@bot_a 代号叫什么来着?' } }), // ← 被 @ 那条
],
meta: { name: '测试房', roomType: 'office' },
}
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 3,
now: 1_000_003,
})
assert.equal(ctx.focus?.seq, 3, 'focus 必须是在 @ 那条')
const windowSeqs = ctx.window.map((m) => m.seq)
assert.equal(windowSeqs.includes(1), true, '窗口内非 @ 那条必须在上下文里(否则判据 4 失败)')
assert.equal(ctx.prompt.includes('ALPHA'), true, 'prompt 必须真的带上那条内容')
assert.equal(ctx.prompt.includes('排期在周五'), true)
assert.equal(ctx.room.members, 2)
})
test('判据 4 / §四-3:被 @ 那条若落在窗口外 ⇒ **显式补回**(不变量 ①)', () => {
const many = []
for (let i = 1; i <= 60; i += 1) many.push(msg(i, { ts: 1_000_000 + i }))
// 被 @ 的是**最老**那条(第 1 条),必然被"最近 50 条"裁掉
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: many,
meta: {},
}
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 1,
now: 1_000_060,
})
assert.equal(ctx.focus?.seq, 1, 'focus 必须还在')
assert.equal(ctx.window.length, AGENT_CONTEXT_MAX_MESSAGES + 1, '窗口 50 + 补回 1')
assert.equal(ctx.window.map((m) => m.seq).includes(1), true)
// 且窗口仍按 seq 严格递增(补回后要重排)
const seqs = ctx.window.map((m) => m.seq)
assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b))
})
test('判据 4 / §四-6:上下文窗口 = 最近 50 条或最近 10 分钟,**取小**', () => {
const old = msg(1, { ts: 0, payload: { text: '十分钟前的旧话' } }) // 远超时间窗
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [old, msg(2, { ts: 1_000_000 }), msg(3, { ts: 1_000_001 })],
meta: {},
}
const now = 1_000_001
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 3,
now,
})
// 旧话被时间窗排除 ⇒ 但因为它**可见且落窗** ⇒ 进 folded 摘要(⛔ 不是硬截断)
assert.equal(ctx.window.map((m) => m.seq).includes(1), false, '旧话不进窗口')
assert.equal(ctx.folded.length, 1, '旧话应折叠为摘要')
assert.equal(ctx.prompt.includes('更早消息摘要'), true)
assert.equal(AGENT_CONTEXT_WINDOW_MS, 10 * 60 * 1000)
})
test('判据 4 / §五-4②:**越权内容不出现**(可见性按接收者判)', () => {
const snapshot = {
roomId: 'imr_test',
members: [
member('u_alice'),
member('u_bob'),
member('bot_a', { kind: 'bot', agentRef: 'agent_a' }),
],
messages: [
msg(1, { authorId: 'u_bob', payload: { text: '公开情报' } }),
// bob 与 alice 的私聊(visibility=private,作者 bob)⇒ bot_a 无权看到
msg(2, { authorId: 'u_bob', visibility: 'private', payload: { text: '机密:代号 OMEGA' } }),
msg(3, { payload: { text: '@bot_a 有啥情报' } }),
],
meta: {},
}
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 3,
now: 1_000_003,
})
assert.equal(ctx.prompt.includes('机密'), false, '越权内容不得出现(判据 4 最硬的一条)')
assert.equal(ctx.prompt.includes('公开情报'), true)
assert.equal(visibleTo({ visibility: 'private', authorId: 'u_bob' }, 'bot_a'), false)
assert.equal(visibleTo({ visibility: 'private', authorId: 'bot_a' }, 'bot_a'), true, '自己的私聊可见')
})
test('判据 4:形态 B 的上下文以**组员本人**为可见性主体(§五-6 一致)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: true }), member('u_bob')],
messages: [
// alice 自己的私聊 ⇒ 代答 agent 应能看到(因为它代表 alice)
msg(1, { authorId: 'u_alice', visibility: 'private', payload: { text: '我的私人备注' } }),
msg(2, { authorId: 'u_bob', payload: { text: '@u_alice 看下' } }),
],
meta: {},
}
const ctx = buildAgentContext({
snapshot,
memberId: 'u_alice',
agentRef: 'agent_alice',
form: 'proxy',
viewerId: 'u_alice', // 🔴 以组员本人为可见性主体
focusSeq: 2,
now: 1_000_002,
})
assert.equal(ctx.prompt.includes('我的私人备注'), true, '代表 alice ⇒ 能看 alice 自己的私聊')
assert.equal(ctx.role.includes('代'), true, '形态 B 的职责说明应点出"代答"')
})
/* ── 判据 5 / 9:四条硬约束 + 房间级限速排队 ─────────────────────────────── */
test('判据 5:房间级限速 —— 超限**排队**(⛔ 不丢消息)', () => {
let clock = 0
const limiter = new RoomRateLimiter(2, 1000, () => clock)
assert.deepEqual(limiter.request('imr_a', 'k1'), { admit: true })
assert.deepEqual(limiter.request('imr_a', 'k2'), { admit: true })
const q3 = limiter.request('imr_a', 'k3')
assert.equal(q3.admit, false)
assert.equal(q3.position, 1, '排队位置按到达序')
const q4 = limiter.request('imr_a', 'k4')
assert.equal(q4.position, 2)
// 重复 key 不重复排队(幂等)
assert.equal(limiter.request('imr_a', 'k3').position, 1)
assert.equal(limiter.state('imr_a').queued, 2)
// 翻窗后按序放行
clock = 1001
assert.deepEqual(limiter.drain('imr_a', 5), ['k3', 'k4'])
assert.equal(limiter.state('imr_a').queued, 0)
assert.equal(limiter.state('imr_a').used, 2)
})
test('判据 5:房间级限速是**按房**隔离的(一个房刷屏不拖累另一个)', () => {
const limiter = new RoomRateLimiter(1, 60_000)
assert.equal(limiter.request('imr_a', 'a1').admit, true)
assert.equal(limiter.request('imr_a', 'a2').admit, false)
assert.equal(limiter.request('imr_b', 'b1').admit, true, '另一个房不受影响')
})
test('判据 6 / §五-6:代答标识 —— 形态 B 的 author 指向组员本人且 via=agent', () => {
const proxy = replyMarkerOf({ form: 'proxy', memberId: 'u_alice', ownerName: 'Alice' })
assert.equal(proxy.authorId, 'u_alice', '形态 B 的 author 必须是组员本人')
assert.equal(proxy.via, 'agent')
assert.equal(proxy.label.includes('代答'), true, '标识必须可见(产品语义)')
assert.equal(proxy.label.includes('Alice'), true)
const bot = replyMarkerOf({ form: 'bot', memberId: 'bot_a' })
assert.equal(bot.authorId, 'bot_a', '形态 A 的 author 指向 bot 成员位')
assert.equal(bot.via, 'agent')
// 回写 payload 带标识(UI 可直读,不必猜)
const payload = replyPayloadOf('答案是 ALPHA', proxy)
assert.equal(payload.text, '答案是 ALPHA')
assert.equal(payload.agentReply, true)
assert.equal(typeof payload.agentLabel, 'string')
})
test('判据 8 / §六-1-8:插件崩溃 ⇒ 只影响自己(判据落在"不抛"上)', async () => {
// 载体:真加载插件包的纯函数面(不 apply ⇒ 不起连接)
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
// 默认 ask 实现是占位,必须**不抛**
const out = await mod.defaultAsk('hello')
assert.equal(typeof out, 'string')
// 解析端点在坏输入下应抛(由 apply 捕获 ⇒ 不拖垮 profile);这里只确认它可解析正常输入
const ep = mod.parseEndpoint('https://ai1net.com')
assert.equal(ep.port, 443)
assert.equal(ep.path, '/api/im/ws')
assert.equal(ep.tls, true)
const ep2 = mod.parseEndpoint('http://127.0.0.1:3080')
assert.equal(ep2.port, 3080)
assert.equal(ep2.tls, false)
})
/* ── 判据 2 / 10:只拨出(无监听端口)──────────────────────────────────── */
test('判据 2 / 10 / E3:插件源码**不含任何 listen(**(只拨出)', () => {
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
// ⚠️ 只查真正的调用形态(`listen(` / `.listen (`),注释里提到这个词不算
const callSites = src
.split('\n')
.filter((line) => !line.trim().startsWith('*') && !line.trim().startsWith('//'))
.filter((line) => /\.listen\s*\(/.test(line) || /(^|[^.\w])listen\s*\(/.test(line))
assert.deepEqual(callSites, [], `⛔ 不得有监听端口(E3 只拨出):\n${callSites.join('\n')}`)
// 正向:必须真的有 connect 调用(否则"无 listen"是空断言)
assert.equal(/connect\s*\(/.test(src), true, '必须有拨出(connect)')
assert.equal(/createServer|createServer\s*\(/.test(src), false, '⛔ 不得建 server')
})
test('判据 2:插件不引任何外部依赖(无 import 第三方包)', () => {
const pkg = JSON.parse(readFileSync(join(REPO, 'poc/im-agent-bridge/package.json'), 'utf8'))
assert.deepEqual(pkg.dependencies, {}, '⛔ C 单不引入新依赖')
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1])
assert.equal(imports.length > 0, true)
for (const spec of imports) {
assert.equal(spec.startsWith('node:') || spec.startsWith('.'), true, `⛔ 非内置/相对导入:${spec}`)
}
})
test('判据 2 / §三:插件形态 = cordis.patch.yml + dsh.bundle.patch(⛔ 无 client 面)', () => {
const pkg = JSON.parse(readFileSync(join(REPO, 'poc/im-agent-bridge/package.json'), 'utf8'))
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
assert.equal(pkg.dsh.client, undefined, 'C 单是**服务端**接入,⛔ 不声明 client 面')
const patch = readFileSync(join(REPO, 'poc/im-agent-bridge/cordis.patch.yml'), 'utf8')
assert.equal(patch.includes('@dsh-local/im-agent-bridge'), true)
})
/* ── 判据 1 / §三:env 是唯一开关(不注入则不启动)──────────────────────── */
test('判据 1 / §三:不注入 env ⇒ apply() 直接 return(⛔ 无连接尝试)', async () => {
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
const saved = {
url: process.env.DSH_PLATFORM_IM_URL,
token: process.env.DSH_IM_INSTANCE_TOKEN,
}
delete process.env.DSH_PLATFORM_IM_URL
delete process.env.DSH_IM_INSTANCE_TOKEN
try {
// 无 ctx 服务依赖、不抛 ⇒ 天然开关生效
assert.doesNotThrow(() => mod.apply({ on: () => undefined }))
assert.equal(mod.currentBridge(), null, '不注入 ⇒ 不起桥(日志无连接尝试)')
} finally {
if (saved.url !== undefined) process.env.DSH_PLATFORM_IM_URL = saved.url
if (saved.token !== undefined) process.env.DSH_IM_INSTANCE_TOKEN = saved.token
}
})
test('判据 1 / §五-2:重连退避在 30 s 内(首延迟与上限)', async () => {
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
assert.equal(mod.RECONNECT_FIRST_MS <= 30_000, true, '首延迟必须 ≤ 30 s')
assert.equal(mod.RECONNECT_MAX_MS <= 30_000, true, '上限必须 ≤ 30 s(§五-2 要求 30 s 内恢复)')
})
test('判据 1:凭据头是 `x-dsh-im-instance-token`(与 B 单预留点同名)', () => {
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
assert.equal(src.includes('x-dsh-im-instance-token'), true)
// 与平台侧读取的头必须字面一致
const routes = readFileSync(join(REPO, 'src/web/routes/im.ts'), 'utf8')
assert.equal(routes.includes('x-dsh-im-instance-token'), true)
const ws = readFileSync(join(REPO, 'src/im/ws.ts'), 'utf8')
assert.equal(ws.includes('x-dsh-im-instance-token'), true)
})
/* ── 判据 7:边界(官方 dsh 未改 / relay 未改 / 表结构未改)─────────────── */
test('判据 7 / §六-1:A 单的三张表 DDL 计数未变(⛔ 本单不动表结构)', () => {
const schema = readFileSync(join(REPO, 'src/db/schema.ts'), 'utf8')
// ⚠️ 本条原断言「最高版本 = 14」是**A 单自己的 ratchet**:A 单⛔不改表结构。
// 后续单子**允许**加版本(v15 = 插件数据面台账,插件投放与分库线),故判据改成
// 「**v14 仍在** + 没有**大于 v14 的本单改动**」——保留 ratchet 语义(A 单的表结构未被改),
// 同时不把后续合法迁移卡红。⛔ 不要为了过测而删掉 v14 的存在性断言。
const versions = [...schema.matchAll(/\{\s*version:\s*(\d+)/g)].map((m) => Number(m[1]))
assert.equal(versions.includes(14), true, 'A 单的 v14 三张表仍在')
assert.equal(schema.includes('im_room_keys'), false, 'E2EE 的两张表属**待拍板**,本单⛔不建')
})
test('判据 7:B 单的对外形状未变(WS 路径与 op 词表)', () => {
const ws = readFileSync(join(REPO, 'src/im/ws.ts'), 'utf8')
for (const op of ['subscribe', 'unsubscribe', 'send', 'resume', 'ping']) {
assert.equal(ws.includes(`'${op}'`), true, `客户端 op 词表应保留 ${op}`)
}
const hub = readFileSync(join(REPO, 'src/im/hub.ts'), 'utf8')
assert.equal(hub.includes("'/api/im/ws'"), true, 'WS 路径形状不变')
assert.equal(IM_MAX_CONN_PER_USER > 0, true)
})
+547
View File
@@ -0,0 +1,547 @@
// IM **连接层后端抽象**回归测试(node:test)—— IM 线 · 第 12 棒。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// ## 覆盖策略
// ① **等价性判据**(最重要):native 后端 / 未注入扇出端口时,`ImHub.deliver` 与
// `flushPresence` 的行为必须与第 6 棒落地时**逐字相同**(帧文本、`delivered` 口径、
// "无人订阅则不产帧")。这是"零行为变化"的可执行证据。
// ② **能力自述判据**:两种后端的 `capabilities` 逐项对照 + 结构性约束(不发心跳
// 却不声明需要垫片 ⇒ 构造即抛 —— 把 E-2 的「26 s 全断」钉成编译期/构造期约束)。
// ③ **自建保活判据**(硬实现项 ①):计时起点**必须**绑该连接自己的认证完成时刻;
// 并对"整进程统一计时"这个**结构性反面**做对照断言(不编造掉线数,只断死线差异)。
// ④ **gateway 判据**:发布一次 / 帧原样装信封 / 配置缺项与背压都**具名**且**不抛** /
// 失败只计数 / `presenceIngest` 自曝未接线。
// ⑤ **不变量判据**:`/api/im/ws` 路径常量、帧上限、`DSH_IM_WS=0` 回滚开关、
// 「后端概念不得泄漏进内核文件」的源码级断言。
//
// ⚠️ 本文件**不碰网络**(gateway 用注入的 `fetchImpl`)、**不碰 DB**(store 用桩)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
BACKEND_KINDS,
IM_BACKEND_ENV,
IM_GATEWAY_OBSERVED_IDLE_MS,
assertCapabilities,
assertKeepalivePlan,
planKeepalive,
selectConnectionBackend,
} from '../lib/im/connection-backend.js'
import {
GATEWAY_CAPABILITIES,
GATEWAY_PRESENCE_INGEST,
IM_GATEWAY_DEFAULT_CHANNEL_PREFIX,
channelFor,
createGatewayConnectionBackend,
gatewayPayloadOf,
} from '../lib/im/backends/gateway.js'
import { NATIVE_CAPABILITIES, createNativeConnectionBackend } from '../lib/im/backends/native.js'
import { ImHub } from '../lib/im/hub.js'
import { syntheticConnection } from '../lib/im/ws.js'
import { ImKeepaliveShim } from '../lib/im/keepalive.js'
const ROOT = process.cwd()
const readSrc = (rel) => readFileSync(join(ROOT, rel), 'utf8')
/** 最小 store 桩(hub 只在 `send` / `resume` 路径上用真 store;本文件不碰)。 */
const storeStub = () => ({})
/** runtime 桩(native / gateway 后端只转发它的三个方法)。 */
const runtimeStub = (over = {}) => ({
dispatch: () => false,
connectionCount: () => 0,
close: () => undefined,
...over,
})
function hubWith(options = {}) {
return new ImHub(storeStub(), options)
}
// ── 判据 1–3 · 后端选择(纯函数 ⇒ 直接断)─────────────────────────────────────
test('B-1 · 后端选择:未声明 ⇒ native/default(⛔ 不静默切到外部层)', () => {
assert.deepEqual(selectConnectionBackend({}), { kind: 'native', reason: 'default' })
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: '' }), { kind: 'native', reason: 'default' })
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: ' ' }), { kind: 'native', reason: 'default' })
})
test('B-2 · 后端选择:显式声明 ⇒ explicit(大小写不敏感)', () => {
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: 'native' }), { kind: 'native', reason: 'explicit' })
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: 'gateway' }), { kind: 'gateway', reason: 'explicit' })
assert.deepEqual(selectConnectionBackend({ [IM_BACKEND_ENV]: ' GateWay ' }), { kind: 'gateway', reason: 'explicit' })
assert.deepEqual(BACKEND_KINDS, ['native', 'gateway'])
})
test('B-3 · 后端选择:不认识的值 ⇒ 退回 native 但**带回原值**(调用方必须落具名告警)', () => {
const sel = selectConnectionBackend({ [IM_BACKEND_ENV]: 'centrifugo-direct' })
assert.equal(sel.kind, 'native')
assert.equal(sel.reason, 'unknown-fallback')
assert.equal(sel.raw, 'centrifugo-direct')
})
// ── 判据 4–7 · 能力自述与结构性约束 ──────────────────────────────────────────
test('B-4 · native 能力自述:自己终结客户端、自己发 WS ping、扇出在本进程、⛔ 不需要垫片', () => {
assert.deepEqual(NATIVE_CAPABILITIES, {
terminatesClients: true,
providesServerHeartbeat: true,
fanoutOffsite: false,
needsKeepaliveShim: false,
presenceSource: 'local',
})
const backend = createNativeConnectionBackend({ hub: hubWith(), runtime: runtimeStub() })
assert.equal(backend.kind, 'native')
assert.deepEqual(backend.capabilities, NATIVE_CAPABILITIES)
})
test('B-5 · gateway 能力自述:扇出外移、**会**发协议级 ping(第 16 棒修正)⇒ 仍须声明垫片、在线态取自后端', () => {
assert.deepEqual(GATEWAY_CAPABILITIES, {
terminatesClients: false,
// 🔴 第 16 棒修正:原 `false` 出自对 `wsPingRecv=0` 的误读 —— 那条读数数的是
// **WS 控制帧**,而外部连接层发的是**协议级 JSON ping**(空对象)。改判依据 =
// 选型单 §15.11.3 / §15.11.4 在 47 就地部署实例上的三向实测(off 断 / conn 掉 0 / global 断)。
providesServerHeartbeat: true,
fanoutOffsite: true,
needsKeepaliveShim: true,
presenceSource: 'backend',
})
})
test('B-6 · 结构性约束:① 不发心跳却不说需要垫片 ⇒ 抛 ② **扇出外移却不说需要垫片 ⇒ 抛**(第 16 棒新增)③ 自相矛盾 ⇒ 抛', () => {
assert.throws(
() => assertCapabilities('native', { ...NATIVE_CAPABILITIES, providesServerHeartbeat: false, needsKeepaliveShim: false }),
/backend-without-heartbeat-needs-shim/,
)
// 🔴 第 16 棒新增:`providesServerHeartbeat` 改 `true` 后,上一条的网关分支不再生效
// (`true` + 任意 shim 值都过)⇒ 必须补这一条,否则"外移却不下发保活要求"会静默通过。
assert.throws(
() => assertCapabilities('gateway', { ...GATEWAY_CAPABILITIES, needsKeepaliveShim: false }),
/backend-fanout-offsite-needs-shim/,
)
assert.throws(
() => assertCapabilities('gateway', { ...GATEWAY_CAPABILITIES, terminatesClients: true }),
/backend-fanout-offsite-cannot-terminate-clients/,
)
// 合规的两组不抛(正向对照)
assert.doesNotThrow(() => assertCapabilities('native', NATIVE_CAPABILITIES))
assert.doesNotThrow(() => assertCapabilities('gateway', GATEWAY_CAPABILITIES))
})
test('B-7 · 保活计划:默认由实测空窗反推,锚点**只能是**每连接认证完成时刻', () => {
const plan = planKeepalive()
assert.equal(plan.idleTimeoutMs, IM_GATEWAY_OBSERVED_IDLE_MS)
assert.equal(plan.idleTimeoutMs, 26_000)
assert.equal(plan.intervalMs, Math.floor(26_000 / 3))
assert.ok(plan.intervalMs > 0 && plan.intervalMs < plan.idleTimeoutMs, '间隔必须严格小于空窗')
assert.ok(plan.intervalMs * 2 < plan.idleTimeoutMs, '丢一次 ping 也不该被判死')
assert.equal(plan.anchor, 'per-connection-auth-complete')
// 篡改锚点 / 把间隔调到 ≥ 空窗 ⇒ 必须抛(⛔ 不带病上线)
assert.throws(() => assertKeepalivePlan({ ...plan, anchor: 'process-start' }), /keepalive-anchor-invalid/)
assert.throws(() => assertKeepalivePlan({ ...plan, intervalMs: plan.idleTimeoutMs }), /keepalive-interval-invalid/)
assert.doesNotThrow(() => assertKeepalivePlan(plan))
})
// ── 判据 8–11 · 内核等价性("零行为变化"的可执行证据)─────────────────────────
test('B-8 · 等价性(native/未注入端口):`deliver` 读数 = 在线连接数;**无人订阅则不产帧**', () => {
const hub = hubWith()
const a = syntheticConnection('c1', 'u1')
const b = syntheticConnection('c2', 'u1')
hub.attach(a)
hub.attach(b)
// 无人订阅该房 ⇒ delivered/skipped 恒 0(与第 6 棒逐字相同)
const empty = hub.deliver('room-x', { id: 'm1', seq: 1 })
assert.deepEqual(empty, { roomId: 'room-x', messageId: 'm1', delivered: 0, skipped: 0 })
const sent = []
const conn = { ...syntheticConnection('c3', 'u2'), send: (t) => (sent.push(t), true) }
hub.attach(conn)
hub.subscribe(conn, 'room-1')
const r = hub.deliver('room-1', { id: 'm2', seq: 2 })
assert.equal(r.delivered, 1)
assert.equal(r.skipped, 0)
// 帧文本与改动前逐字相同(客户端只有一种解包路径)
assert.equal(sent[0], JSON.stringify({ op: 'message', roomId: 'room-1', messages: [{ id: 'm2', seq: 2 }] }))
})
test('B-9 · 等价性(native/未注入端口):`deliver` 一条消息对**同一连接只推 1 次**', () => {
const hub = hubWith()
const sent = []
const conn = { ...syntheticConnection('c1', 'u1'), send: (t) => (sent.push(t), true) }
hub.attach(conn)
hub.subscribe(conn, 'room-1')
hub.subscribe(conn, 'room-1') // 幂等:重复订阅不增加投递
const r = hub.deliver('room-1', { id: 'm1', seq: 1 })
assert.equal(r.delivered, 1)
assert.equal(sent.length, 1)
})
test('B-10 · 等价性(native/未注入端口):`flushPresence` 每房每窗口 1 帧/连接,无人订阅不产帧', () => {
// ⚠️ `now: () => 0` + `presenceBatchMs: 0`:把批窗口闸门打开且**不依赖真实时间**
// (否则 `lastFlushAt` 取真实 Date.now ⇒ 首帧被闸门吃掉,判据会假绿)。
const hub = hubWith({ now: () => 0, presenceBatchMs: 0 })
const frames1 = []
const c1 = { ...syntheticConnection('c1', 'u1'), send: (t) => (frames1.push(t), true) }
hub.attach(c1)
hub.notePresence('room-none', 'u9', 'online', 900)
assert.equal(hub.flushPresence(1000), 0, '无人订阅的房:⛔ 不产帧')
hub.subscribe(c1, 'room-1', false) // ⛔ 不因订阅本身记一次 online(否则成员数组会多一条)
hub.notePresence('room-1', 'u2', 'online', 1500)
assert.equal(hub.flushPresence(2000), 1)
assert.equal(frames1.length, 1)
assert.equal(
frames1[0],
JSON.stringify({ op: 'presence', roomId: 'room-1', at: 2000, members: [{ memberId: 'u2', state: 'online', at: 1500 }] }),
)
})
test('B-11 · 注入扇出端口(外移口径):即使本进程无人订阅也**照发**,且帧文本与 native 逐字相同', () => {
const seen = []
const hub = hubWith({
now: () => 0,
presenceBatchMs: 0,
fanout: { deliverFrame: (roomId, text) => (seen.push([roomId, text]), { delivered: 1, skipped: 0 }) },
})
// 本进程零连接
const r = hub.deliver('room-9', { id: 'm9', seq: 9 })
assert.equal(r.delivered, 1, '外移口径下必须照发(否则一条都送不出去)')
assert.equal(seen.length, 1)
assert.equal(seen[0][0], 'room-9')
assert.equal(seen[0][1], JSON.stringify({ op: 'message', roomId: 'room-9', messages: [{ id: 'm9', seq: 9 }] }))
// presence 也走端口
hub.notePresence('room-9', 'u1', 'online', 4000)
assert.equal(hub.flushPresence(5000), 1)
assert.equal(seen.length, 2)
assert.match(seen[1][1], /"op":"presence"/)
})
// ── 判据 12–16 · 自建保活垫片(硬实现项 ①)──────────────────────────────────
test('B-12 · 保活锚点:两条相隔 5000 ms 认证的连接 ⇒ 死线之差**恰为** 5000 ms(⛔ 不是同一条死线)', () => {
const pings = []
const shim = new ImKeepaliveShim({ sink: { ping: (id) => pings.push(id) }, now: () => 0, sweepMs: 3600_000 })
try {
shim.start('c-early', 1000)
shim.start('c-late', 6000)
const [early, late] = shim.anchors()
assert.equal(early.connId, 'c-early')
assert.equal(late.connId, 'c-late')
assert.equal(early.anchorAt, 1000)
assert.equal(late.anchorAt, 6000)
assert.equal(late.nextPingAt - early.nextPingAt, 5000, '死线必须跟着各连接自己的认证时刻走')
assert.equal(early.nextPingAt, 1000 + shim.planRef.intervalMs)
assert.equal(late.nextPingAt, 6000 + shim.planRef.intervalMs)
} finally {
shim.close()
}
})
test('B-13 · 保活锚点(结构性反面):整进程统一计时会给**同一条死线** —— 两种模型必须可区分', () => {
// 对照模型(**不是**本实现,只用来证明"两种模型不同"):死线由进程起点决定,与认证时刻无关。
const sharedModel = (t0) => (authTimes) => authTimes.map(() => t0 + 1)
const perConn = (authTimes, interval) => authTimes.map((t) => t + interval)
const authTimes = [1000, 6000, 11000]
assert.equal(new Set(sharedModel(0)(authTimes)).size, 1, '统一计时 ⇒ 死线全相等(这正是 E-2 的缺陷形态)')
assert.equal(new Set(perConn(authTimes, 8000)).size, 3, '每连接计时 ⇒ 死线各不相同')
// 本实现落在"每连接"那一侧
const shim = new ImKeepaliveShim({ sink: { ping: () => undefined }, now: () => 0, sweepMs: 3600_000 })
try {
for (const t of authTimes) shim.start(`c-${t}`, t)
assert.equal(new Set(shim.anchors().map((a) => a.nextPingAt)).size, 3)
} finally {
shim.close()
}
})
test('B-14 · 保活节奏:到点发 ping、每连接各自计数;入向流量刷新 lastSeen ⇒ 不误判死', () => {
const pings = []
const shim = new ImKeepaliveShim({ sink: { ping: (id) => pings.push(id) }, now: () => 0, sweepMs: 3600_000 })
try {
const iv = shim.planRef.intervalMs
shim.start('c1', 0)
shim.tick(iv - 1)
assert.equal(pings.length, 0, '未到死线不发')
shim.tick(iv)
assert.deepEqual(pings, ['c1'])
shim.tick(iv * 2)
assert.deepEqual(pings, ['c1', 'c1'])
assert.equal(shim.anchors()[0].pingsSent, 2)
// 持续有入向流量 ⇒ 永不过期
shim.noteSeen('c1', iv * 2)
shim.tick(iv * 2 + shim.planRef.idleTimeoutMs - 1)
assert.equal(shim.stats().expired, 0)
assert.equal(shim.size(), 1)
} finally {
shim.close()
}
})
test('B-15 · 保活判死:静默超过空窗 ⇒ 恰好 expire 一次(⛔ 不重复计数)+ 报出 idleMs', () => {
const expired = []
const shim = new ImKeepaliveShim({
sink: { ping: () => undefined },
now: () => 0,
sweepMs: 3600_000,
onExpire: (id, idleMs) => expired.push([id, idleMs]),
})
try {
shim.start('c1', 0)
shim.tick(shim.planRef.idleTimeoutMs) // 恰好等于空窗 ⇒ 不超 ⇒ 不判死
assert.equal(expired.length, 0)
shim.tick(shim.planRef.idleTimeoutMs + 1)
assert.equal(expired.length, 1)
assert.equal(expired[0][0], 'c1')
assert.equal(expired[0][1], shim.planRef.idleTimeoutMs + 1)
shim.tick(shim.planRef.idleTimeoutMs + 5000)
assert.equal(expired.length, 1, '已回收的连接不得重复计数')
assert.equal(shim.size(), 0)
assert.equal(shim.stats().expired, 1)
} finally {
shim.close()
}
})
test('B-16 · 保活收尾:stop/close 幂等,close 之后 tick 不再动', () => {
const pings = []
const shim = new ImKeepaliveShim({ sink: { ping: (id) => pings.push(id) }, now: () => 0, sweepMs: 3600_000 })
shim.start('c1', 0)
shim.start('c2', 0)
shim.stop('c1')
assert.equal(shim.size(), 1)
shim.stop('c1')
assert.equal(shim.size(), 1, 'stop 幂等')
shim.close()
assert.equal(shim.size(), 0)
shim.tick(shim.planRef.intervalMs * 10)
assert.equal(pings.length, 0, 'close 之后不再发 ping')
})
// ── 判据 17–22 · gateway 后端 ────────────────────────────────────────────────
test('B-17 · 频道名归一:前缀允许 `:`(命名空间分隔符),房间 id **只放行** `[A-Za-z0-9_-]`', () => {
assert.equal(channelFor('imr_abc-123', 'im-room-'), 'im-room-imr_abc-123')
// 前缀里的 `:` 与 `.` 保留(否则运维没法按命名空间配置);空格仍被换掉
assert.equal(channelFor('room/with:bad chars', 'p x:'), 'p_x:room_with_bad_chars')
assert.equal(channelFor('imr_1', 'im:'), 'im:imr_1')
// 🔴 房间 id 里的 `:` 必须被挡掉 —— 否则能借它把帧推进**另一个命名空间**(越权面)
assert.equal(channelFor('imr_1:other-ns', 'im:'), 'im:imr_1_other-ns')
assert.equal(channelFor('r1'), `${IM_GATEWAY_DEFAULT_CHANNEL_PREFIX}r1`)
})
test('B-18 · 帧格式不变:应用层帧**原样**装进 `{frame}`(客户端 parse 出来与 native 逐字相同)', () => {
const text = JSON.stringify({ op: 'message', roomId: 'r', messages: [{ id: 'm' }] })
const payload = gatewayPayloadOf(text)
assert.deepEqual(payload, { frame: text })
assert.equal(JSON.parse(JSON.stringify(payload)).frame, text)
})
test('B-19 · gateway 配置缺项:**具名降级**且不抛 —— `{0,1}` + `degraded` + 只警告一次', () => {
const warns = []
const noFetch = () => {
throw new Error('不该发网络请求')
}
const backend = createGatewayConnectionBackend({
endpoint: '',
runtime: runtimeStub(),
fetchImpl: noFetch,
log: { info: () => undefined, warn: (f, m) => warns.push([m, f]) },
})
assert.deepEqual(backend.deliverFrame('r1', 'x'), { delivered: 0, skipped: 1 })
assert.deepEqual(backend.deliverFrame('r1', 'x'), { delivered: 0, skipped: 1 })
assert.equal(backend.stats().degraded, 'missing-endpoint')
assert.equal(backend.stats().publishes, 0)
assert.equal(warns.filter((w) => w[0] === 'im_gateway_not_ready').length, 1, '⛔ 不刷屏')
// 有 URL 无 KEY ⇒ 另一个具名原因
const backend2 = createGatewayConnectionBackend({ endpoint: 'http://127.0.0.1:1', runtime: runtimeStub(), fetchImpl: noFetch })
backend2.deliverFrame('r1', 'x')
assert.equal(backend2.stats().degraded, 'missing-key')
})
test('B-20 · gateway 发布:一次 `deliverFrame` ⇒ 恰一次调用,频道与信封正确,计数 +1', async () => {
const calls = []
const backend = createGatewayConnectionBackend({
endpoint: 'http://gw.test:9/',
key: 'k-1',
runtime: runtimeStub(),
fetchImpl: async (url, init) => {
calls.push({ url, init })
return { ok: true, status: 200, text: async () => '' }
},
})
const text = JSON.stringify({ op: 'message', roomId: 'imr_1', messages: [{ id: 'm' }] })
assert.deepEqual(backend.deliverFrame('imr_1', text), { delivered: 1, skipped: 0 })
assert.equal(calls.length, 1)
assert.equal(calls[0].url, 'http://gw.test:9/api/publish', '尾斜杠必须被剥掉')
assert.equal(calls[0].init.headers.authorization, 'apikey k-1')
const body = JSON.parse(calls[0].init.body)
assert.equal(body.channel, 'im-room-imr_1')
assert.deepEqual(body.data, { frame: text })
// 发布是**异步受理** ⇒ 等一拍再读计数
await new Promise((r) => setImmediate(r))
const s = backend.stats()
assert.equal(s.publishes, 1)
assert.equal(s.publishFailures, 0)
assert.equal(s.inflight, 0)
assert.ok(s.lastPublishMs >= 0)
assert.equal(s.hasKey, true)
assert.equal(s.endpoint, 'http://gw.test:9')
assert.equal(s.degraded, '')
})
test('B-21 · gateway 发布失败:只计数 + 具名日志,**⛔ 不抛**(一条坏消息不得掀掉房间)', async () => {
const warns = []
const backend = createGatewayConnectionBackend({
endpoint: 'http://gw.test:9',
key: 'k',
runtime: runtimeStub(),
log: { info: () => undefined, warn: (f, m) => warns.push([m, f]) },
fetchImpl: async () => ({ ok: false, status: 502, text: async () => 'bad gateway' }),
})
assert.doesNotThrow(() => backend.deliverFrame('r1', 'x'))
await new Promise((r) => setImmediate(r))
assert.equal(backend.stats().publishFailures, 1)
assert.equal(backend.stats().lastError, 'http-502')
assert.ok(warns.some((w) => w[0] === 'im_gateway_publish_failed'))
const backend2 = createGatewayConnectionBackend({
endpoint: 'http://gw.test:9',
key: 'k',
runtime: runtimeStub(),
fetchImpl: async () => {
throw new Error('ECONNREFUSED')
},
})
assert.doesNotThrow(() => backend2.deliverFrame('r1', 'x'))
await new Promise((r) => setImmediate(r))
assert.equal(backend2.stats().publishFailures, 1)
assert.match(backend2.stats().lastError, /ECONNREFUSED/)
})
test('B-22 · gateway 背压:在途满 ⇒ 立刻 `{0,1}` + `publishDropped`(⛔ 不无限堆积)', async () => {
let release
const gate = new Promise((r) => {
release = r
})
const backend = createGatewayConnectionBackend({
endpoint: 'http://gw.test:9',
key: 'k',
runtime: runtimeStub(),
maxInflight: 1,
fetchImpl: async () => {
await gate
return { ok: true, status: 200, text: async () => '' }
},
})
assert.deepEqual(backend.deliverFrame('r1', 'a'), { delivered: 1, skipped: 0 })
assert.deepEqual(backend.deliverFrame('r1', 'b'), { delivered: 0, skipped: 1 })
assert.equal(backend.stats().publishDropped, 1)
assert.equal(backend.stats().degraded, 'backpressure')
assert.equal(backend.stats().inflight, 1)
release()
await new Promise((r) => setImmediate(r))
assert.equal(backend.stats().inflight, 0)
assert.equal(backend.stats().publishes, 1)
})
test('B-23 · gateway 通道与就绪:平台侧 `/api/im/ws` 保留(dispatch 同一函数对象)、`health()` 缺配置不发请求', async () => {
const dispatch = () => true
let fetchCount = 0
const backend = createGatewayConnectionBackend({
endpoint: 'http://gw.test:9',
key: 'k',
runtime: runtimeStub({ dispatch, connectionCount: () => 7 }),
fetchImpl: async () => {
fetchCount += 1
return { ok: true, status: 200, text: async () => '{}' }
},
})
assert.equal(backend.dispatch, dispatch, '两种后端的 dispatch 都来自 runtime(实例内通道 + 回滚路径)')
assert.equal(backend.connectionCount(), 7)
assert.equal(backend.stats().presenceIngest, GATEWAY_PRESENCE_INGEST)
assert.equal(GATEWAY_PRESENCE_INGEST, 'not-wired')
const missing = createGatewayConnectionBackend({ endpoint: '', runtime: runtimeStub(), fetchImpl: async () => ({ ok: true, status: 200, text: async () => '' }) })
assert.deepEqual(await missing.health(), { ok: false, reason: 'missing-endpoint' })
assert.equal(fetchCount, 0, '缺配置的健康检查不得发网络请求')
const ok = await backend.health()
assert.equal(ok.ok, true)
assert.equal(fetchCount, 1, 'health() ⛔ 不改任何发设计数')
assert.equal(backend.stats().publishes, 0)
})
test('B-24 · native 后端:dispatch / connectionCount / close 全部转发给 runtime(纯包装)', () => {
const dispatch = () => false
let closed = 0
const runtime = runtimeStub({ dispatch, connectionCount: () => 3, close: () => (closed += 1) })
const backend = createNativeConnectionBackend({ hub: hubWith(), runtime })
assert.equal(backend.dispatch, dispatch)
assert.equal(backend.connectionCount(), 3)
assert.equal(backend.stats().keepaliveShim, false)
backend.close()
assert.equal(closed, 1)
})
// ── 判据 25–28 · 不变量(协议 / 开关 / 概念边界)─────────────────────────────
test('B-25 · 协议不变量:`/api/im/ws` 路径与帧上限常量一字未变', async () => {
const hub = await import('../lib/im/hub.js')
assert.equal(hub.IM_WS_PATH, '/api/im/ws')
assert.equal(hub.IM_API_PREFIX, '/api/im/')
assert.equal(hub.IM_HEARTBEAT_MS, 25_000)
assert.equal(hub.IM_PRESENCE_BATCH_MS, 1000)
const ws = await import('../lib/im/ws.js')
assert.equal(ws.IM_MAX_FRAME_BYTES, 1 << 20)
assert.equal(ws.IM_MAX_PAYLOAD_BYTES, 64 << 10)
})
test('B-26 · 回滚开关不变量:`DSH_IM_WS=0` 的早退仍在,且仍在插件最早处', () => {
const src = readSrc('src/web/routes/im.ts')
assert.match(src, /process\.env\.DSH_IM_WS === '0'/, '回滚开关必须保留')
const idxGuard = src.indexOf("DSH_IM_WS === '0'")
// ⚠️ 必须找**调用点**而不是名字 —— `import { createNativeConnectionBackend }` 在文件顶部,
// 按名字搜会误判成"后端在开关之前建"(首版踩过)。
const idxBackend = src.indexOf('createNativeConnectionBackend({ hub')
assert.ok(idxGuard > 0 && idxBackend > idxGuard, '开关必须在建后端之前 ⇒ 关掉时一行都不装')
})
test('B-27 · 概念边界:内核文件里**不得出现**外部连接层的产品名 / 专属配置键', () => {
// ⚠️ 必须带词边界:`openImPg(` 里含子串 "openim" ⇒ 裸 /openim/i 会假红(首版踩过)。
const banned = /\b(?:centrifugo|centrifuge|ejabberd|openim)\b/i
for (const f of [
'src/im/hub.ts',
'src/im/ws.ts',
'src/im/store.ts',
'src/im/types.ts',
'src/im/clock.ts',
'src/im/db.ts',
'src/im/agent-bridge.ts',
'src/im/instance-token.ts',
'src/im/connection-backend.ts',
'src/im/keepalive.ts',
'src/im/backends/native.ts',
'src/im/backends/gateway.ts',
]) {
assert.doesNotMatch(readSrc(f), banned, `${f} 不得泄漏外部组件概念(命名必须中性)`)
}
// 参考部署的专属配置只允许出现在 poc/ 与文档里
assert.doesNotMatch(readSrc('src/web/routes/im.ts'), /centrifugo|centrifuge/i)
})
test('B-28 · 文件名中性:`backends/` 下只有 native 与 gateway 两个后端实现', () => {
const native_ = readSrc('src/im/backends/native.ts')
const gateway = readSrc('src/im/backends/gateway.ts')
assert.match(native_, /export const NATIVE_CAPABILITIES/)
assert.match(gateway, /export const GATEWAY_CAPABILITIES/)
// 接口单一定义源:扇出端口只在 hub.ts 定义,connection-backend 只再导出
const cb = readSrc('src/im/connection-backend.ts')
assert.match(cb, /export type \{ ImFanoutPort, ImFanoutResult \}/)
assert.doesNotMatch(cb, /export interface ImFanoutPort/)
})
+361
View File
@@ -0,0 +1,361 @@
// IM **网关准入面**回归测试(node:test)—— IM 线 · 第 16 棒。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)+ **真客户端件** `poc/im-conversation-tabs/lib/client.js`。
// 判据编号写在用例名里 ⇒ 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// ## 覆盖策略
// ① **能力口径修正判据**:`providesServerHeartbeat` 与实测一致(网关**会**发协议级 ping);
// 连带复核 `assertCapabilities` —— 含**一条反例**(构造即抛仍可复现)。
// ② **票据判据**(平台侧纯函数):HS256 结构 / 签验往返 / 过期 / 错密钥 / 无 `sub` / 频道不符;
// ⛔ 密钥与票据都不进任何读数(返回值里**没有**密钥字段)。
// ③ **频道名反解判据**:严格(前缀不符 / 非 `imr_` / 含 `:` / 空 ⇒ 一律 `undefined`),
// 且 `channelFor` ↔ `roomIdFromChannel` 往返恒等。
// ④ **客户端件判据**(🔴 直接驱动**真客户端件**):空对象即回、回的是**空命令**、
// 非空对象**不回**;`push.pub.data.frame` 提取后与发出去的原帧**逐字相同**。
// ⑤ **源码级结构性判据**:fail-closed 分支齐备、密钥从不外泄、两处房间前缀字面一致、
// 客户端不含 `{op:'pong'}` 这类未认 op。
//
// ⚠️ 本文件**不碰网络**、**不碰 DB**(不 import fastify 路由:那需要真 DB)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { GATEWAY_CAPABILITIES } from '../lib/im/backends/gateway.js'
import {
GATEWAY_ROOM_ID_PREFIX,
IM_GATEWAY_SUBSCRIBE_CALLBACK_PATH,
channelFor,
resolveChannelPrefix,
roomIdFromChannel,
} from '../lib/im/backends/gateway.js'
import { NATIVE_CAPABILITIES } from '../lib/im/backends/native.js'
import { assertCapabilities } from '../lib/im/connection-backend.js'
import {
IM_GATEWAY_CONNECT_TTL_SEC,
IM_GATEWAY_SUBSCRIBE_TTL_SEC,
base64Url,
buildConnectClaims,
buildSubscriptionClaims,
issueConnectToken,
issueSubscriptionToken,
signHmacJwt,
verifyHmacJwt,
} from '../lib/im/gateway-token.js'
const ROOT = process.cwd()
const readSrc = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const SECRET = 'test-secret-not-a-real-key'
const NOW = 1_700_000_000
// ── 判据 ① · 能力口径修正(连带复核 assertCapabilities)──────────────────────
test('G-1 · capability 口径:外部连接层**会**发协议级 ping ⇒ providesServerHeartbeat=true(依据 §15.11 三向实测)', () => {
assert.equal(GATEWAY_CAPABILITIES.providesServerHeartbeat, true)
// ⛔ 旧的 false 出自对 `wsPingRecv=0` 的误读(那条读数数的是 WS 控制帧)——
// 本断言把修正后的值钉住,防止有人照旧注释又改回去。
assert.notEqual(GATEWAY_CAPABILITIES.providesServerHeartbeat, false)
// 垫片仍需要(平台侧拨不到外部层的连接 ⇒ 保活落点在客户端)。
assert.equal(GATEWAY_CAPABILITIES.needsKeepaliveShim, true)
assert.equal(GATEWAY_CAPABILITIES.terminatesClients, false)
assert.equal(GATEWAY_CAPABILITIES.fanoutOffsite, true)
assert.equal(GATEWAY_CAPABILITIES.presenceSource, 'backend')
})
test('G-2 · 构造即抛仍可复现(反例)+ 新增的「扇出外移必须声明垫片」判据', () => {
// 反例 ①:不发心跳、又不声明垫片 ⇒ 抛(E-2「26 s 全断」的约束形态)
assert.throws(
() => assertCapabilities('native', { ...NATIVE_CAPABILITIES, providesServerHeartbeat: false, needsKeepaliveShim: false }),
/backend-without-heartbeat-needs-shim/,
)
// 反例 ②(第 16 棒新增):扇出外移却声明"不需要垫片" ⇒ 抛。
// 🔴 为什么必须新增:`providesServerHeartbeat` 改 true 之后,反例 ① 的网关分支
// 不再生效(true + 任意 shim 值都过)⇒ 不补这一条,规则就只是摆设。
assert.throws(
() => assertCapabilities('gateway', { ...GATEWAY_CAPABILITIES, needsKeepaliveShim: false }),
/backend-fanout-offsite-needs-shim/,
)
// 反例 ③:扇出外移又自称终结客户端 ⇒ 抛
assert.throws(
() => assertCapabilities('gateway', { ...GATEWAY_CAPABILITIES, terminatesClients: true }),
/backend-fanout-offsite-cannot-terminate-clients/,
)
// 正向对照:两组合规自述都不抛
assert.doesNotThrow(() => assertCapabilities('gateway', GATEWAY_CAPABILITIES))
assert.doesNotThrow(() => assertCapabilities('native', NATIVE_CAPABILITIES))
})
// ── 判据 ② · 票据(纯函数;⛔ 不碰密钥以外的东西)────────────────────────────
test('G-3 · 票据结构:三段 base64url + 头部写死 HS256(⛔ alg 不由调用方给)', () => {
const token = signHmacJwt({ sub: 'u_1', exp: NOW + 60 }, SECRET)
const parts = token.split('.')
assert.equal(parts.length, 3)
for (const p of parts) assert.match(p, /^[A-Za-z0-9_-]+$/)
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'))
assert.deepEqual(header, { alg: 'HS256', typ: 'JWT' })
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
assert.deepEqual(payload, { sub: 'u_1', exp: NOW + 60 })
// 空密钥 ⇒ 抛(⛔ 不签出"没签名"的票据)
assert.throws(() => signHmacJwt({ sub: 'u_1' }, ''), /gateway-token-secret-empty/)
assert.equal(base64Url(Buffer.from([0xfb, 0xff])), '-_8')
})
test('G-4 · 签 → 验往返:正确密钥过,错密钥 / 过期 / 无 sub / 频道不符 一律不过(且原因具名)', () => {
const tok = issueConnectToken('u_42', SECRET, NOW)
assert.equal(verifyHmacJwt(tok.token, SECRET, NOW + 1).ok, true)
assert.deepEqual(verifyHmacJwt(tok.token, 'other-secret', NOW + 1), { ok: false, reason: 'bad-signature' })
assert.deepEqual(verifyHmacJwt(tok.token, SECRET, tok.exp + 1), { ok: false, reason: 'expired' })
assert.deepEqual(verifyHmacJwt('not-a-jwt', SECRET, NOW), { ok: false, reason: 'malformed' })
assert.deepEqual(verifyHmacJwt(signHmacJwt({ exp: NOW + 60 }, SECRET), SECRET, NOW), { ok: false, reason: 'no-sub' })
// 订阅票据:频道不符 ⇒ 不过("这张票只对这一个频道有效")
const sub = issueSubscriptionToken('u_42', 'im:imr_a', SECRET, NOW)
assert.equal(verifyHmacJwt(sub.token, SECRET, NOW + 1, { expectedChannel: 'im:imr_a' }).ok, true)
assert.equal(verifyHmacJwt(sub.token, SECRET, NOW + 1, { expectedChannel: 'im:imr_b' }).ok, false)
// ⛔ 空密钥 ⇒ 恒不过(fail-closed,⛔ 不"跳过校验")
assert.equal(verifyHmacJwt(tok.token, '', NOW + 1).ok, false)
})
test('G-5 · claims 口径:sub 恒为字符串、exp = now + ttl、ttl 有下限;签发结果里⛔ 没有密钥字段', () => {
const c = buildConnectClaims(42, NOW)
assert.equal(c.sub, '42') // 数字 id 也必须转成字符串(协议要求,否则连接被拒)
assert.equal(c.exp, NOW + IM_GATEWAY_CONNECT_TTL_SEC)
assert.equal(c.iat, NOW)
const s = buildSubscriptionClaims('u_1', 'im:imr_x', NOW)
assert.equal(s.channel, 'im:imr_x')
assert.equal(s.exp, NOW + IM_GATEWAY_SUBSCRIBE_TTL_SEC)
const issued = issueSubscriptionToken('u_1', 'im:imr_x', SECRET, NOW)
assert.equal(issued.scope, 'subscribe')
assert.equal(issued.channel, 'im:imr_x')
assert.equal(issued.exp, NOW + IM_GATEWAY_SUBSCRIBE_TTL_SEC)
assert.equal(issued.ttlSec, IM_GATEWAY_SUBSCRIBE_TTL_SEC)
assert.equal(issued.exp - NOW, issued.ttlSec)
// 🔴 响应形状级判据:**没有**任何字段的名字里带 key / secret
for (const k of Object.keys(issued)) {
assert.doesNotMatch(k.toLowerCase(), /key|secret|token_?secret/)
}
assert.ok(issued.token.length > 0)
// ttl 下限:0 / 负数 ⇒ 至少 1 s(⛔ 不签出"出生即过期"的票据)
assert.equal(buildConnectClaims('u', NOW, 0).exp, NOW + 1)
assert.equal(buildConnectClaims('u', NOW, -5).exp, NOW + 1)
const conn = issueConnectToken('u_1', SECRET, NOW)
assert.equal(conn.scope, 'connect')
assert.equal(conn.channel, undefined)
})
// ── 判据 ③ · 频道名 ↔ 房间 id(严格反解)────────────────────────────────────
test('G-6 · 频道名反解:往返恒等;前缀不符 / 非 imr_ / 含 : / 空 ⇒ 一律 undefined', () => {
const prefix = 'im:'
const roomId = 'imr_5f1c-9a'
const channel = channelFor(roomId, prefix)
assert.equal(channel, 'im:imr_5f1c-9a')
assert.equal(roomIdFromChannel(channel, prefix), roomId)
// 严格拒绝(各给一条反例 ⇒ "猜"这条路被关掉)
assert.equal(roomIdFromChannel('other:imr_5f1c', prefix), undefined) // 前缀不符
assert.equal(roomIdFromChannel('im:notaroom', prefix), undefined) // 不是 imr_ 起头
assert.equal(roomIdFromChannel('im:imr_a:b', prefix), undefined) // 房间 id 里带 `:`(跨命名空间注入)
assert.equal(roomIdFromChannel('im:imr_', prefix), undefined) // 余部为空
assert.equal(roomIdFromChannel('im:', prefix), undefined)
assert.equal(roomIdFromChannel('', prefix), undefined)
assert.equal(roomIdFromChannel('im:imr_a b', prefix), undefined) // 空格
// 默认前缀(未配 env 时)也要能反解
assert.equal(roomIdFromChannel('im-room-imr_x', undefined), 'imr_x')
assert.equal(GATEWAY_ROOM_ID_PREFIX, 'imr_')
})
test('G-7 · 频道前缀单一来源:缺项 / 空串 ⇒ 默认值(⛔ 不返回空串);规范化只发生在 `channelFor` 里', () => {
assert.equal(resolveChannelPrefix({}), 'im-room-')
assert.equal(resolveChannelPrefix({ DSH_IM_GATEWAY_CHANNEL_PREFIX: '' }), 'im-room-')
assert.equal(resolveChannelPrefix({ DSH_IM_GATEWAY_CHANNEL_PREFIX: 'im:' }), 'im:')
// 本函数**只做取值**(⛔ 不悄悄改写运维给的串)—— 规范化是 `channelFor` 的职责,且
// 两处必须同规则,否则"发布进 A 频道、订阅判在 B 频道"(本用例顺手把这条也钉住)。
assert.equal(resolveChannelPrefix({ DSH_IM_GATEWAY_CHANNEL_PREFIX: 'im $:' }), 'im $:')
assert.equal(channelFor('imr_a', 'im $:'), 'im__:imr_a')
assert.equal(roomIdFromChannel('im__:imr_a', 'im $:'), 'imr_a')
})
// ── 判据 ④ · 真客户端件(网关协议规则)──────────────────────────────────────
/**
* 载入**真客户端件**(`poc/im-conversation-tabs/lib/client.js`)。
*
* 做法:给它一个假的 `window.__ModuleLoader__`,把 `load()` 的入参截下来,再用桩 `require`
* 执行 `factory()`。⛔ 不 mock 任何被测函数 —— 拿到的是**生产那份代码的同一份实现**。
*/
function loadClientBundle() {
const src = readSrc('poc/im-conversation-tabs/lib/client.js')
let captured = null
const reactStub = new Proxy(
{},
{
get: () => () => undefined,
},
)
const requireStub = (name) => {
if (name === 'react' || name === 'react/jsx-runtime') return reactStub
if (name === 'react-dom') return { createPortal: () => null }
throw new Error('unexpected require in bundle: ' + name)
}
const fakeWindow = {
__ModuleLoader__: { load: (def) => { captured = def } },
console: { warn: () => undefined, log: () => undefined },
location: { protocol: 'https:', host: 'example.test' },
}
// eslint-disable-next-line no-new-func
const run = new Function('window', 'document', 'require', src)
run(fakeWindow, { querySelector: () => null, head: { appendChild: () => undefined } }, requireStub)
assert.notEqual(captured, null, '未捕获到 __ModuleLoader__.load 的注册对象')
const mod = captured.factory(requireStub)
assert.ok(mod.imTransport !== undefined, '客户端件必须导出 imTransport(判据 ③ 的独立断言面)')
return mod
}
test('G-8 · 客户端件:「空对象即回」——回的是**空命令**;非空对象**不回**(独立断言,⛔ 不靠端到端)', () => {
const { imTransport: T } = loadClientBundle()
// ① 只有"零键对象"才算协议级 ping
assert.equal(T.isGatewayPing({}), true)
assert.equal(JSON.parse(T.GATEWAY_PONG) !== null && Object.keys(JSON.parse(T.GATEWAY_PONG)).length, 0)
assert.equal(T.GATEWAY_PONG, '{}')
// ② 非空对象一律**不是** ping ⇒ 客户端不会误回(乱回会被判 3501 bad request)
assert.equal(T.isGatewayPing({ id: 0 }), false)
assert.equal(T.isGatewayPing({ push: {} }), false)
assert.equal(T.isGatewayPing({ connect: {} }), false)
// ③ 形态边界:数组 / null / 字符串 / 数字 都不是
assert.equal(T.isGatewayPing([]), false)
assert.equal(T.isGatewayPing(null), false)
assert.equal(T.isGatewayPing('{}'), false)
assert.equal(T.isGatewayPing(0), false)
// ④ 原型链上的键不算(`{}` 的 toString 之类不得把它判成"非空")
assert.equal(T.isGatewayPing(Object.create({ inherited: 1 })), true)
})
test('G-9 · 客户端件:出站命令形状(id 从 1 起、方法名即键)+ 推送取回"原帧"逐字相同', () => {
const { imTransport: T } = loadClientBundle()
assert.equal(T.GATEWAY_FIRST_ID, 1)
assert.deepEqual(T.gatewayCommand(1, 'connect', { token: 'tk' }), { id: 1, connect: { token: 'tk' } })
assert.deepEqual(T.gatewayCommand(2, 'subscribe', { channel: 'im:imr_x' }), {
id: 2,
subscribe: { channel: 'im:imr_x' },
})
// 平台发出来的那一串(含 `op` 制信封)原样装在 `pub.data.frame` 里 ⇒ 取出来必须**逐字相同**
const original = '{"op":"message","roomId":"imr_x","messages":[{"id":"m1","seq":7}]}'
const push = { push: { channel: 'im:imr_x', pub: { data: { frame: original }, offset: 7 } } }
assert.equal(T.appFrameOfPush(push), original)
assert.equal(JSON.parse(T.appFrameOfPush(push)).op, 'message')
// 不是 pub 推送 ⇒ null(join / leave 交给平台产 presence 帧,客户端不处理)
assert.equal(T.appFrameOfPush({ push: { channel: 'im:imr_x', join: 'c1' } }), null)
assert.equal(T.appFrameOfPush({ push: { channel: 'im:imr_x', leave: 'c1' } }), null)
assert.equal(T.appFrameOfPush({}), null)
assert.equal(T.appFrameOfPush({ push: { pub: { data: {} } } }), null)
assert.equal(T.appFrameOfPush(null), null)
// 断开建议读得出来(用户要看得见原因)
assert.deepEqual(T.disconnectInfoOf({ disconnect: { code: 3012, reason: 'no pong' } }), {
code: 3012,
reason: 'no pong',
})
assert.equal(T.disconnectInfoOf({}), null)
// 两套协议的 op 白名单仍然只在 native 侧生效(gateway 的帧⛔ 不走白名单)
assert.deepEqual(T.WS_OPS_OUT, ['subscribe', 'resume', 'ping'])
assert.ok(T.WS_OPS_OUT.indexOf('pong') < 0, '`pong` ⛔ 不是合法出站 op(ws.ts 实现里没有该 case)')
})
// ── 判据 ⑤ · 源码级结构性判据(本机可查、无需真环境)────────────────────────
test('G-10 · 平台侧 fail-closed 分支齐备:非 gateway ⇒ 403 / 无密钥 ⇒ 403 / 非成员 ⇒ 403(票据面)', () => {
const src = readSrc('src/web/routes/im.ts')
assert.match(src, /app\.post\('\/api\/im\/gateway\/token'/)
assert.match(src, /reply\.code\(403\)\.send\(\{ error: 'gateway-not-enabled'/)
assert.match(src, /reply\.code\(403\)\.send\(\{ error: 'gateway-key-missing'/)
assert.match(src, /reply\.code\(403\)\.send\(\{ error: 'not-a-member'/)
// 🔴 成员表是**唯一**判据:签发订阅票据之前必须真查一次
assert.match(src, /await store\.isMember\(roomId, user\.id\)/)
// 🔴 票据用**共享密钥**签;密钥只从 env 来,⛔ 不出现在响应体
assert.match(src, /issueSubscriptionToken\(user\.id, channelFor\(roomId, channelPrefix\), gatewayKey, nowSec\)/)
assert.doesNotMatch(src, /gatewayKey\s*[,)]\s*\}\s*\)\s*$/m)
})
test('G-11 · 订阅回检面:与在线态回调面**同一套密钥与常量时间比对**,⛔ 不另造第二套', () => {
const src = readSrc('src/web/routes/im.ts')
const route = new RegExp(
'app\\.post\\(IM_GATEWAY_SUBSCRIBE_CALLBACK_PATH[\\s\\S]{0,2600}?return \\{ result: \\{\\} \\}',
)
const m = route.exec(src)
assert.notEqual(m, null, '未找到订阅回检路由体')
const body = m[0]
assert.match(body, /selection\.kind !== 'gateway' \|\| gatewayKey === ''/)
assert.match(body, /reply\.code\(404\)\.send\(\{ error: 'not-found' \}\)/) // fail-closed 不暴露
assert.match(body, /secretEquals\(supplied, gatewayKey\)/)
assert.match(body, /reply\.code\(401\)\.send\(\{ error: 'bad-gateway-key' \}\)/)
assert.match(body, /roomIdFromChannel\(channel, channelPrefix\)/)
assert.match(body, /store\.isMember\(roomId, userId\)/)
// 拒绝形状遵守回检契约(code 落 400–1999,且显式 terminal)
assert.match(body, /code: 403, message: reason, temporary: false/)
// ⛔ 回调面里不得出现任何把密钥回给调用方的写法
assert.doesNotMatch(body, /gatewayKey\s*\}\s*\)/)
assert.equal(IM_GATEWAY_SUBSCRIBE_CALLBACK_PATH, '/api/im/gateway/subscribe')
})
test('G-12 · 读数面不含凭据:stats 的准入计数只有整数与具名原因(⛔ 无 token / key 字段)', () => {
const src = readSrc('src/web/routes/im.ts')
const block = /const gatewayAccess = \{([\s\S]*?)\n \}/.exec(src)
assert.notEqual(block, null, '未找到 gatewayAccess 计数器')
const fields = block[1]
assert.match(fields, /connectIssued/)
assert.match(fields, /subscribeIssued/)
assert.match(fields, /subscribeAllowed/)
assert.match(fields, /rejected/)
assert.match(fields, /lastReject/)
for (const line of fields.split('\n')) {
const name = /^\s*([A-Za-z]+):/.exec(line)
if (name === null) continue
assert.doesNotMatch(name[1].toLowerCase(), /token|key|secret/)
}
// 响应体里只 spread 一份拷贝(⛔ 不外泄内部对象)
assert.match(src, /gatewayAccess: \{ \.\.\.gatewayAccess \}/)
})
test('G-13 · 传输口径面:只回协议与路径 + 保活要求(⛔ 无端点地址、无凭据、无票据)', () => {
const src = readSrc('src/web/routes/im.ts')
const m = /app\.get\('\/api\/im\/transport'[\s\S]{0,2000}?\n \}\)/.exec(src)
assert.notEqual(m, null, '未找到传输口径路由体')
const body = m[0]
assert.match(body, /backend: 'native' as const/)
assert.match(body, /backend: 'gateway' as const/)
assert.match(body, /pongFrame: '\{\}'/)
assert.match(body, /tokenPath: '\/api\/im\/gateway\/token'/)
assert.match(body, /keepalive: planKeepalive\(\)/)
assert.match(body, /keepaliveAnchor: 'per-connection-auth-complete' as const/)
// ⛔ 不下发端点地址 / 密钥 / 票据
assert.doesNotMatch(body, /DSH_IM_GATEWAY_URL|endpoint|gatewayKey/)
})
test('G-14 · 客户端件源码级:网关分支必须"空对象即回"+ ⛔ 不出未认 op', () => {
const src = readSrc('poc/im-conversation-tabs/lib/client.js')
// ① 回帧落点存在且**就在** isGatewayPing 分支里
const m = /if \(isGatewayPing\(msg\)\) \{([\s\S]{0,400}?)\n \}/.exec(src)
assert.notEqual(m, null, '未找到空对象帧分支')
assert.match(m[1], /sendRaw\(ws, GATEWAY_PONG\)/)
// ② 网关模式不发应用层 ping(外部层会发协议级 ping)
assert.match(src, /if \(gatewayMode\) return/)
// ③ 模式来自平台下发(⛔ 不硬编码)+ 取不到时**不**默认退回 native
assert.match(src, /apiFetch\('\/transport'\)/)
assert.match(src, /const gatewayMode = cfg\.protocol === PROTOCOL_GATEWAY/)
// ④ 应用层帧两种模式共用同一段分发(换连接层不动协议)
assert.match(src, /onAppFrame\(msg\)/)
assert.match(src, /onAppFrame\(app\)/)
// ⑤ ⛔ 出站白名单里不得出现 `pong`(那是服务端回帧;发出去 = bad-op + destroy)
const ops = /const WS_OPS_OUT = \[([^\]]*)\]/.exec(src)
assert.notEqual(ops, null)
assert.doesNotMatch(ops[1], /pong/)
})
test('G-15 · 两处房间 id 前缀字面必须一致(签发 / 发布 / 反解三面共用同一命名空间)', () => {
const routes = readSrc('src/web/routes/im.ts')
const m = /const ROOM_ID_PREFIX = '([^']+)'/.exec(routes)
assert.notEqual(m, null, '未找到 routes 的 ROOM_ID_PREFIX')
assert.equal(m[1], GATEWAY_ROOM_ID_PREFIX)
})
+277
View File
@@ -0,0 +1,277 @@
// IM **在线态取数接线**回归测试(node:test)—— IM 线 · 第 15 棒。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// ## 覆盖策略
// ① **具名取值判据**:`'local-hub'` / `'gateway-join-leave'` / `'not-wired'` 三个取值
// 互不相同,且"没接"这件事**必须**仍然可读出来(⛔ 不许把哨兵改掉)。
// ② **接线判据**:注入端口 ⇒ `stats().presenceIngest` 具名;未注入 ⇒ 仍是哨兵。
// ③ **落到内核判据**(最重要):事件必须走进 `hub.notePresence()` ⇒ 批合并 / 收敛 /
// 帧文本 / "只推订阅该房的连接"**全部与 native 逐字相同**(本模块不自己造帧)。
// ④ **不信任外部输入**:`apply()` 丢弃并计数(不抛);`parsePresenceEvents()` 整批拒收
// (fail-closed,具名原因)。
// ⑤ **回调面结构性判据**(源码级):fail-closed 的 404 分支、常量时间比对、key 不进读数。
//
// ⚠️ 本文件**不碰网络**、**不碰 DB**(hub 用桩 store,连接用 `syntheticConnection`)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { ImHub } from '../lib/im/hub.js'
import {
PRESENCE_EVENT_KINDS,
PRESENCE_INGEST_GATEWAY,
PRESENCE_INGEST_LOCAL,
PRESENCE_INGEST_NOT_WIRED,
createHubPresenceIngest,
parsePresenceEvents,
secretEquals,
} from '../lib/im/presence-ingest.js'
import {
GATEWAY_PRESENCE_INGEST,
GATEWAY_PRESENCE_INGEST_WIRED,
createGatewayConnectionBackend,
} from '../lib/im/backends/gateway.js'
import { createNativeConnectionBackend } from '../lib/im/backends/native.js'
import { syntheticConnection } from '../lib/im/ws.js'
const ROOT = process.cwd()
const readSrc = (rel) => readFileSync(join(ROOT, rel), 'utf8')
/** 最小 store 桩(本文件不碰 send / resume 路径)。 */
const storeStub = () => ({})
/** runtime 桩(native / gateway 后端只转发它的三个方法)。 */
const runtimeStub = (over = {}) => ({
dispatch: () => false,
connectionCount: () => 0,
close: () => undefined,
...over,
})
/**
* 批窗口闸门打开的 hub:`now: () => 0` + `presenceBatchMs: 0` —— ⛔ 不依赖真实时间
* (否则 `lastFlushAt` 取真实 `Date.now` ⇒ 首帧被闸门吃掉,判据会**假绿**;第 12 棒踩过)。
*/
const hubOpenBatch = () => new ImHub(storeStub(), { now: () => 0, presenceBatchMs: 0 })
// ── 判据 ① · 具名取值 ─────────────────────────────────────────────────────────
test('P-1 · 取数路径三个取值互不相同;哨兵仍是 `not-wired`(⛔ 不许把"没接"伪装成"接了")', () => {
const all = [PRESENCE_INGEST_NOT_WIRED, PRESENCE_INGEST_LOCAL, PRESENCE_INGEST_GATEWAY]
assert.equal(new Set(all).size, 3)
assert.equal(PRESENCE_INGEST_NOT_WIRED, 'not-wired')
assert.equal(PRESENCE_INGEST_LOCAL, 'local-hub')
assert.equal(PRESENCE_INGEST_GATEWAY, 'gateway-join-leave')
// gateway 模块的导出与内核常量**同源**(⛔ 不是两处各写一遍字面量)
assert.equal(GATEWAY_PRESENCE_INGEST, PRESENCE_INGEST_NOT_WIRED)
assert.equal(GATEWAY_PRESENCE_INGEST_WIRED, PRESENCE_INGEST_GATEWAY)
assert.deepEqual(PRESENCE_EVENT_KINDS, ['join', 'leave', 'snapshot'])
})
// ── 判据 ② · 接线与否在 stats 里自曝 ──────────────────────────────────────────
test('P-2 · 未注入取数端口 ⇒ gateway 后端自曝 `not-wired`,且不挂 `presenceIngest` 字段', () => {
const backend = createGatewayConnectionBackend({ endpoint: 'http://127.0.0.1:1', key: 'k', runtime: runtimeStub() })
assert.equal(backend.stats().presenceIngest, PRESENCE_INGEST_NOT_WIRED)
assert.equal(backend.presenceIngest, undefined)
})
test('P-3 · 注入取数端口 ⇒ `stats().presenceIngest` 具名,且后端把同一端口挂出来', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub, { source: PRESENCE_INGEST_GATEWAY })
const backend = createGatewayConnectionBackend({
endpoint: 'http://127.0.0.1:1',
key: 'k',
runtime: runtimeStub(),
presence: port,
})
assert.equal(backend.stats().presenceIngest, PRESENCE_INGEST_GATEWAY)
assert.equal(backend.presenceIngest, port)
assert.equal(backend.presenceIngest.source, PRESENCE_INGEST_GATEWAY)
})
test('P-9 · native 后端如实报 `local-hub`(在线态权威就在本进程 ⇒ ⛔ 不再是 not-wired)', () => {
const hub = hubOpenBatch()
const backend = createNativeConnectionBackend({ hub, runtime: runtimeStub() })
assert.equal(backend.stats().presenceIngest, PRESENCE_INGEST_LOCAL)
})
// ── 判据 ③ · 事件真的落进内核(两条读数对照)──────────────────────────────────
test('P-4 · 两条读数对照:取数前该成员不在快照里 ⇒ 取数后为 `online`', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
// 读数 1(取数前):快照里没有任何人,在线聚合数 = 0
const before = port.snapshot('room-1')
assert.deepEqual(before, {})
assert.equal(port.onlineCount('room-1'), 0)
assert.equal(port.ledgerOnline(), 0)
// 喂两条 join(不同成员,同一房)
const r = port.apply([
{ roomId: 'room-1', memberId: 'u1', kind: 'join' },
{ roomId: 'room-1', memberId: 'u2', kind: 'join' },
])
assert.deepEqual(r, { accepted: 2, ignored: 0, rooms: 1, reason: '' })
// 读数 2(取数后)
const after = port.snapshot('room-1')
assert.deepEqual(after, { u1: 'online', u2: 'online' })
assert.equal(port.onlineCount('room-1'), 2)
assert.equal(port.ledgerOnline(), 2)
assert.notDeepEqual(before, after)
})
test('P-5 · `leave` 才是离线;`snapshot` **只做加法**(⛔ 不把"缺席"当"离开")', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
port.apply([{ roomId: 'room-1', memberId: 'u1', kind: 'join' }])
port.apply([{ roomId: 'room-1', memberId: 'u2', kind: 'snapshot' }])
assert.deepEqual(port.snapshot('room-1'), { u1: 'online', u2: 'online' })
// 显式 leave ⇒ 从**在线快照**里消失(账本记 offline,但快照只回在线 —— 与 hub 口径一致)
port.apply([{ roomId: 'room-1', memberId: 'u1', kind: 'leave' }])
assert.deepEqual(port.snapshot('room-1'), { u2: 'online' })
assert.equal(port.onlineCount('room-1'), 1)
// ⚠️ 快照只含 u2 ⇒ u1 **不因缺席被改写**(谁在线由显式 leave / join 决定)
const r = port.apply([{ roomId: 'room-1', memberId: 'u2', kind: 'snapshot' }])
assert.equal(r.accepted, 1)
assert.deepEqual(port.snapshot('room-1'), { u2: 'online' })
})
test('P-6 · 批合并 + 帧文本:一个窗口内同成员多次变更 ⇒ 每房每连接**恰 1 帧**,且只推订阅者', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
const got = []
const sub = { ...syntheticConnection('c1', 'u1'), send: (t) => (got.push(t), true) }
const other = { ...syntheticConnection('c2', 'u2'), send: () => true }
hub.attach(sub)
hub.attach(other)
hub.subscribe(sub, 'room-1', false) // ⛔ 订阅本身不记 online(否则成员数组会多一条)
// 同一批里 u2 三次变更(join → leave → join)⇒ 收敛成最后一次
const r = port.apply([
{ roomId: 'room-1', memberId: 'u2', kind: 'join' },
{ roomId: 'room-1', memberId: 'u2', kind: 'leave' },
{ roomId: 'room-1', memberId: 'u2', kind: 'join' },
])
assert.equal(r.accepted, 3)
const frames = hub.flushPresence()
assert.equal(frames, 1, '一个窗口 ⇒ 每房每连接恰 1 帧')
assert.equal(got.length, 1)
assert.deepEqual(JSON.parse(got[0]), {
op: 'presence',
roomId: 'room-1',
at: 0,
members: [{ memberId: 'u2', state: 'online', at: 0 }],
})
// ⛔ 没订阅该房的连接一帧都收不到
assert.equal(got.length, 1)
})
test('P-6b · 无人订阅该房 ⇒ 事件照收(进快照),但**不产帧**', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub)
port.apply([{ roomId: 'room-silent', memberId: 'u9', kind: 'join' }])
assert.equal(hub.flushPresence(), 0)
assert.deepEqual(port.snapshot('room-silent'), { u9: 'online' })
})
test('P-6c · native 口径(`ownsPresence=false`):快照 / 在线人数**直通 hub**,账本不启用', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub, { source: PRESENCE_INGEST_LOCAL })
assert.equal(port.ownsPresence, false)
// hub 里有一条真连接并订阅该房 ⇒ 快照能看到它
const conn = { ...syntheticConnection('c1', 'u1'), send: () => true }
hub.attach(conn)
hub.subscribe(conn, 'room-1', false)
assert.deepEqual(port.snapshot('room-1'), { u1: 'online' })
assert.equal(port.onlineCount('room-1'), 1)
// ⛔ 喂事件**不会**写进账本(native 的权威是 hub 自己)
port.apply([{ roomId: 'room-1', memberId: 'u9', kind: 'join' }])
assert.deepEqual(port.snapshot('room-1'), { u1: 'online' })
assert.equal(port.onlineCount('room-1'), 1)
assert.equal(port.ledgerOnline(), hub.stats().connections)
})
// ── 判据 ④ · 不信任外部输入 ───────────────────────────────────────────────────
test('P-7 · `apply()` 对畸形/超量**丢弃并计数**,⛔ 不抛(一条坏事件不得掀掉整批)', () => {
const hub = hubOpenBatch()
const port = createHubPresenceIngest(hub, { maxEvents: 2 })
const r = port.apply([
{ roomId: 'room-1', memberId: 'u1', kind: 'join' }, // 处理 1
{ roomId: '', memberId: 'u2', kind: 'join' }, // 处理 2,畸形 ⇒ 丢
{ roomId: 'room-1', memberId: 'u3', kind: 'join' }, // 超量 ⇒ 丢(上限 = 已处理条数)
])
assert.equal(r.accepted, 1)
assert.equal(r.ignored, 2)
assert.equal(r.reason, 'too-many-events')
assert.deepEqual(port.snapshot('room-1'), { u1: 'online' })
})
test('P-8 · `parsePresenceEvents()` **整批拒收**(fail-closed)+ 具名原因', () => {
assert.deepEqual(parsePresenceEvents({ events: [] }), { events: [] })
assert.deepEqual(
parsePresenceEvents({ events: [{ roomId: 'r', memberId: 'u', kind: 'join' }] }),
{ events: [{ roomId: 'r', memberId: 'u', kind: 'join' }] },
)
assert.deepEqual(parsePresenceEvents(null), { error: 'bad-body' })
assert.deepEqual(parsePresenceEvents('{"events":[]}'), { error: 'bad-body' })
assert.deepEqual(parsePresenceEvents({}), { error: 'bad-events' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: 'r', memberId: 'u' }] }), { error: 'bad-kind' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: '', memberId: 'u', kind: 'join' }] }), { error: 'bad-room-id' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: 'r', memberId: '', kind: 'join' }] }), { error: 'bad-member-id' })
assert.deepEqual(parsePresenceEvents({ events: [{ roomId: 'r', memberId: 'u', kind: 'pong' }] }), { error: 'bad-kind' })
assert.deepEqual(parsePresenceEvents({ events: ['x'] }), { error: 'bad-event' })
assert.deepEqual(parsePresenceEvents({ events: [[]] }), { error: 'bad-event' })
assert.deepEqual(parsePresenceEvents([]), { error: 'bad-body' })
assert.deepEqual(
parsePresenceEvents({ events: new Array(3).fill({ roomId: 'r', memberId: 'u', kind: 'join' }) }, 2),
{ error: 'too-many-events' },
)
})
test('P-11 · `secretEquals()`:长度不等先拒、逐字符比较、空期望值恒拒', () => {
assert.equal(secretEquals('abc', 'abc'), true)
assert.equal(secretEquals('abc', 'abd'), false)
assert.equal(secretEquals('abc', 'abcd'), false)
assert.equal(secretEquals('', ''), false, '期望值为空 ⇒ 恒拒(⛔ 不许"空对空"放行)')
assert.equal(secretEquals('', 'x'), false)
})
// ── 判据 ⑤ · 回调面的结构性判据(源码级,⛔ 不靠"读代码猜")──────────────────
test('P-10 · 回调面:非 gateway 模式 / 未配 key ⇒ 恒 404;鉴别走常量时间比对;key 不进读数', () => {
const src = readSrc('src/web/routes/im.ts')
// ① fail-closed 的 404 分支存在,且判据同时含"模式"与"key 非空"
assert.match(src, /app\.post\('\/api\/im\/gateway\/presence'/)
assert.match(src, /if \(selection\.kind !== 'gateway' \|\| gatewayKey === ''\)/)
assert.match(src, /reply\.code\(404\)\.send\(\{ error: 'not-found' \}\)/)
// ② 鉴别用 secretEquals(⛔ 不是 ===)
assert.match(src, /secretEquals\(supplied, gatewayKey\)/)
assert.match(src, /x-dsh-im-gateway-key/)
// ③ key 只从 env 读,且不出现在 stats 的返回里
assert.match(src, /const gatewayKey = process\.env\[IM_GATEWAY_KEY_ENV\] \?\? ''/)
const statsBlock = src.slice(src.indexOf("app.get('/api/im/stats'"))
assert.ok(!statsBlock.includes('gatewayKey'), 'stats 读数里 ⛔ 不得出现 key')
assert.match(src, /presenceIngest: presenceIngest\.source/)
assert.match(src, /presenceOnline: presenceIngest\.ledgerOnline\(\)/)
// ⑤ 读"谁在线"的两处也走端口(⛔ 否则 gateway 模式下恒空 / 恒 0,读数会骗人)
assert.match(src, /presence: presenceIngest\.snapshot\(ctx\.roomId\)/)
assert.match(src, /online: presenceIngest\.onlineCount\(ctx\.roomId\)/)
// ④ 事件落点是 hub.notePresence(⛔ 内核不新增第二套在线态)
const ingestSrc = readSrc('src/im/presence-ingest.ts')
assert.match(ingestSrc, /hub\.notePresence\(event\.roomId, event\.memberId, state\)/)
assert.ok(!ingestSrc.includes('pendingPresence'), '⛔ 不得碰 hub 的内部结构')
})
+1019
View File
File diff suppressed because it is too large. Load diff
+571
View File
@@ -0,0 +1,571 @@
// IM 房间内核回归测试(node:test)—— A 单 `交接单/IM群组-A-房间内核与DB.md` §六 + §六.1。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// 覆盖面:迁移 v14 / 幂等 · 内容哈希去重 · 乱序收敛 · 游标补拉 · 权限 · 写放大 ·
// 归属列 NOT NULL · 区字段预留 · 无 OFFSET(走索引)· 预算降级 · 慢查询标记 ·
// `?`→`$n` 翻译。
// ⚠️ Postgres 段自跳过:未设 `DSHS_TEST_DB_URL` 即 skip(与 `test/db.test.mjs` 同约定)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { openImSqlite, openImPg, toPgPlaceholders } from '../lib/im/db.js'
import { ImStore, MAX_LIST_LIMIT, SLOW_QUERY_MARKER } from '../lib/im/store.js'
import { RoomClock, mergeMessages, orderDrafts, orderMessages } from '../lib/im/clock.js'
import { canonicalJson, messageId } from '../lib/im/types.js'
const PG_URL = process.env.DSHS_TEST_DB_URL
/** 打开一个内存库 + 内核,并在用例结束时关库。 */
function freshStore(t, options = {}) {
const db = openImSqlite(':memory:')
t.after(() => db.close())
return { db, store: new ImStore(db, options) }
}
/** 计数端口:记录每次 `INSERT`(判据 6 / 15 的写放大断言靠它)。 */
function countingDb(db) {
const counts = { inserts: 0, messageInserts: 0, calls: [] }
return {
counts,
db: {
dialect: db.dialect,
all: (sql, params) => {
counts.calls.push(sql.trim().split(/\s+/).slice(0, 3).join(' '))
return db.all(sql, params)
},
run: (sql, params) => {
const upper = sql.trim().toUpperCase()
counts.calls.push(upper.split(/\s+/).slice(0, 3).join(' '))
if (upper.startsWith('INSERT')) {
counts.inserts += 1
if (/INSERT\s+INTO\s+MESSAGES/.test(upper)) counts.messageInserts += 1
}
return db.run(sql, params)
},
exec: (sql) => db.exec(sql),
close: () => db.close(),
},
}
}
/** 建一个房里塞 n 个成员的脚手架。 */
async function makeRoom(store, roomId, ownerId = 'u-owner') {
await store.upsertRoom({ id: roomId, roomType: 'group', name: roomId, ownerId })
await store.addMember({ roomId, memberId: ownerId, kind: 'human', role: 'owner' })
return roomId
}
// ── 判据 1 / 19:迁移 v14 落地 + 幂等 ────────────────────────────────────────
//
// ⚠️ 计数断言从「= 14」改成「≥ 14 且**含** v14」:后续单子允许加迁移
//(v15 = 插件数据面台账),而本条的**本意**是「v14 落地了 + 既有迁移没被改」。
// ⛔ 不要把下界改成等号(那会把后续合法迁移卡红);也⛔不要删掉 v14 的存在性断言。
test('判据1/19 迁移:新库跑到 v14;同一文件重开版本不变(幂等)', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'im-v14-'))
const path = join(dir, 'control.db')
t.after(() => rmSync(dir, { recursive: true, force: true }))
const first = openImSqlite(path)
const rowsFirst = await first.all('SELECT version FROM schema_migrations ORDER BY version')
await first.close()
assert.ok(rowsFirst.length >= 14, `至少 14 条迁移记录(实际 ${rowsFirst.length})`)
assert.equal(
rowsFirst.some((r) => Number(r.version) === 14),
true,
'v14(A 单三张表)必须已应用',
)
assert.equal(Number(rowsFirst.at(0).version), 1, '首条仍 = v1(⛔ 未改既有迁移)')
// 迁移号必须**连续无洞**(新增只许追加,⛔ 不许跳号/插号)。
const versions = rowsFirst.map((r) => Number(r.version))
assert.deepEqual(
versions,
Array.from({ length: versions.length }, (_, i) => i + 1),
'迁移号连续(1..N,无跳号)',
)
const second = openImSqlite(path)
const rowsSecond = await second.all('SELECT version FROM schema_migrations')
const tables = await second.all("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
await second.close()
// 幂等的**本意**是"重开不再登记一遍" ⇒ 判据用**首开 vs 重开的计数相等**,
// ⛔ 不要写死数字(写死会把后续合法迁移卡红,且完全没验到幂等性)。
assert.equal(rowsSecond.length, rowsFirst.length, '重开不重复登记(幂等)')
for (const name of ['rooms', 'room_members', 'messages']) {
assert.ok(tables.some((r) => r.name === name), `表 ${name} 存在`)
}
})
test('判据1 三表已建但⛔ 无插件前缀(内核表用保留名)', async (t) => {
const { db } = freshStore(t)
const rows = await db.all("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
const names = rows.map((r) => r.name)
assert.ok(names.includes('rooms') && names.includes('room_members') && names.includes('messages'))
assert.equal(names.filter((n) => n.startsWith('p_')).length, 0, '⛔ 无 p_<pluginId>_ 前缀表')
})
// ── 判据 2 / 9:内容哈希去重 ────────────────────────────────────────────────
test('判据2/9 去重:同房同作者同 payload 重投 3 次 ⇒ messages 只 1 行,id 稳定', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
const payload = { text: 'hello', n: 1 }
const a = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
const b = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
const c = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
assert.equal(a.inserted, true, '首次 = 真插入')
assert.equal(b.inserted, false, '第二次 = 命中去重')
assert.equal(c.inserted, false, '第三次 = 命中去重')
assert.equal(await store.countMessages('r1'), 1, '恰好 1 行(三表新增 1 行)')
assert.equal(a.message.id, b.message.id, 'id 稳定')
assert.equal(a.message.id, c.message.id, 'id 稳定')
assert.equal(a.message.id, messageId(payload, 'u-owner'), 'id = 内容哈希')
assert.equal(a.message.id.length, 64, 'sha256 hex')
assert.equal(a.message.seq, b.message.seq, '命中去重返回既有 seq')
})
test('判据2 自决项:不同作者同 payload ⇒ 各自成行(⛔ 不互相吞)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.addMember({ roomId: 'r1', memberId: 'bot-1', kind: 'bot' })
const payload = { text: '收到' }
const h = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload })
const b = await store.appendMessage({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', payload })
assert.notEqual(h.message.id, b.message.id, 'id 含作者 ⇒ 不同作者不同 id')
assert.equal(await store.countMessages('r1'), 2, '两条各自的同文消息都留下')
})
test('判据2 id 与键序无关:payload 键序不同 ⇒ 同 id', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
const one = { a: 1, b: { d: 4, c: 3 } }
const two = { b: { c: 3, d: 4 }, a: 1 }
assert.equal(canonicalJson(one), canonicalJson(two), 'canonicalJson 递归排序键')
const first = await store.appendMessage({ roomId: 'r1', authorId: 'u', authorKind: 'human', payload: one })
const second = await store.appendMessage({ roomId: 'r1', authorId: 'u', authorKind: 'human', payload: two })
assert.equal(first.message.id, second.message.id)
assert.equal(second.inserted, false, '键序不同但内容相同 ⇒ 仍去重')
})
// ── 判据 3 / 10:乱序收敛到同一顺序 ───────────────────────────────────────
test('判据3/10 收敛:同一草稿集合的 3 种到达序 ⇒ orderDrafts 输出同一个数组', async () => {
const drafts = [
{ id: 'm1', roomId: 'r1', ts: 1000, authorId: 'a', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 1 }, lamport: 3 },
{ id: 'm2', roomId: 'r1', ts: 1200, authorId: 'b', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 2 }, lamport: 1 },
{ id: 'm3', roomId: 'r1', ts: 1100, authorId: 'a', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 3 }, lamport: 2 },
{ id: 'm4', roomId: 'r1', ts: 900, authorId: 'b', authorKind: 'human', via: 'web', visibility: 'room', payload: { t: 4 }, lamport: 4 },
]
const orders = [
drafts,
[...drafts].reverse(),
[drafts[2], drafts[0], drafts[3], drafts[1]],
]
const out = orders.map((o) => orderDrafts(o).map((d) => d.id))
assert.deepEqual(out[0], ['m2', 'm3', 'm1', 'm4'], '规范序 = lamport 升序')
assert.deepEqual(out[1], out[0], '逆序到达 ⇒ 同一个数组')
assert.deepEqual(out[2], out[0], '第三种到达序 ⇒ 同一个数组')
})
test('判据3 收敛(落库往返):同一批消息以 2 种到达序落库 ⇒ 规范序同一个数组', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'rA')
await makeRoom(store, 'rB')
const lamportOf = new Map()
const batch = []
for (let i = 1; i <= 6; i += 1) {
const payload = { t: i }
const id = messageId(payload, i % 2 === 0 ? 'w-a' : 'w-b')
lamportOf.set(id, i)
batch.push({ payload, authorId: i % 2 === 0 ? 'w-a' : 'w-b' })
}
await store.addMember({ roomId: 'rA', memberId: 'w-a' })
await store.addMember({ roomId: 'rB', memberId: 'w-a' })
await store.addMember({ roomId: 'rA', memberId: 'w-b' })
await store.addMember({ roomId: 'rB', memberId: 'w-b' })
const write = async (roomId, order) => {
for (const i of order) {
const m = batch[i]
await store.appendMessage({ roomId, authorId: m.authorId, authorKind: 'human', payload: m.payload })
}
const rows = await store.listMessages(roomId, 0, 100)
// 落库后的行没有 lamport 列 ⇒ 用写入前的映射还原草稿,再喂给规范序函数。
return orderDrafts(
rows.map((r) => ({
id: r.id, roomId: r.roomId, ts: r.ts, authorId: r.authorId, authorKind: r.authorKind,
via: r.via, visibility: r.visibility, payload: r.payload, lamport: lamportOf.get(r.id),
})),
).map((d) => d.id)
}
const forward = await write('rA', [0, 1, 2, 3, 4, 5])
const backward = await write('rB', [5, 4, 3, 2, 1, 0])
assert.deepEqual(backward, forward, '两种到达序 ⇒ 规范化后同一个数组')
// 到达序只改变 seq 的**分配**,不改变集合与单调性(§四-6 既定设计)。
const seqA = (await store.listMessages('rA', 0, 100)).map((m) => m.seq)
const seqB = (await store.listMessages('rB', 0, 100)).map((m) => m.seq)
assert.deepEqual(seqA, [1, 2, 3, 4, 5, 6], '恒为紧凑 1..N')
assert.deepEqual(seqB, [1, 2, 3, 4, 5, 6], '另一到达序同样紧凑')
const idsA = new Set((await store.listMessages('rA', 0, 100)).map((m) => m.id))
const idsB = new Set((await store.listMessages('rB', 0, 100)).map((m) => m.id))
assert.deepEqual([...idsB].sort(), [...idsA].sort(), 'id 集合相等')
})
test('判据3 listMessages 恒按 seq 严格递增(乱序投递后)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.addMember({ roomId: 'r1', memberId: 'w2' })
const clock = new RoomClock()
const payloads = [3, 1, 4, 1, 5, 9, 2, 6]
for (const n of payloads) {
const lamport = clock.issue('r1')
await store.appendMessage({
roomId: 'r1', authorId: n % 2 === 0 ? 'u-owner' : 'w2', authorKind: 'human',
payload: { n, seen: `${payloads.join(',')}` }, lamport,
})
}
const list = await store.listMessages('r1', 0, 100)
for (let i = 1; i < list.length; i += 1) {
assert.ok(list[i].seq > list[i - 1].seq, 'seq 严格递增')
}
assert.deepEqual(orderMessages(list).map((m) => m.id), list.map((m) => m.id), '已是权威序')
})
// ── 判据 4 / 11 / 14:游标补拉,不多不少 ────────────────────────────────────
test('判据4/11/14 补拉:断线期写 100 条 ⇒ 带 since=K 恰好补 100−K 条,无重复', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
for (let i = 1; i <= 100; i += 1) {
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i } })
}
assert.equal(await store.latestSeq('r1'), 100, '100 条 ⇒ 最大 seq = 100')
const K = 40
const backlog = await store.listMessages('r1', K, 500)
assert.equal(backlog.length, 100 - K, '恰好 100−K 条')
assert.equal(backlog[0].seq, K + 1, '首条 = K+1')
assert.equal(backlog.at(-1).seq, 100, '末条 = 100')
const again = await store.listMessages('r1', K, 500)
assert.deepEqual(again.map((m) => m.id), backlog.map((m) => m.id), '重复拉取幂等(无重复无遗漏)')
assert.equal(new Set(backlog.map((m) => m.id)).size, backlog.length, '拉回的 id 互不重复')
// 与"实时推送"合并 ⇒ 按 id 去重后条数相等(离线补齐判据)。
const realtime = await store.listMessages('r1', 95, 500)
const merged = mergeMessages(backlog, realtime)
assert.equal(merged.length, 100 - K, '补拉 + 实时并集去重后 = 100−K,⛔ 不多')
assert.equal(new Set(merged.map((m) => m.seq)).size, merged.length, '合并后无重复 seq')
})
test('判据4 分页批拉:limit 生效且不必一次拉完', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
for (let i = 1; i <= 25; i += 1) {
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i } })
}
const page1 = await store.listMessages('r1', 0, 10)
const page2 = await store.listMessages('r1', page1.at(-1).seq, 10)
const page3 = await store.listMessages('r1', page2.at(-1).seq, 10)
assert.deepEqual([page1.length, page2.length, page3.length], [10, 10, 5])
assert.equal(new Set([...page1, ...page2, ...page3].map((m) => m.id)).size, 25, '三页拼齐 25 条')
assert.ok(MAX_LIST_LIMIT >= 1000, '硬顶常量存在(保护性上限)')
})
// ── 判据 5 / 17:权限与归属列 ───────────────────────────────────────────────
test('判据5 权限:非成员不可见(canSee=false + 列表空集)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { text: 'hi' } })
const message = (await store.listMessages('r1', 0, 10))[0]
assert.equal(await store.canSee('r1', 'u-owner', message), true, '成员可见')
assert.equal(await store.canSee('r1', 'outsider', message), false, '非成员不可见')
assert.deepEqual(await store.listMessages('r1', 0, 10, { viewerId: 'outsider' }), [], '非成员拉取 ⇒ 空集')
assert.equal((await store.listMessages('r1', 0, 10, { viewerId: 'u-owner' })).length, 1, '成员拉取 ⇒ 有')
})
test('判据5 权限:private 消息不进公共列表,只有作者本人看得到', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
await store.addMember({ roomId: 'r1', memberId: 'u-2' })
const pub = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { text: 'public' } })
const priv = await store.appendMessage({
roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { text: 'secret' }, visibility: 'private',
})
const common = await store.listMessages('r1', 0, 10, { viewerId: 'u-2' })
assert.deepEqual(common.map((m) => m.id), [pub.message.id], 'private 不进公共列表')
const own = await store.listMessages('r1', 0, 10, { viewerId: 'u-owner' })
assert.deepEqual(own.map((m) => m.id), [pub.message.id, priv.message.id], '作者本人两条都能看')
assert.equal(await store.canSee('r1', 'u-2', priv.message), false, '他人对 private = 不可见')
assert.equal(await store.canSee('r1', 'u-owner', priv.message), true, '作者对 private = 可见')
// 无视角(内核/agent 拉取路径)⇒ 不做过滤,两条都在(过滤是调用方的选择)。
assert.equal((await store.listMessages('r1', 0, 10)).length, 2, '无视角 ⇒ 全量读出')
})
test('判据17 归属列:owner_id / member_id / author_id / room_id 强制 NOT NULL', async (t) => {
const { db, store } = freshStore(t)
await makeRoom(store, 'r1')
const notNull = async (table) => {
const rows = await db.all(`PRAGMA table_info(${table})`)
return new Map(rows.map((r) => [r.name, Number(r.notnull)]))
}
assert.equal((await notNull('rooms')).get('owner_id'), 1, 'rooms.owner_id NOT NULL')
assert.equal((await notNull('room_members')).get('member_id'), 1, 'room_members.member_id NOT NULL')
assert.equal((await notNull('room_members')).get('room_id'), 1, 'room_members.room_id NOT NULL')
assert.equal((await notNull('messages')).get('author_id'), 1, 'messages.author_id NOT NULL')
assert.equal((await notNull('messages')).get('room_id'), 1, 'messages.room_id NOT NULL')
await assert.rejects(
() => db.run('INSERT INTO rooms (id, room_type, name, owner_id, created_at) VALUES (?,?,?,?,?)', ['x', 't', 'n', null, 1]),
/NOT NULL/i, 'NULL owner_id 被拒',
)
await assert.rejects(
() => db.run(
'INSERT INTO messages (id, room_id, seq, ts, author_id, author_kind, via, visibility, payload_json) VALUES (?,?,?,?,?,?,?,?,?)',
['m', 'r1', 999, 1, null, 'human', 'api', 'room', '{}'],
),
/NOT NULL/i, 'NULL author_id 被拒',
)
})
// ── 判据 6 / 15:写放大 ────────────────────────────────────────────────────
test('判据6/15 写放大:一条消息 = 1 次 messages INSERT,且与成员数无关', async (t) => {
const inner = openImSqlite(':memory:')
t.after(() => inner.close())
const { db, counts } = countingDb(inner)
const store = new ImStore(db)
await makeRoom(store, 'r1')
counts.messageInserts = 0
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 1 } })
assert.equal(counts.messageInserts, 1, '1 条消息 = 1 次 INSERT')
for (let i = 0; i < 50; i += 1) await store.addMember({ roomId: 'r1', memberId: `m${i}` })
assert.equal(await store.countMembers('r1'), 51, '51 个成员')
counts.messageInserts = 0
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 2 } })
assert.equal(counts.messageInserts, 1, '成员数 ×51 后仍是 1 次(⛔ 不按成员循环插入)')
assert.equal(await store.countMessages('r1'), 2, '库里 2 行')
counts.messageInserts = 0
const dedup = await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 2 } })
assert.equal(dedup.inserted, false, '命中去重')
assert.equal(counts.messageInserts, 1, '去重仍**只发 1 条** INSERT 语句(ON CONFLICT DO NOTHING 空转)')
assert.equal(await store.countMessages('r1'), 2, '⛔ 行数不变(0 行被插入)')
})
// ── 判据 18:游标分页无 OFFSET(走索引) ───────────────────────────────────
test('判据18 无 OFFSET:WHERE room_id=? AND seq>? 走索引,不 SCAN messages', async (t) => {
const { db, store } = freshStore(t)
await makeRoom(store, 'r1')
for (let i = 1; i <= 30; i += 1) {
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i } })
}
const plan = await db.all(
'EXPLAIN QUERY PLAN SELECT id, room_id, seq FROM messages WHERE room_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?',
['r1', 5, 10],
)
const details = plan.map((r) => String(r.detail))
assert.ok(details.some((d) => /USING (COVERING )?INDEX/.test(d)), `走索引:${details.join(' | ')}`)
assert.ok(!details.some((d) => /SCAN messages/.test(d)), `⛔ 不得顺序扫描:${details.join(' | ')}`)
})
// ── 判据 13(限速面):具名降级策略 ────────────────────────────────────────
test('判据13 预算:具名降级(rate_limited / silence_period / room_full / ok)', async (t) => {
const tight = new ImStore(openImSqlite(':memory:'), {
limits: { maxMembers: 10, agentQuotaPer10Min: 2, silencePeriodMs: 30_000 },
})
t.after(() => undefined)
await makeRoom(tight, 'r1')
await tight.addMember({ roomId: 'r1', memberId: 'bot-1', kind: 'bot' })
const T = 1_700_000_000_000
assert.equal(await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T }), 'ok')
await tight.appendMessage({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', payload: { i: 1 }, ts: T })
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T + 10_000 }),
'silence_period', '距上一条 10 s(静默期 30 s)⇒ 具名 silence_period',
)
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T + 40_000 }),
'ok', '距上一条 40 s(> 静默期 30 s)⇒ ok',
)
await tight.appendMessage({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', payload: { i: 2 }, ts: T + 60_000 })
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'bot-1', authorKind: 'bot', at: T + 120_000 }),
'rate_limited', '10 min 内第 3 条(配额 2)⇒ 具名 rate_limited',
)
assert.equal(
await tight.decideWrite({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', at: T + 120_000 }),
'ok', '人类成员不受 agent 限速约束',
)
const full = new ImStore(openImSqlite(':memory:'), { limits: { maxMembers: 1, agentQuotaPer10Min: 10, silencePeriodMs: 0 } })
await full.upsertRoom({ id: 'r2', roomType: 'group', name: 'r2', ownerId: 'o' })
await full.addMember({ roomId: 'r2', memberId: 'o' })
await full.addMember({ roomId: 'r2', memberId: 'o2' })
assert.equal(await full.decideWrite({ roomId: 'r2', authorId: 'o', authorKind: 'bot' }), 'room_full')
})
test('判据13 预算默认值 = §四-7 的待标定初值(可配置)', async (t) => {
const { store } = freshStore(t)
await makeRoom(store, 'r1')
const m = await store.addMember({ roomId: 'r1', memberId: 'bot-1', kind: 'bot', autoReply: true, agentRef: 'agent://x' })
assert.equal(m.autoReply, true, 'auto_reply 落库')
assert.equal(m.agentRef, 'agent://x', 'agent_ref 落库')
assert.equal(await store.setMemberAgent('r1', 'bot-1', null), true, '解绑成功')
assert.equal((await store.findMember('r1', 'bot-1')).agentRef, null, '解绑后为 null')
assert.equal(await store.setMemberAgent('r1', 'nobody', 'agent://y'), false, '不存在的成员 ⇒ false')
})
// ── 判据 21:慢查询可观测 ─────────────────────────────────────────────────
test('判据21 慢查询标记:超阈值 ⇒ onSlowQuery(marker=im_slow_query)', async (t) => {
const inner = openImSqlite(':memory:')
t.after(() => inner.close())
const slow = []
const slowDb = {
dialect: inner.dialect,
all: async (sql, params) => {
await new Promise((r) => setTimeout(r, 15))
return inner.all(sql, params)
},
run: (sql, params) => inner.run(sql, params),
exec: (sql) => inner.exec(sql),
close: () => inner.close(),
}
const store = new ImStore(slowDb, { slowQueryMs: 5, onSlowQuery: (info) => slow.push(info) })
await makeRoom(store, 'r1')
await store.appendMessage({ roomId: 'r1', authorId: 'u-owner', authorKind: 'human', payload: { i: 1 } })
await store.listMessages('r1', 0, 10)
assert.ok(slow.length > 0, '有慢查询被记录')
assert.equal(slow[0].marker, SLOW_QUERY_MARKER, '标记可 grep')
assert.ok(typeof slow[0].sql === 'string' && slow[0].ms >= 5, `记下 SQL 与耗时(${slow[0].ms} ms)`)
})
// ── 判据 22 / 23:区字段预留 + 无跨区同步路径 ──────────────────────────────
test('判据22 区/网预留字段:rooms 有 region/network 且可读写', async (t) => {
const { db, store } = freshStore(t)
const cols = (await db.all('PRAGMA table_info(rooms)')).map((r) => r.name)
assert.ok(cols.includes('region') && cols.includes('network'), `rooms 含 region/network:${cols.join(',')}`)
await store.upsertRoom({ id: 'r1', roomType: 'group', name: 'n', ownerId: 'o', region: 'cn-east', network: 'net-1' })
const room = await store.findRoom('r1')
assert.equal(room.region, 'cn-east')
assert.equal(room.network, 'net-1')
// 默认(未标定)⇒ 空串而非 NULL(便于二期按字段过滤,⛔ 不用 NULL 判空)。
await store.upsertRoom({ id: 'r2', roomType: 'group', name: 'n', ownerId: 'o' })
const bare = await store.findRoom('r2')
assert.equal(bare.region, '')
assert.equal(bare.network, '')
// ⛔ room_members / messages 不冗余存区(经 room_id 推出即可)。
const memberCols = (await db.all('PRAGMA table_info(room_members)')).map((r) => r.name)
const messageCols = (await db.all('PRAGMA table_info(messages)')).map((r) => r.name)
assert.ok(!memberCols.includes('region') && !messageCols.includes('region'), '子表不冗余区字段')
})
// ── 房间 / 成员的读写面 ───────────────────────────────────────────────────
test('读写面:upsertRoom 幂等、listRooms 过滤与稳定序、addMember 幂等不降权', async (t) => {
const { store } = freshStore(t)
const first = await store.upsertRoom({ id: 'r1', roomType: 'group', name: '旧名', ownerId: 'o' })
const second = await store.upsertRoom({ id: 'r1', roomType: 'group', name: '新名', ownerId: 'o' })
assert.equal(second.name, '新名', 'name 被更新')
assert.equal(second.createdAt, first.createdAt, 'created_at ⛔ 不被覆盖')
await store.upsertRoom({ id: 'r2', roomType: 'dm', name: 'b', ownerId: 'o' })
await store.upsertRoom({ id: 'r3', roomType: 'group', name: 'c', ownerId: 'other' })
assert.deepEqual((await store.listRooms({ ownerId: 'o' })).map((r) => r.id), ['r1', 'r2'], '按房主过滤')
assert.deepEqual((await store.listRooms({ roomType: 'group' })).map((r) => r.id).sort(), ['r1', 'r3'], '按类型过滤')
await store.addMember({ roomId: 'r1', memberId: 'o', role: 'owner' })
const again = await store.addMember({ roomId: 'r1', memberId: 'o', role: 'member' })
assert.equal(again.role, 'owner', '⛔ 重复 addMember 不把 owner 降成 member')
assert.equal(await store.countMembers('r1'), 1, '仍 1 个成员')
await store.setMemberAutoReply('r1', 'o', true)
assert.equal((await store.findMember('r1', 'o')).autoReply, true)
})
test('外键:房间不存在 ⇒ 投消息 / 加成员被拒(归属不悬空)', async (t) => {
const { store } = freshStore(t)
await assert.rejects(
() => store.appendMessage({ roomId: 'no-such-room', authorId: 'u', authorKind: 'human', payload: { i: 1 } }),
)
await assert.rejects(() => store.addMember({ roomId: 'no-such-room', memberId: 'u' }))
})
// ── 判据 22-adjacent:NOT/EXPLAIN 辅助函数 ────────────────────────────────
test('toPgPlaceholders:? → $n,并跳过字符串字面量里的 ?', () => {
assert.equal(
toPgPlaceholders('SELECT * FROM rooms WHERE id = ? AND name = ?'),
'SELECT * FROM rooms WHERE id = $1 AND name = $2',
)
assert.equal(
toPgPlaceholders("SELECT * FROM rooms WHERE name = 'a?b' AND id = ?"),
"SELECT * FROM rooms WHERE name = 'a?b' AND id = $1",
)
assert.equal(
toPgPlaceholders("SELECT 'it''s ?' AS x, ? AS y"),
"SELECT 'it''s ?' AS x, $1 AS y",
)
})
// ── Postgres 段(未设 DSHS_TEST_DB_URL ⇒ 自跳过,与 test/db.test.mjs 同约定) ──
test('PG:v14 + 去重 + 游标补拉 + EXPLAIN 无 Seq Scan', { skip: PG_URL === undefined ? 'DSHS_TEST_DB_URL 未设置' : false }, async (t) => {
const db = await openImPg(PG_URL, { migrate: true })
t.after(() => db.close())
const store = new ImStore(db)
const roomId = `r-${Date.now()}`
const rows = await db.all('SELECT MAX(version) AS v FROM schema_migrations')
// ⚠️ 从「= 14」放开为「≥ 14」:后续单子允许加迁移(v15 插件数据面、v16 E 单房间可见性列),
// 本条**本意**是「v14 已应用」,⛔ 不是"锁死版本号"。
assert.ok(Number(rows[0].v) >= 14, `PG 侧最新 ≥ v14(实测 ${rows[0].v})`)
await store.upsertRoom({ id: roomId, roomType: 'group', name: 'pg', ownerId: 'o' })
const a = await store.appendMessage({ roomId, authorId: 'o', authorKind: 'human', payload: { text: 'dup' } })
const b = await store.appendMessage({ roomId, authorId: 'o', authorKind: 'human', payload: { text: 'dup' } })
assert.equal(a.inserted, true)
assert.equal(b.inserted, false, 'PG 侧同样命中去重')
assert.equal(await store.countMessages(roomId), 1)
for (let i = 1; i <= 10; i += 1) {
await store.appendMessage({ roomId, authorId: 'o', authorKind: 'human', payload: { i } })
}
const pulled = await store.listMessages(roomId, 4, 100)
assert.equal(pulled.length, 7, '恰好补 10−4+1 条')
// 小表下 PG 规划器会偏好顺序扫描 ⇒ 关掉 enable_seqscan 才是"索引可用性"的判据。
await db.exec('SET enable_seqscan = off')
const plan = await db.all(
'EXPLAIN SELECT id FROM messages WHERE room_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?',
[roomId, 1, 10],
)
const text = plan.map((r) => String(Object.values(r)[0])).join('\n')
assert.ok(!/Seq Scan on messages/.test(text), `⛔ 不得 Seq Scan:\n${text}`)
})
+587
View File
@@ -0,0 +1,587 @@
// IM 会话界面 UI 回归测试(node:test)—— E 单 `交接单/IM群组-E-会话界面UI.md` §六(1–13)+ §六.1(8–9)
// + **判据 14(协议一致性,第 13 棒新增)**:源 = 入口 `接续入口_IM线_20260922.md` §2「第 13 棒」第 4 条
// (修选型单 §15.7 的 P0 —— 客户端帧由 `type:` 回到平台的 `op:` 制)。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// ## 覆盖策略(为什么要读源码文本 —— ⛔ 不是"看了就当验过")
// 本单交付的**主件是客户端 bundle**(`poc/im-conversation-tabs/lib/client.js`),它跑在
// **浏览器**里(React + DOM + WebSocket),Node 侧无法执行。因此判据拆成两类:
// ① **可执行判据**(服务端增量:v16 迁移 / 可发现列表 / 自助加入 / 成员面 / 上限)⇒ 真跑;
// ② **结构判据**(客户端 bundle)⇒ 对**源码文本**做机械断言(选择器清单、`!important`、
// 反向还原、`display:none`、上限常量、词条键集、⛔ 无 inline 布局 style)。
// ⚠️ 口径写明:结构判据只保证"该写的都在",**不等于**浏览器里跑过(E 单 §六-5 明确
// 「本机改完不算交付」⇒ 生效链路四关是本单的**未完成项**,已记入回报)。
//
// ⚠️ Postgres 段自跳过:未设 `DSHS_TEST_DB_URL` 即 skip(与 `test/im-store.test.mjs` 同约定)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { openImSqlite } from '../lib/im/db.js'
import { ImStore } from '../lib/im/store.js'
import { ROOM_LIMITS, toJoinPolicy, JOIN_POLICIES } from '../lib/im/types.js'
const CLIENT_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'lib', 'client.js')
const HOST_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'lib', 'index.js')
const PKG_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'package.json')
const PATCH_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'cordis.patch.yml')
/** 平台 WS 端点源码 —— 协议一致性判据(E-14)必须**两侧源码互相对照**,⛔ 不是"看了一遍"。 */
const WS_PATH = join(process.cwd(), 'src', 'im', 'ws.ts')
/** 平台扇出源码 —— `presence` / `message` 的推帧在这里。 */
const HUB_PATH = join(process.cwd(), 'src', 'im', 'hub.ts')
/** 读客户端 bundle 源码(判据 8/9/10/11/12/13 的结构判据都基于它)。 */
function clientSource() {
return readFileSync(CLIENT_PATH, 'utf8')
}
/** 打开一个内存库 + 内核,并在用例结束时关库。 */
function freshStore(t, options = {}) {
const db = openImSqlite(':memory:')
t.after(() => db.close())
return { db, store: new ImStore(db, options) }
}
/** 建一个跑完全部迁移的临时库文件(判据 7 用;收尾**先关库**再删目录 —— Windows 上
* 文件被占用时 `rmSync` 会 EBUSY)。 */
function migratedStore(t) {
const dir = mkdtempSync(join(tmpdir(), 'im-e16-'))
const path = join(dir, 'control.db')
const db = openImSqlite(path)
t.after(async () => {
await db.close()
rmSync(dir, { recursive: true, force: true })
})
return db
}
// ── 判据 7 的路线声明:**显式列 + v16 迁移(只增)** ─────────────────────────
// 🔴 本单自决(理由写在 `src/db/schema.ts` 的 V16 注释与 E 单回报):授权语义不埋 config。
test('E-7 · 路线 = 显式列:v16 迁移存在且只增列(discoverable / join_policy)', async (t) => {
const db = migratedStore(t)
const rows = await db.all('SELECT version FROM schema_migrations ORDER BY version')
const versions = rows.map((r) => Number(r.version))
assert.ok(versions.includes(16), `v16(E 单房间可见性列)必须已应用(实测 ${versions.join(',')})`)
assert.equal(Number(rows.at(0).version), 1, '首条仍 = v1(⛔ 未改既有迁移)')
assert.deepEqual(
versions,
Array.from({ length: versions.length }, (_, i) => i + 1),
'迁移号连续(无跳号;新增只许追加)',
)
const cols = (await db.all('PRAGMA table_info(rooms)')).map((r) => r.name)
assert.ok(cols.includes('discoverable'), 'rooms 应有 discoverable 列(显式列路线)')
assert.ok(cols.includes('join_policy'), 'rooms 应有 join_policy 列(显式列路线)')
// 只增:v14 的既有列仍在(⛔ 迁移不得删列)。
for (const c of ['id', 'room_type', 'name', 'owner_id', 'region', 'network', 'config_json', 'created_at']) {
assert.ok(cols.includes(c), `v14 既有列 ${c} 不得被删`)
}
})
test('E-7b · 只增迁移:既有行默认「不可发现 + 仅受邀」(⛔ 不静默扩大可见面)', async (t) => {
const db = migratedStore(t)
// 手工插一行"v15 时代"的房(不显式带新列)⇒ 默认值必须落在保守侧。
await db.run(
`INSERT INTO rooms (id, room_type, name, owner_id, region, network, config_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
['imr_legacy', 'office', '老房', 'u1', '', '', '{}', Date.now()],
)
const row = (await db.all('SELECT discoverable, join_policy FROM rooms WHERE id = ?', ['imr_legacy']))[0]
assert.equal(Number(row.discoverable), 0, '存量房必须默认不可发现')
assert.equal(row.join_policy, 'invite', '存量房必须默认仅受邀')
})
test('E-7c · toJoinPolicy 值域:只认 open,其余一律回落 invite(保守侧)', () => {
assert.deepEqual([...JOIN_POLICIES], ['open', 'invite'])
assert.equal(toJoinPolicy('open'), 'open')
assert.equal(toJoinPolicy('invite'), 'invite')
for (const bad of [undefined, null, 0, 1, true, '', 'OPEN', 'public', {}]) {
assert.equal(toJoinPolicy(bad), 'invite', `${String(bad)} 必须回落 invite`)
}
})
// ── 判据 13 · 发现 + 加入(服务端增量 · 可执行)────────────────────────────
test('E-13a · 可发现列表:只列 discoverable 且 joinPolicy=open 的房', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r_open', roomType: 'office', name: '公开房', ownerId: 'u1', discoverable: true, joinPolicy: 'open' })
await store.upsertRoom({ id: 'r_closed', roomType: 'office', name: '受邀房', ownerId: 'u1', discoverable: true, joinPolicy: 'invite' })
await store.upsertRoom({ id: 'r_hidden', roomType: 'office', name: '隐藏房', ownerId: 'u1' })
// 🔴 默认值方向断言:不传 ⇒ 不可发现(⛔ 反向默认 = 静默扩大可见面)。
const hiddenDefault = await store.findRoom('r_hidden')
assert.equal(hiddenDefault.discoverable, false, '不传 discoverable ⇒ 默认 false')
assert.equal(hiddenDefault.joinPolicy, 'invite', '不传 joinPolicy ⇒ 默认 invite')
// 下推查询(进 WHERE)而不是取回后过滤。
const discoverable = await store.listRooms({ discoverable: true })
assert.deepEqual(discoverable.map((r) => r.id).sort(), ['r_closed', 'r_open'])
const openOnly = discoverable.filter((r) => r.joinPolicy === 'open')
assert.deepEqual(openOnly.map((r) => r.id), ['r_open'], '前端候选 = discoverable ∩ open')
})
test('E-13b · discoverable 双后端归一:读回为布尔真(⛔ 不是 1/0)', async (t) => {
const { store } = freshStore(t)
const room = await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'u1', discoverable: true, joinPolicy: 'open' })
assert.equal(room.discoverable, true)
assert.equal(room.joinPolicy, 'open')
const reread = await store.findRoom('r1')
assert.equal(reread.discoverable, true, 'SQLite 存 0/1 ⇒ 读回必须归一成 boolean')
})
test('E-13c · 自助加入成功 + 幂等(已是成员 ⇒ joined=false,⛔ 不是错误)', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'owner', discoverable: true, joinPolicy: 'open' })
await store.addMember({ roomId: 'r1', memberId: 'owner', kind: 'human', role: 'owner' })
const first = await store.joinRoom('r1', 'u2')
assert.equal(first.ok, true)
assert.equal(first.joined, true)
assert.equal(first.member.role, 'member', '自助加入的角色恒为 member(⛔ 不是 owner/admin)')
assert.equal(first.members, 2)
const second = await store.joinRoom('r1', 'u2')
assert.equal(second.ok, true)
assert.equal(second.joined, false, '已是成员 ⇒ 幂等返回 joined=false')
assert.equal(second.members, 2, '幂等不得重复计数')
})
test('E-13d · 具名拒绝:不可发现 ⇒ not_discoverable;可见但仅受邀 ⇒ join_closed', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r_hidden', roomType: 'office', name: 'h', ownerId: 'o', joinPolicy: 'open' })
await store.upsertRoom({ id: 'r_closed', roomType: 'office', name: 'c', ownerId: 'o', discoverable: true, joinPolicy: 'invite' })
const a = await store.joinRoom('r_hidden', 'u2')
assert.equal(a.ok, false)
assert.equal(a.reason, 'not_discoverable', '不可发现 ⇒ 具名理由(路由层归成 404)')
const b = await store.joinRoom('r_closed', 'u2')
assert.equal(b.ok, false)
assert.equal(b.reason, 'join_closed', '可见但需受邀 ⇒ join_closed(路由层 403)')
const c = await store.joinRoom('r_nope', 'u2')
assert.equal(c.ok, false)
assert.equal(c.reason, 'not_found')
})
test('E-13e · 容量判据:满房 ⇒ room_full(沿用 ROOM_LIMITS.maxMembers = 2000)', async (t) => {
// 用极小上限构造"已满",避免插 2000 行。
const { store } = freshStore(t, { limits: { maxMembers: 2, agentQuotaPer10Min: 10, silencePeriodMs: 30_000 } })
await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'o', discoverable: true, joinPolicy: 'open' })
await store.addMember({ roomId: 'r1', memberId: 'o', kind: 'human', role: 'owner' })
await store.addMember({ roomId: 'r1', memberId: 'u2', kind: 'human', role: 'member' })
const full = await store.joinRoom('r1', 'u3')
assert.equal(full.ok, false)
assert.equal(full.reason, 'room_full', '满房 ⇒ 具名拒绝(⛔ 不静默放行)')
assert.match(String(full.detail), /2/)
// 已在房里的人仍可"加入"(幂等优先于容量 —— ⛔ 否则满房后老成员会被自己拒之门外)。
const already = await store.joinRoom('r1', 'u2')
assert.equal(already.ok, true)
assert.equal(already.joined, false)
assert.equal(ROOM_LIMITS.maxMembers, 2000, '生产上限口径不变(A 单初值)')
})
// ── 判据 2 / 12 · 成员模型可见性 + 在线态(数据源 = hub,⛔ 不新增)─────────
test('E-2/12a · 成员表:bot 独立成行;形态 B 组员不出现第二行(⛔ 不合成 agent 成员)', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'o' })
await store.addMember({ roomId: 'r1', memberId: 'o', kind: 'human', role: 'owner' })
await store.addMember({ roomId: 'r1', memberId: 'bot1', kind: 'bot', role: 'member' })
// 形态 B:给人类成员挂 agent —— ⛔ 不得因此多出一行。
await store.addMember({ roomId: 'r1', memberId: 'u2', kind: 'human', role: 'member', agentRef: 'agent-of-u2' })
const members = await store.listMembers('r1')
assert.equal(members.length, 3, '挂 agent 的人类成员不得产生第二行(142 §3.1)')
const kinds = members.map((m) => m.kind)
assert.equal(kinds.filter((k) => k === 'bot').length, 1, 'bot 应独立成行')
const withAgent = members.find((m) => m.memberId === 'u2')
assert.equal(withAgent.kind, 'human', '形态 B 组员仍是 human')
assert.equal(withAgent.agentRef, 'agent-of-u2', 'agent 绑定挂在同一行的 agentRef 上')
})
test('E-12b · 在线态数据源 = hub(presenceSnapshot 存在且只回在线成员)', async (t) => {
const { ImHub } = await import('../lib/im/hub.js')
const { store } = freshStore(t)
const hub = new ImHub(store)
const conn = {
id: 'c1',
userId: 'u1',
rooms: new Set(['r1']),
send: () => true,
close: () => {},
lastSeenAt: Date.now(),
openedAt: Date.now(),
}
assert.equal(hub.attach(conn), true, '挂连接成功')
hub.subscribe(conn, 'r1')
const snap = hub.presenceSnapshot('r1')
assert.deepEqual(snap, { u1: 'online' }, '快照只含在线成员(缺省 = 离线,由客户端处理)')
assert.equal(hub.onlineIn('r1'), 1, '与 onlineIn 同源')
hub.shutdown()
})
test('E-12c · 在线态在 1 s 批窗口内收敛(presence 批合并,⛔ 不新增数据源)', async (t) => {
const { ImHub, IM_PRESENCE_BATCH_MS } = await import('../lib/im/hub.js')
const { store } = freshStore(t)
let clock = 1_000_000
const frames = []
const hub = new ImHub(store, { now: () => clock })
const conn = {
id: 'c1',
userId: 'u1',
rooms: new Set(['r1']),
send: (text) => {
frames.push(JSON.parse(text))
return true
},
close: () => {},
lastSeenAt: clock,
openedAt: clock,
}
hub.attach(conn)
hub.subscribe(conn, 'r1')
// 订阅自己会记一次 online ⇒ 先刷掉,再单独验"批合并"语义。
assert.equal(hub.flushPresence(clock), 0, '窗口未到 ⇒ 不刷(批次窗口语义)')
clock += IM_PRESENCE_BATCH_MS + 1
assert.equal(hub.flushPresence(clock), 1, '订阅产生的 online 在窗口到达后刷出')
// 同一批里同成员多次变更 ⇒ 覆盖,只出一帧一条。
hub.notePresence('r1', 'u1', 'online', clock)
hub.notePresence('r1', 'u1', 'offline', clock + 1)
hub.notePresence('r1', 'u1', 'online', clock + 2)
clock += IM_PRESENCE_BATCH_MS + 1
const n = hub.flushPresence(clock)
assert.equal(n, 1, '一批一帧/连接')
const presenceFrames = frames.filter((f) => f.op === 'presence')
assert.equal(presenceFrames.length, 2, '共两批')
assert.equal(presenceFrames[1].members.length, 1, '同批内同成员覆盖 ⇒ 只剩最后一条')
assert.equal(presenceFrames[1].members[0].state, 'online')
hub.shutdown()
})
// ── 判据 8/9/10/11 · 客户端 bundle 结构判据(源码文本级)────────────────────
test('E-8/11a · 🔴 多选择器兜底:清单含新名(主)+ 旧名(兜底),按序取首个命中', () => {
const src = clientSource()
// ⚠️ 逐行抽(数组字面量跨行),且按 **行首 `]`** 收尾 —— ⛔ 不能用 indexOf(']'),
// CSS 属性选择器 `[data-slot="…"]` 自带 `]`,会提前截断 ⇒ 抽取结果只剩第一项。
const lines = src.split(/\r?\n/)
const start = lines.findIndex((l) => /const CONVERSATION_SLOT_SELECTORS\s*=\s*\[/.test(l))
assert.ok(start >= 0, '必须有 CONVERSATION_SLOT_SELECTORS 清单')
const body = []
for (let i = start + 1; i < lines.length; i++) {
if (/^\s*\]/.test(lines[i])) break
body.push(lines[i])
}
const list = [...body.join('\n').matchAll(/'([^']*)'/g)].map((x) => x[1])
assert.equal(list.length, 2, `清单应恰有 2 项(新名 + 旧名兜底),实测 ${list.length}`)
assert.ok(
list[0].includes('main.conversation'),
'清单须含新槽名 data-slot="main.conversation"',
)
assert.ok(
/\[data-slot="conversation"\]/.test(list[1]),
'清单须含旧槽名 data-slot="conversation"(兜底)',
)
assert.ok(list[0] !== list[1], '两项必须不同(新名 / 旧名)')
assert.ok(/function findConversationSlot\(\)/.test(src), '必须有 findConversationSlot 兜底函数')
assert.ok(/for \(const sel of CONVERSATION_SLOT_SELECTORS\)/.test(src), '必须**逐个**试选择器(⛔ 不用单选择器)')
})
test('E-11b · 🔴 槽位失效**可见提示**(⛔ 不静默失败)', () => {
const src = clientSource()
assert.ok(/slotMissing/.test(src), '必须有失配状态位')
assert.ok(/imtabs-alert/.test(src), '失配时必须渲染可见提示条(imtabs-alert)')
assert.ok(/t\('slot\.missing'\)|T\('slot\.missing'\)/.test(src), '提示文案须走词条(zh/en 双侧)')
// ⛔ 失配路径不得"只 return null 了事"。
assert.ok(/if \(slotMissing\) \{[\s\S]{0,400}?imtabs-alert/.test(src), '失配分支必须落到提示条而不是空返回')
})
test('E-8b · 🔴 flex 布局由 CSS 规则 + !important 驱动(⛔ 不用 inline style)', () => {
const src = clientSource()
// 关键:会话区子选择器的布局压制定必须在 CSS 里带 !important。
assert.ok(/\.imtabs-convWrap>\[data-slot='conversation'\]>div/.test(src), 'CSS 须覆盖旧槽名子选择器')
assert.ok(/\.imtabs-convWrap>\[data-slot='main\.conversation'\]>div/.test(src), 'CSS 须覆盖新槽名子选择器')
const importantCount = (src.match(/!important/g) ?? []).length
assert.ok(importantCount >= 6, `!important 用量应 ≥6(实测 ${importantCount})`)
// ⛔ 不得用 inline style 驱动布局:React 元素上只允许 2 处"非布局"用途(at 选择器容器 + 无)。
const styleAttr = src.match(/style:\s*\{[^}]*\}/g) ?? []
const layoutish = styleAttr.filter((s) => /(flex|width|height|display|minWidth|minHeight|overflow)/.test(s))
assert.equal(
layoutish.length,
1,
`⛔ 只允许 at 选择器包裹层用 inline(1 处),实测布局性 inline style ${layoutish.length} 处:${layoutish.join(' | ')}`,
)
})
test('E-8c · 🔴 卸载反向还原(会话区插回原位 + 恢复 previousCss + 移除 wrapper)', () => {
const src = clientSource()
assert.ok(/prevCss/.test(src), '必须记录会话区的 previousCss')
assert.ok(/style\.cssText/.test(src), '必须原样写回 cssText')
assert.ok(
/insertBefore\(cur\.conversation,\s*cur\.wrap\)/.test(src),
'必须把会话区**插回原位**(插在 wrapper 之前)',
)
assert.ok(/cur\.wrap\.remove\(\)/.test(src), '必须移除 wrapper')
// 幂等:不得重复建 wrapper。
assert.ok(/querySelector\(':scope > \[data-imtabs-root="1"\]'\)/.test(src), '必须复用既有 wrapper(幂等)')
})
test('E-1 · 接管走官方扩展面(⛔ 不改 dsh 主程序 / client bundle / 缓存,不触 R2)', () => {
const src = clientSource()
assert.ok(/data-slot/.test(src), '接管锚点必须是官方 data-slot(公开可寻址 DOM 槽)')
// ⛔ 不得出现对 dsh 官方资源的写操作。
for (const forbidden of ['localStorage.clear', 'indexedDB.deleteDatabase', 'document.cookie =', 'window.__DSH']) {
assert.equal(src.includes(forbidden), false, `⛔ 不得出现 ${forbidden}`)
}
})
test('E-9 · tab 保活 = display:none(候选一):切走不卸载,滚动位置与草稿保留', () => {
const src = clientSource()
assert.ok(/imtabs-paneHidden\{display:none!important\}/.test(src), '必须用 CSS 类做 display:none 保活')
assert.ok(
/classList\.toggle\('imtabs-paneHidden'/.test(src),
'显隐必须走 class toggle(⛔ 不用 unmount / 条件渲染)',
)
// 🔴 关键:群组 pane 的子树**常驻渲染**(靠 CSS 显隐)⇒ 草稿 state 不丢。
assert.ok(
/groups\.map\(\(g\)[\s\S]{0,600}?createPortal\(\s*jsx\.jsx\(RoomPane/.test(src),
'pane 子树必须常驻渲染(保活的成立前提)',
)
// 草稿与滚动位置必须存在 state/ref(保活的被保对象)。
assert.ok(/const \[draft, setDraft\] = React\.useState\(''\)/.test(src), '输入框草稿必须在 React state 里')
assert.ok(/const listRef = React\.useRef\(null\)/.test(src), '消息列表必须有 ref(滚动位置载体)')
assert.ok(/scrollTop/.test(src), '必须有滚动位置读写(保活的验收点)')
})
test('E-10 · 🔴 群组 tab 上限 = 5(超限具名提示,⛔ 不静默丢弃)', () => {
const src = clientSource()
assert.ok(/const MAX_GROUP_TABS = 5\b/.test(src), '上限常量必须恰为 5(E 单 §四-4 拍板)')
assert.ok(/groups\.some\(\(g\) => g\.id === room\.id\)/.test(src), '已开过的房间必须去重(⛔ 不开重复 tab)')
assert.ok(
/if \(groups\.length >= MAX_GROUP_TABS\) \{[\s\S]{0,140}?setToast\(T\('tab\.limit'\)\)/.test(src),
'增 tab 前必须查上限,且超限出具名提示',
)
assert.ok(!/\.slice\(0, MAX_GROUP_TABS\)/.test(src), '⛔ 不得用截断静默丢弃超限项')
})
test('E-8d · tab 行形态:`dsh 会话` + 群组会话 1..n;切换不返回到处', () => {
const src = clientSource()
assert.ok(/T\('tab\.dsh'\)/.test(src), '必须有「dsh 会话」tab')
assert.ok(/T\('tab\.group', \{ n: i \+ 1 \}\)/.test(src), '群组 tab 标题必须是「群组会话 + 序号」')
assert.ok(/activeKey === tb\.key \? ' imtabs-tabActive'/.test(src), '必须有激活态样式切换')
assert.ok(/onClick: \(\) => onTabClick\(tb\.key\)/.test(src), '点击必须走 onTabClick(tb.key)(⛔ 不跳转/不刷新)')
assert.ok(
/const onTabClick = \(key\) => \{\s*setActiveKey\(key\)/.test(src),
'onTabClick 内部必须 setActiveKey(切激活 key,⛔ 不改 location / 不 reload)',
)
assert.ok(!/location\.(href|assign|reload)|window\.open\(/.test(src), '⛔ 切 tab 不得跳转或刷新')
})
test('E-9b · 追加判据 8 · 重连指数退避(2^n 封顶)+ 抖动,60 s 内 ≤ 8 次', () => {
const src = clientSource()
assert.ok(/RECONNECT_BASE_MS = 1000/.test(src), '退避基值 1 s')
assert.ok(/RECONNECT_MAX_MS = 15000/.test(src), '退避封顶 15 s')
assert.ok(/Math\.min\(RECONNECT_BASE_MS \* Math\.pow\(2, attempt - 1\), RECONNECT_MAX_MS\)/.test(src), '必须 2^n 封顶')
assert.ok(/RECONNECT_JITTER_MS/.test(src), '必须有抖动(⛔ 无抖动的同步重连 = 重连风暴)')
assert.ok(/Math\.random\(\)/.test(src), '抖动必须是随机的')
assert.ok(/attempt >= 8/.test(src), '必须封顶 8 次尝试(60 s 内 ≤ 8)')
assert.ok(/attempt = 0/.test(src), '连上后必须重置计数(⛔ 否则长连接会被历史计数拖死)')
})
test('E-9c · 追加判据 9 · 断网 ⇒ 界面出现**具名**「重连中」状态(⛔ 不静默)', () => {
const src = clientSource()
assert.ok(/T\('conn\.reconnecting', \{ n: retries \}\)/.test(src), '必须显示「重连中(第 n 次)」具名状态')
assert.ok(/T\('conn\.offline'\)/.test(src), '必须有「已断开」状态')
assert.ok(/T\('conn\.online'\)/.test(src), '必须有「已连接」状态')
assert.ok(/T\('conn\.retryNow'\)/.test(src), '超限后必须有手动重试逃生口')
// 恢复后按游标补齐 ⇒ 无重复消息。
assert.ok(/pull\(cursorRef\.current\)/.test(src), '重连后必须按游标补齐')
assert.ok(/const seen = new Set\(prev\.map\(\(m\) => m\.id\)\)/.test(src), '合并必须按 id 去重(⛔ 否则补齐会重复)')
})
// ── 判据 4 / 6 · 规范与 i18n ────────────────────────────────────────────────
test('E-6 · i18n:zh/en 键集一致(⛔ 无硬编码中文残留于渲染路径)', () => {
const src = clientSource()
const dictStart = src.indexOf('const DICT = {')
assert.ok(dictStart >= 0, '必须有 DICT 词典')
const zhAt = src.indexOf('\n zh: {', dictStart)
const enAt = src.indexOf('\n en: {', dictStart)
assert.ok(zhAt > dictStart && enAt > zhAt, 'DICT 必须含 zh / en 两段(顺序 zh → en)')
const zhBlock = src.slice(zhAt, enAt)
const enBlock = src.slice(enAt, src.indexOf('\n },', enAt) + 8)
const keysOf = (block) => {
const out = new Set()
const re = /'([a-zA-Z][a-zA-Z0-9._]*)':/g
let m
while ((m = re.exec(block)) !== null) out.add(m[1])
return out
}
const zh = keysOf(zhBlock)
const en = keysOf(enBlock)
assert.ok(zh.size > 40, `zh 词条数应 > 40(实测 ${zh.size})`)
assert.ok(en.size > 40, `en 词条数应 > 40(实测 ${en.size})`)
const missingInEn = [...zh].filter((k) => !en.has(k))
const missingInZh = [...en].filter((k) => !zh.has(k))
assert.deepEqual(missingInEn, [], `en 缺这些键:${missingInEn.join(', ')}`)
assert.deepEqual(missingInZh, [], `zh 缺这些键:${missingInZh.join(', ')}`)
})
test('E-6b · 渲染路径走词条函数(⛔ 不直接写死中文到 UI)—— 允许清单外为零', () => {
const src = clientSource()
// 允许清单:注释、错误码 fallback、符号(✕ / + / @ / · / #)。
const ALLOW = ['张', '阅', '…']
// 抓 JSX children 位置的字面中文(粗筛:`children: '…'` 形式)。
const hits = src.match(/children:\s*'[^']*[\u4e00-\u9fa5][^']*'/g) ?? []
const bad = hits.filter((h) => !ALLOW.some((a) => h.includes(a)))
assert.deepEqual(bad, [], `渲染路径不得硬编码中文:${bad.join(' | ')}`)
})
test('E-4b · ⛔ 不引完整开源 IM 前端(只借交互与组件)—— 零新依赖', () => {
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
assert.deepEqual(pkg.dependencies ?? {}, {}, '⛔ 不得引入任何依赖(含 Rocket.Chat / Mattermost 类整站)')
assert.deepEqual(pkg.devDependencies ?? {}, {}, '⛔ 不得引入任何 devDependencies')
const src = clientSource()
// 🔴 判据面 = **依赖 / 引入面**(require / import / script src),⛔ 不是注释文本 ——
// E 单 §四-5 允许在注释里点名「不引 Rocket.Chat / Mattermost」作为口径说明。
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const imported = [
...[...code.matchAll(/require\('([^']+)'\)/g)].map((x) => x[1]),
...[...code.matchAll(/from\s+'([^']+)'/g)].map((x) => x[1]),
].map((s) => s.toLowerCase())
for (const banned of ['rocket.chat', 'rocketchat', 'mattermost', 'matrix-js-sdk', 'socket.io-client']) {
assert.equal(
imported.some((r) => r === banned || r.startsWith(banned + '/')),
false,
`⛔ 不得在引入面引用 ${banned}`,
)
}
assert.equal(
/<script[^>]+src=\s*["'][^"']*(rocket|mattermost)/i.test(code),
false,
'⛔ 不得从整站应用注入前端脚本',
)
// 前端宿主能力只允许这三个(官方 react 运行时)。
const allowed = new Set(['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client'])
const extra = imported.filter((r) => !allowed.has(r))
assert.deepEqual(extra, [], `⛔ 只允许官方宿主 require:实测多出 ${extra.join(', ')}`)
})
test('E-1b · 插件包结构齐备(package.json + cordis.patch.yml + host 半边)', () => {
assert.ok(existsSync(PKG_PATH), 'package.json 必须存在')
assert.ok(existsSync(PATCH_PATH), 'cordis.patch.yml 必须存在')
assert.ok(existsSync(HOST_PATH), 'host 半边 lib/index.js 必须存在')
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
assert.equal(pkg.name, '@dsh-local/im-conversation-tabs')
assert.equal(pkg.exports['./client'], './lib/client.js', 'client 半边必须在 exports 里声明')
assert.equal(pkg.dsh.client.platform, 'web', 'client 平台必须是 web')
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
const patch = readFileSync(PATCH_PATH, 'utf8')
assert.ok(/id:\s*im-conversation-tabs/.test(patch), 'patch 必须 insert 本插件行')
assert.ok(/name:\s*'@dsh-local\/im-conversation-tabs'/.test(patch))
})
test('E-12d · 客户端消费 hub 协议:subscribe / ping / message / presence 四类帧', () => {
const src = clientSource()
// 🔴 第 13 棒(P0 修复):出站帧由 `type:` 改回**平台已实现的 op 制**。
// 改前那 5 条断言(`type:'subscribe'` / `type:'ping'` / `msg.type === …` / `batch`)
// 正是把 P0 钉在绿灯上的原因 —— 客户端发 `type` ⇒ 服务端 `bad-op` ⇒ `destroy()`。
assert.ok(/sendOp\(ws, 'subscribe'/.test(src), '必须发 subscribe(op 制;平台的 subscribe ⛔ 不读 since)')
assert.ok(/sendOp\(ws, 'ping'/.test(src), '必须发 ping 保活(⚠️ 平台**没有** `case \'pong\'`)')
assert.ok(/case 'message'/.test(src), '必须处理 message 帧(`messages` 是数组)')
assert.ok(/case 'presence'/.test(src), '必须处理 presence 帧(`members` 是数组)')
assert.ok(/case 'messages'/.test(src), '必须处理 messages 帧(补拉结果,与 message 同形)')
// ⚠️ IM 数据走同源相对路径(⛔ 不硬编码主机 / 区域)。
assert.ok(/const API = '\/api\/im'/.test(src), 'API 基址必须是同源相对路径')
assert.equal(/https?:\/\/[a-zA-Z0-9.-]+\/api\/im/.test(src), false, '⛔ 不得硬编码主机')
assert.ok(/window\.location\.host/.test(src), 'WS 地址必须由当前 location 推出(区域无关)')
})
// ── 判据 14 · 协议一致性(第 13 棒新增)────────────────────────────────────────
// 🔴 这条判据是 **两侧源码文本互相对照的机械断言**,⛔ 不是"读了一遍觉得对"。
// 起因(第 12 棒 §15.7 的 P0):E 单客户端 bundle 与平台 `/api/im/ws` 的帧协议不一致 ——
// 客户端发 `{type:…}`,平台认 `{op:…}` ⇒ 首帧即 `reject('bad-op')` + `socket.destroy()`
// ⇒ 投放后「连上就断、一条消息看不到」。
test('E-14 · 协议一致性:客户端出站 ⊂ 平台认的 op;入站分支 ⊇ 平台推的 op(机械对账)', () => {
const client = clientSource()
/** 去掉块注释 / 整行注释 —— 否则**模块头表格**里的 `{op:'subscribe'}` 会被当成"平台推帧"
* (首版实测踩过:抽出 subscribe/unsubscribe/send/resume 四条假的"缺失分支")。 */
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/[^\n]*$/gm, '')
const ws = stripComments(readFileSync(WS_PATH, 'utf8'))
const hub = stripComments(readFileSync(HUB_PATH, 'utf8'))
/** 抽 `switch (op)` 里的 `case '…'`(= 平台**已实现**的客户端 → 服务端 op)。 */
const serverOpStart = ws.indexOf('const op = msg.op')
assert.ok(serverOpStart > 0, '平台 ws.ts 必须仍有 `const op = msg.op`(op 制的锚点)')
const serverOpRegion = ws.slice(serverOpStart, ws.indexOf('function onFrame(', serverOpStart))
const serverOps = new Set([...serverOpRegion.matchAll(/case '([a-z][a-z0-9-]*)':/g)].map((m) => m[1]))
assert.ok(serverOps.size >= 5, `平台认的 op 应 ≥5(实测 ${[...serverOps].join('/')})`)
/** 抽客户端**唯一的出站入口** `sendOp(ws, '<op>'` 的字面量。 */
const outOps = [...client.matchAll(/sendOp\(ws, '([a-z][a-z0-9-]*)'/g)].map((m) => m[1])
assert.ok(outOps.length >= 3, `客户端应至少发 3 类出站帧(实测 ${outOps.join('/')})`)
const outUnique = [...new Set(outOps)]
// ① 出站字面量集合 **⊆** 平台认的 op 集合。
const illegal = outUnique.filter((o) => !serverOps.has(o))
assert.deepEqual(illegal, [], `⛔ 客户端出了平台不认的 op(会被 bad-op 断开):${illegal.join(', ')}`)
// ⚠️ 反向专测:`pong` 只出现在平台**注释**里,实现里没有这个 case ⇒ 发了必被断开。
assert.equal(
serverOpRegion.includes("case 'pong'"),
false,
'⚠️ 前提失效:平台若已实现 `case \'pong\'`,本条判据的两侧口径需重新对账',
)
assert.equal(outUnique.includes('pong'), false, '⛔ 客户端⛔ 不得把心跳写成 pong(平台未实现该 case)')
// ② 客户端**不存在** `type:` 形式的出站帧 / `msg.type` 入站判据。
assert.equal(/send\(\s*JSON\.stringify\(\{\s*type\s*:/.test(client), false, '⛔ 出站帧不得用 type 字段')
assert.equal(/msg\.type\s*===/.test(client), false, '⛔ 入站分支不得按 msg.type 判(平台推的是 op)')
// ③ 客户端入站分支覆盖**平台实际推的 op**(从 ws.ts + hub.ts 的推帧字面量机械抽取)。
const pushed = new Set([...ws.matchAll(/op:\s*'([a-z][a-z0-9-]*)'/g)].map((m) => m[1]))
for (const m of hub.matchAll(/op:\s*'([a-z][a-z0-9-]*)'/g)) pushed.add(m[1])
const caseStart = client.indexOf('switch (msg.op)')
assert.ok(caseStart > 0, '客户端必须有 `switch (msg.op)` 入站分发')
const inCases = new Set([...client.slice(caseStart).matchAll(/case '([a-z][a-z0-9-]*)':/g)].map((m) => m[1]))
const missing = [...pushed].filter((o) => !inCases.has(o))
assert.deepEqual(missing, [], `⛔ 平台会推这些 op 但客户端没有分支:${missing.join(', ')}`)
// 入口本条要求显式点名的 8 类。
for (const need of ['hello', 'subscribed', 'unsubscribed', 'message', 'messages', 'presence', 'sent', 'error']) {
assert.ok(inCases.has(need), `客户端必须覆盖入站 op:${need}`)
}
// ⛔ `error` 不得静默丢弃 ⇒ 必须有可见面(写进 UI 错误位)。
assert.ok(/case 'error'/.test(client) && /T\('err\.ws'/.test(client), '⛔ error 帧必须让用户看得见')
// 客户端常量自述必须与实测一致(⛔ 防"常量写一套、发送写另一套")。
for (const o of outUnique) assert.ok(client.includes(`'${o}'`), `出站 op ${o} 必须在常量区有自述`)
// ④ 保活:周期 + **计时起点 = 本连接自己的 open**(与 `src/im/keepalive.ts` 同口径)。
assert.ok(/WS_IDLE_TIMEOUT_MS = 26000/.test(client), '空窗必须与 `IM_GATEWAY_OBSERVED_IDLE_MS` 同值')
assert.ok(/WS_PING_MS = Math\.max\(1000, Math\.floor\(WS_IDLE_TIMEOUT_MS \/ WS_PING_CYCLES\)\)/.test(client),
'周期必须由「空窗 ÷ 周期数」推出(照 `planKeepalive()`)')
assert.ok(/let pingAnchorAt = openedAt/.test(client), '🔴 计时起点必须绑定本次 open 时刻')
assert.ok(/const openedAt = Date\.now\(\)/.test(client), '必须有本连接的 open 时刻锚点')
assert.ok(/nextPingAt = pingAnchorAt \+/.test(client), '死线必须由 anchor 推算(⛔ 不按上次发送漂移)')
assert.ok(/lastInboundAt > WS_IDLE_TIMEOUT_MS/.test(client), '半开链路检测:整窗无入向流量必须主动重连')
})
test('E-3b · 形态 B 代答标识渲染(不只靠作者名)', () => {
const src = clientSource()
assert.ok(/byAgentId/.test(src), '必须识别代答载荷(byAgentId)')
assert.ok(/onBehalfOf/.test(src), '必须识别代答对象(onBehalfOf)')
assert.ok(/imrt-agent/.test(src), '必须有代答标识的可见样式(徽章)')
assert.ok(/T\('agent\.badge', \{ name: agentName \}\)/.test(src), '代答徽章必须写「由 X 的助手代答」')
})
test('E-5b · 消息面闭环:游标拉取 + 发送 + @ 选择器', () => {
const src = clientSource()
assert.ok(/\/messages' \+ q/.test(src) || /\/messages\?/.test(src), '必须按 since 游标拉取')
assert.ok(/since=\$\{since\}&limit=200/.test(src), 'since 递增拉取(⛔ 无 OFFSET)')
assert.ok(/method: 'POST'/.test(src), '必须有发送(POST)路径')
assert.ok(/imrt-atList/.test(src), '@ 选择器必须渲染候选列表')
assert.ok(/insertAt\(m\)/.test(src), '@ 选择器必须能插入对象')
assert.ok(/lastIndexOf\('@'\)/.test(src), '@ 触发判据必须存在')
})
test('E-6c · 暗色/亮色跟随官方 design token(⛔ 不硬编码主题色)', () => {
const src = clientSource()
// 颜色必须走 --dsw-* token(跟随 dsh 主题);自研品牌色只允许作为 fallback。
assert.ok(/var\(--dsw-alias-label-primary\)/.test(src), '文本色必须走官方 token')
assert.ok(/var\(--dsw-alias-bg-base\)/.test(src), '背景色必须走官方 token')
assert.ok(/var\(--dsw-alias-border-l2/.test(src), '边框色必须走官方 token')
assert.ok(/--imtabs-brand,/.test(src), '自研品牌色必须**带 fallback**(token 缺失时仍可读)')
})
+327
View File
@@ -0,0 +1,327 @@
/**
* 覆盖网络 · **序47 S4/S5:服务器实例的设备凭据**(平台侧代签 + 投递 + 续签)单测。
*
* ## 这个文件要回答的六个问题
* 1. **首签**:凭据文件落在**实例 home**(`<dataRoot>/users/<id>/home/.overlay-device.json`)、
* `0600`、台账出 `kind='instance'` 条目、relay 密钥表新增该 hostId、registry 已批准,
* 且 grant 能被 `identity.ts` 的**验签路径**验过(⛔ 不是"写了个文件"就算过)。
* 2. **幂等**:租约充裕时再调 ⇒ `unchanged`,**一个字节都不写**(台账 `grant_renewals` 不动、
* 密钥表不出现新的 `*.bak-*`)。这条钉住"每次实例启动都续签一遍"的代价是 0。
* 3. **续签**:租约将尽 ⇒ 同 `hostId`、**同 hmacSecret**(⛔ 不轮换 ⇒ 不会把还在用旧文件的实例
* 自己拒掉),`grant_renewals + 1`,且 relay 密钥表**未被重写**(`relayKeyRotated=false`)。
* 4. **⛔ 不猜不重建**:文件形状非法 ⇒ 具名 `credential-file-corrupt`,**文件逐字节保持原样**、
* 台账不新增(重建会换 hostId ⇒ 绕过停发并多占配额位,见模块头注纪律 ③)。
* 5. **停发即拒**:台账 `status='revoked'` ⇒ `device-revoked`(403),文件保持原样。
* 6. **收编(纪律 ⑤)**:新签发改变 hostId 时,该用户**其它** `kind='instance'` 条目被置
* `revoked` 并从 registry 移除 ⇒ "删文件重建"这条处置不会静默留下孤儿设备 / 烧配额。
*
* 运行:`node --test test/instance-credential.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。
*
* @module test/instance-credential
*/
import assert from 'node:assert/strict'
import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
import {
ensureInstanceCredential,
parseInstanceCredential,
startInstanceCredentialRenewal,
INSTANCE_DEVICE_FILE,
} from '../lib/net/relay/instance-credential.js'
import { generateNodeKey, verifyPeerGrant } from '../lib/net/relay/identity.js'
import { loadKeysFile } from '../lib/net/relay/keys.js'
import { loadRegistry } from '../lib/net/relay/registry.js'
const USER = 'u47'
/* ─────────────────────────── 工具 ─────────────────────────── */
/**
* 设备台账(v12/v13 `overlay_devices`)**内存桩** —— 只复刻被判据依赖的四条语义:
* ① 首次插入 `grantRenewals = 1`,之后 **+1**;② `status` 不被续签覆盖(停发粘性);
* ③ `kind` 缺省 `desktop`;④ `setOverlayDeviceStatus` 对不存在的行回 `false`。
*/
function ledgerStub(seed = []) {
const rows = new Map()
for (const r of seed) rows.set(`${r.network}/${r.hostId}`, { kind: 'desktop', status: 'active', grantRenewals: 0, ...r })
return {
rows,
async findOverlayDevice(network, hostId) {
return rows.get(`${network}/${hostId}`)
},
async upsertOverlayDevice(input) {
const key = `${input.network}/${input.hostId}`
const at = input.at ?? Date.now()
const prev = rows.get(key)
const row = {
network: input.network,
hostId: input.hostId,
userId: input.userId,
nodeKey: input.nodeKey,
kind: input.kind ?? 'desktop',
status: prev?.status ?? 'active',
leaseExpiresAt: input.leaseExpiresAt,
grantIssuedAt: at,
grantRenewals: (prev?.grantRenewals ?? 0) + 1,
}
rows.set(key, row)
return row
},
async listOverlayDevices(network) {
return [...rows.values()].filter((r) => network === undefined || r.network === network)
},
async setOverlayDeviceStatus(network, hostId, status) {
const key = `${network}/${hostId}`
const prev = rows.get(key)
if (prev === undefined) return false
rows.set(key, { ...prev, status })
return true
},
}
}
/** 一组临时环境:签名者私钥 + 密钥表 + registry + 真 `LocalUserFs`(要验 0600 / 落点)。 */
function makeEnv(t, extraEnv = {}) {
const dir = mkdtempSync(join(tmpdir(), 'dshs-seq47-inst-'))
t.after(() => rmSync(dir, { recursive: true, force: true }))
const dataRoot = join(dir, 'data')
const signer = generateNodeKey()
const signerKeyFile = join(dir, 'signer-key.pem')
writeFileSync(signerKeyFile, signer.privateKeyPem, { mode: 0o600 })
const relayKeysFile = join(dir, 'relay-keys.json')
writeFileSync(relayKeysFile, '{}\n', { mode: 0o600 })
const vars = {
DSHS_OVERLAY_SIGNER_KEY_FILE: signerKeyFile,
DSHS_RELAY_KEYS_FILE: relayKeysFile,
DSHS_OVERLAY_NODES_FILE: join(dir, 'nodes.json'),
DESKTOP_GRANT_TTL_HOURS: '24',
DESKTOP_GRANT_MAX_DEVICES: '10',
...extraEnv,
}
for (const [k, v] of Object.entries(vars)) process.env[k] = v
const userFs = new LocalUserFs((id) => join(dataRoot, 'users', id))
const credFile = join(dataRoot, 'users', USER, 'home', INSTANCE_DEVICE_FILE)
return { dir, dataRoot, signer, signerKeyFile, relayKeysFile, userFs, credFile, vars }
}
/** 台账里的实例条目(`kind='instance'`)。 */
function instanceRows(db) {
return [...db.rows.values()].filter((r) => r.kind === 'instance')
}
function bakCount(dir) {
return readdirSync(dir).filter((f) => f.startsWith('relay-keys.json.bak-')).length
}
/* ─────────────────────────── 用例 ─────────────────────────── */
test('S4 首签:凭据落实例 home(0600)+ 台账 kind=instance + 密钥表 + registry + grant 可验签', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { reason: 'test' })
assert.equal(res.ok, true, JSON.stringify(res))
assert.equal(res.action, 'issued')
assert.equal(res.network, `u:${USER}`)
assert.equal(res.renewals, 1)
// ① 文件落在**实例 home**(不是 ws、不是平台目录),权限 0600,内容可解析
assert.ok(existsSync(env.credFile), `凭据文件应存在:${env.credFile}`)
const text = readFileSync(env.credFile, 'utf8')
const cred = parseInstanceCredential(text)
assert.notEqual(cred, undefined, '凭据文件应能被严格解析')
assert.equal(cred.hostId, res.hostId)
assert.equal(cred.kind, 'instance')
assert.equal(cred.version, 1)
if (process.platform !== 'win32') {
assert.equal(statSync(env.credFile).mode & 0o777, 0o600, '凭据必须 0600')
}
// ② hostId 口径 = `d-<userId>-<公钥前 8 位>`(§④ 已定规则,与用户态端点同源)
assert.equal(cred.hostId, `d-${USER}-${cred.nodeKey.slice(0, 8)}`)
assert.equal(cred.nodeKey.length, 64)
// ③ 台账:kind='instance' + active + 租约与回执一致
const rows = instanceRows(db)
assert.equal(rows.length, 1)
assert.equal(rows[0].status, 'active')
assert.equal(rows[0].nodeKey, cred.nodeKey)
assert.equal(new Date(rows[0].leaseExpiresAt).toISOString(), res.expiresAt)
// ④ relay 密钥表:新增该 hostId(否则实例的 HELLO 永远对不上 MAC)
const keys = loadKeysFile(env.relayKeysFile)
assert.ok(
[...keys.keys()].some((k) => k === `${res.network}/${res.hostId}`),
`密钥表应含 ${res.network}/${res.hostId},实得 ${[...keys.keys()].join(',')}`,
)
// ⑤ registry:已批准(⇒ 立刻进入该网的派生白名单)
const reg = loadRegistry(env.vars.DSHS_OVERLAY_NODES_FILE)
const node = reg.nodes[`${res.network}/${res.hostId}`]
assert.equal(node?.status, 'approved')
// ⑥ grant 能被**产品自己的验签路径**验过(network / hostId / nodeKey 三者都要对得上)
const verdict = verifyPeerGrant(cred.grant.doc, cred.grant.sig, {
trustedSignerKeys: [env.signer.publicKey],
network: res.network,
hostId: res.hostId,
nodeKey: cred.nodeKey,
})
assert.equal(verdict.ok, true, `grant 应验签通过,实得 ${JSON.stringify(verdict)}`)
// ⑦ ⛔ 回执 / 回执的序列化里不得出现密钥材料(本模块的返回值会进日志)
const dumped = JSON.stringify(res)
assert.ok(!dumped.includes('PRIVATE KEY'), '回执不得含私钥')
assert.ok(!dumped.includes(cred.hmacSecret), '回执不得含 hmacSecret')
})
test('S5 幂等:租约充裕 ⇒ unchanged,且一个字节都不写', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.action, 'issued')
const textBefore = readFileSync(env.credFile, 'utf8')
const baksBefore = bakCount(env.dir)
const again = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(again.ok, true)
assert.equal(again.action, 'unchanged')
assert.equal(again.hostId, first.hostId)
assert.equal(again.renewals, 1, '未续签 ⇒ grant_renewals 不应增长')
assert.equal(readFileSync(env.credFile, 'utf8'), textBefore, 'unchanged 不得改写文件')
assert.equal(bakCount(env.dir), baksBefore, 'unchanged 不得产生密钥表备份')
assert.equal(instanceRows(db).length, 1)
})
test('S5 续签:同 hostId + 同 hmacSecret,grant_renewals +1,密钥表不重写', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
const credBefore = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
const baksBefore = bakCount(env.dir)
const renewed = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { force: true, reason: 'test-force' })
assert.equal(renewed.ok, true, JSON.stringify(renewed))
assert.equal(renewed.action, 'renewed')
assert.equal(renewed.hostId, first.hostId, '续签必须同 hostId(⛔ 不换设备)')
assert.equal(renewed.renewals, 2, 'grant_renewals 应为 2')
assert.equal(renewed.relayKeyRotated, false, '复用同一把密钥 ⇒ 密钥表不应被重写')
const credAfter = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
assert.equal(credAfter.nodeKey, credBefore.nodeKey)
assert.equal(credAfter.nodePrivateKeyPem, credBefore.nodePrivateKeyPem)
assert.equal(credAfter.hmacSecret, credBefore.hmacSecret, '⛔ 不轮换 hmacSecret(否则旧进程被自己拒)')
assert.equal(bakCount(env.dir), baksBefore, '密钥表值未变 ⇒ 不得产生备份文件')
assert.equal(instanceRows(db).length, 1, '⛔ 不得出现第二条实例设备')
assert.equal(loadKeysFile(env.relayKeysFile).size, 1)
})
test('S5 定时续签:只扫 kind=instance 且 active 的租户网,桌面条目不动', async (t) => {
const env = makeEnv(t)
const stale = new Date(Date.now() - 60_000).toISOString()
const db = ledgerStub([
{ network: `u:${USER}`, hostId: `d-${USER}-aaaaaaaa`, userId: USER, nodeKey: 'a'.repeat(64), kind: 'instance', status: 'active', leaseExpiresAt: 0, grantRenewals: 3 },
{ network: 'u:desktopguy', hostId: 'd-desktopguy-bbbbbbbb', userId: 'desktopguy', nodeKey: 'b'.repeat(64), kind: 'desktop', status: 'active', leaseExpiresAt: 0, grantRenewals: 1 },
{ network: 'u:revoked', hostId: 'd-revoked-cccccccc', userId: 'revoked', nodeKey: 'c'.repeat(64), kind: 'instance', status: 'revoked', leaseExpiresAt: 0, grantRenewals: 1 },
{ network: 'ops', hostId: 'manager', userId: 'x', nodeKey: 'd'.repeat(64), kind: 'instance', status: 'active', leaseExpiresAt: 0, grantRenewals: 1 },
])
// 给 USER 铺一份"租约将尽"的真凭据(先签发,再把文件里的 expiresAt 改旧)
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.ok, true, JSON.stringify(first))
const cred = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
writeFileSync(env.credFile, JSON.stringify({ ...cred, expiresAt: stale }, null, 2) + '\n')
const handle = startInstanceCredentialRenewal({ db, userFs: env.userFs }, { intervalMs: 0 })
assert.equal(handle.armed, false, 'intervalMs=0 ⇒ 只留手动一遍')
const report = await handle.runOnce()
handle.stop()
assert.equal(report.scanned, 1, `只该扫到 u:${USER}(桌面 / 已停发 / 非租户网都不动),实得 ${report.scanned}`)
assert.equal(report.renewed, 1)
assert.equal(report.issued, 0)
assert.deepEqual(report.failed, [])
const after = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
assert.equal(after.hostId, cred.hostId)
assert.equal(Date.parse(after.expiresAt) > Date.now(), true, '续签后租约应重新变新')
// 台账里那些非 instance 的条目一个都没动
const desktopRow = [...db.rows.values()].find((r) => r.hostId === 'd-desktopguy-bbbbbbbb')
assert.equal(desktopRow.grantRenewals, 1)
})
test('⛔ 不猜不重建:文件形状非法 ⇒ 具名拒绝且文件逐字节保持原样', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.action, 'issued')
const rowsBefore = db.rows.size
for (const bad of ['{not json', '{}', JSON.stringify({ version: 1, kind: 'instance' })]) {
writeFileSync(env.credFile, bad)
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(res.ok, false, `应拒绝:${bad.slice(0, 24)}`)
assert.equal(res.reason, 'credential-file-corrupt')
assert.equal(readFileSync(env.credFile, 'utf8'), bad, '⛔ 拒绝路径不得改写文件')
assert.equal(db.rows.size, rowsBefore, '⛔ 拒绝路径不得新增台账条目')
}
// 公私钥不成对(改成另一把私钥)同样按 corrupt 处理 —— 否则故障会推迟到拨号时表现为"网络问题"
const cred = parseInstanceCredential(
JSON.stringify({
version: 1,
kind: 'instance',
network: `u:${USER}`,
hostId: `d-${USER}-00000000`,
nodeKey: 'e'.repeat(64),
nodePrivateKeyPem: generateNodeKey().privateKeyPem,
hmacSecret: 'f'.repeat(64),
grant: { doc: { v: 1 }, sig: 'x' },
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
renewals: 1,
}),
)
assert.equal(cred, undefined, '公私钥不成对应判为不可用')
})
test('停发即拒:台账 revoked ⇒ device-revoked(403),文件保持原样', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
const textBefore = readFileSync(env.credFile, 'utf8')
await db.setOverlayDeviceStatus(`u:${USER}`, first.hostId, 'revoked')
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { force: true })
assert.equal(res.ok, false)
assert.equal(res.reason, 'device-revoked')
assert.equal(res.status, 403)
assert.equal(readFileSync(env.credFile, 'utf8'), textBefore, '被停发的设备不得拿到新凭据')
})
test('纪律 ⑤ 收编:新签发换 hostId ⇒ 旧的实例条目被置 revoked 并退出 registry', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.action, 'issued')
// 模拟"凭据文件丢了"(我们自己写进处置流程的那条动作)⇒ 下一次 ensure 会重新签发
rmSync(env.credFile)
const again = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(again.ok, true, JSON.stringify(again))
assert.equal(again.action, 'issued')
assert.notEqual(again.hostId, first.hostId, '重建必然换 hostId(平台不留私钥副本)')
assert.deepEqual(again.superseded, [first.hostId], '旧条目应被点名收编')
const old = db.rows.get(`u:${USER}/${first.hostId}`)
assert.equal(old.status, 'revoked', '旧条目应被置 revoked')
assert.equal(old.kind, 'instance')
const reg = loadRegistry(env.vars.DSHS_OVERLAY_NODES_FILE)
assert.equal(reg.nodes[`u:${USER}/${first.hostId}`], undefined, '旧节点应已退出 registry 白名单')
assert.equal(reg.nodes[`u:${USER}/${again.hostId}`]?.status, 'approved')
assert.equal(instanceRows(db).length, 2, '台账保留旧行为审计痕迹(⛔ 不删行)')
})
+641
View File
@@ -0,0 +1,641 @@
/**
* 覆盖网络 · **序㊻ S0 用户态签发端点** + **遗留2 密钥表热加载** 单测。
*
* ## 这个文件要回答的四个问题
* 1. **鉴权等级**:不带会话 ⇒ `401`;带**普通**会话 ⇒ `200` 且拿得到 grant。
* 2. **admin 不豁免**:带 **admin** 会话打到配额上限 ⇒ **同样 `429`**(§⑤ 步骤 5 的第三条判据)。
* ⚠️ 本端点的网名 / hostId **只从会话推导** ⇒ "给别人的网签发"**结构上不可能**;本用例钉住这一点。
* 3. **凭据自洽**:返回的 `grant` 能被**受信签名者公钥**验通,且 `network/hostId/nodeKey` 三者
* 与回执声明**逐字一致**(⛔ 不是"签了个能验的东西"就算过)。
* 4. **遗留 2(热加载)**:设备凭据写进 `relay-keys.json` 后,relay **不重启**就能认(= §① 终态
* 「人工零介入」的前提)。判据 = **同一次进程内**先拒后通,且 `/status` 的 `keyReloads` 涨了。
*
* 运行:`node --test test/overlay-device.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
*
* @module test/overlay-device
*/
import assert from 'node:assert/strict'
import { mkdtempSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { test } from 'node:test'
import Fastify from 'fastify'
import { generateNodeKey, normalizePublicKey, nodeKeyFingerprint, verifyNodeGrant } from '../lib/net/relay/identity.js'
import { loadKeysFile, parseKeysInline } from '../lib/net/relay/keys.js'
import { loadRegistry } from '../lib/net/relay/registry.js'
import { RelayClient } from '../lib/net/relay/client.js'
import { RelayServer } from '../lib/net/relay/server.js'
import { hashSessionToken } from '../lib/web/auth.js'
import { overlayDeviceRoutes } from '../lib/web/routes/overlay-device.js'
import { overlayNodeRoutes } from '../lib/web/routes/overlay-nodes.js'
const BASE = 48200
const SPAN = 120
const PATH = '/dshs-relay'
/* ─────────────────────────── 工具 ─────────────────────────── */
function tmpDir() {
return mkdtempSync(join(tmpdir(), 'dshs-seq46-s0-'))
}
function envOf(dir, over = {}) {
const signer = generateNodeKey()
const signerKeyFile = join(dir, 'signer-key.pem')
writeFileSync(signerKeyFile, signer.privateKeyPem, { mode: 0o600 })
return {
signer,
signerKeyFile,
vars: {
DSHS_OVERLAY_SIGNER_KEY_FILE: signerKeyFile,
DSHS_RELAY_KEYS_FILE: join(dir, 'relay-keys.json'),
DSHS_OVERLAY_NODES_FILE: join(dir, 'nodes.json'),
DESKTOP_GRANT_TTL_HOURS: '24',
DESKTOP_GRANT_MAX_DEVICES: '10',
...over,
},
}
}
/** 用一组 env 起一个**只挂本路由**的 Fastify(`db` 用桩 —— 本步测的是端点,不是数据库)。 */
async function makeApp(dir, envVars, sessions, extraDb = {}) {
for (const [k, v] of Object.entries(envVars)) process.env[k] = v
const app = Fastify()
app.decorate('db', {
findSessionWithUser: async (tokenHash) => sessions.get(tokenHash),
// 默认给一份**空的**内存台账 ⇒ 每个用例都从"这台设备还没登记过"开始;
// 需要预置 / 注入失败的用例用 `extraDb` 覆盖(后写者胜)。
...ledgerStub(),
...extraDb,
})
await app.register(overlayDeviceRoutes)
await app.ready()
return app
}
/**
* **设备台账(v12 `overlay_devices`)的内存桩** —— 只复刻 SQL 层那三条被判据依赖的语义:
* ① 首次插入 `grantRenewals = 1`,之后每次 **+1**;
* ② **`status` 不被续签覆盖**(停发是黏性的);
* ③ `setOverlayDeviceStatus` 对不存在的行返回 `false`(⇒ 端点必须回具名 404)。
*
* ⚠️ 它**不**代替 `test/db.test.mjs` 对真 SQL 的覆盖(那是另一层)。
*/
function ledgerStub() {
const rows = new Map()
return {
rows,
async findOverlayDevice(network, hostId) {
return rows.get(`${network}/${hostId}`)
},
async upsertOverlayDevice(input) {
const key = `${input.network}/${input.hostId}`
const at = input.at ?? Date.now()
const prev = rows.get(key)
const row = {
network: input.network,
hostId: input.hostId,
userId: input.userId,
nodeKey: input.nodeKey,
status: prev?.status ?? 'active',
leaseExpiresAt: input.leaseExpiresAt,
grantIssuedAt: at,
grantRenewals: (prev?.grantRenewals ?? 0) + 1,
createdAt: prev?.createdAt ?? at,
updatedAt: at,
}
rows.set(key, row)
return row
},
async listOverlayDevices(network) {
return [...rows.values()].filter((r) => network === undefined || r.network === network)
},
async setOverlayDeviceStatus(network, hostId, status) {
const key = `${network}/${hostId}`
const prev = rows.get(key)
if (prev === undefined) return false
rows.set(key, { ...prev, status, updatedAt: Date.now() })
return true
},
}
}
function session(user, token = randomBytes(8).toString('hex')) {
return {
token,
cookie: `sid=${token}`,
row: {
expiresAt: Date.now() + 3_600_000,
user: {
id: user.id,
username: user.username ?? user.id,
role: user.role ?? 'user',
created_at: Date.now(),
shared_model_granted: 0,
},
},
}
}
/* ────────────────── ①/②/③ 端点:鉴权 / 配额 / 凭据自洽 ────────────────── */
test('S0-1 不带会话 ⇒ 401;带普通会话 ⇒ 200,且 grant 验通+三字段与回执一致', async (t) => {
const dir = tmpDir()
const { signer, vars } = envOf(dir)
const s = session({ id: 'u-1001' })
const app = await makeApp(dir, vars, new Map([[hashSessionToken(s.token), s.row]]))
t.after(() => app.close())
// ⛔ 先打不带会话那一发 —— 它是"这个端点确实是用户态鉴权"的**唯一**证据。
const anon = await app.inject({ method: 'POST', url: '/api/overlay/device-grant', payload: { nodeKey: 'x' } })
assert.equal(anon.statusCode, 401, `不带会话应当 401(实际 ${anon.statusCode})`)
const node = generateNodeKey()
const ok = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(ok.statusCode, 200, `带会话应当 200(实际 ${ok.statusCode}:${ok.body})`)
const body = ok.json()
// ③ 凭据自洽:用**受信签名者公钥**验签,并逐字段比对回执声明。
assert.equal(body.network, 'u:u-1001')
assert.equal(body.hostId, `d-u-1001-${node.publicKey.slice(0, 8)}`)
// 🔴 形状判据:`grant` 必须是 `{doc, sig}`(= 仓内既有"签好文件"约定)
// ⇒ 设备**直接落盘**即可被 `loadClientIdentity` 认,⛔ 不需要客户端再拼一次。
assert.deepEqual(Object.keys(body.grant).sort(), ['doc', 'sig'])
const verdict = verifyNodeGrant(body.grant.doc, body.grant.sig, [signer.publicKey])
assert.equal(verdict.ok, true, `grant 应当验通(${JSON.stringify(verdict)})`)
assert.equal(verdict.doc.network, body.network)
assert.equal(verdict.doc.hostId, body.hostId)
assert.equal(verdict.doc.nodeKey, normalizePublicKey(node.publicKey))
// 租约:`expiresAt` 必须晚于签发时刻(⛔ 空串 = 永不过期,设备形态**不许**那样)
assert.ok(body.grant.doc.expiresAt !== '', '设备 grant 必须带过期时间(空串 = 不过期)')
assert.ok(Date.parse(body.grant.doc.expiresAt) > Date.parse(body.grant.doc.issuedAt))
// ③ 三处落地:relay 密钥表 + 设备台账
const keys = loadKeysFile(vars.DSHS_RELAY_KEYS_FILE)
const logical = `u:u-1001/${body.hostId}`
assert.equal(keys.has(logical), true, `relay 密钥表应含 ${logical}(实际 ${[...keys.keys()].join(',')})`)
assert.match(keys.get(logical).secret, /^[0-9a-f]{64}$/)
assert.equal(keys.get(logical).secret, body.hmacSecret)
const reg = loadRegistry(vars.DSHS_OVERLAY_NODES_FILE)
assert.equal(reg.nodes[logical]?.status, 'approved')
assert.equal(reg.nodes[logical]?.nodeKey, normalizePublicKey(node.publicKey))
// ② 幂等:**同一**设备重签不占新位(重装 / 轮换密钥的正常路径)
const again = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(again.statusCode, 200, `同一 hostId 重签应当 200(实际 ${again.statusCode}:${again.body})`)
assert.equal(again.json().devices.used, 1, '同一 hostId 重签**不该**多占一个位')
assert.notEqual(again.json().hmacSecret, body.hmacSecret, '重签应当轮换密钥(旧密钥作废)')
})
test('S0-2 配额:admin 会话**同样**受本用户配额约束(§⑤ 步骤5 第三条判据)', async (t) => {
const dir = tmpDir()
const { vars } = envOf(dir, { DESKTOP_GRANT_MAX_DEVICES: '1' })
const admin = session({ id: 'u-admin', role: 'admin' })
const app = await makeApp(dir, vars, new Map([[hashSessionToken(admin.token), admin.row]]))
t.after(() => app.close())
const a = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: generateNodeKey().publicKey },
headers: { cookie: admin.cookie },
})
assert.equal(a.statusCode, 200, `第 1 台(上限 1)应当 200(实际 ${a.statusCode}:${a.body})`)
const b = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: generateNodeKey().publicKey },
headers: { cookie: admin.cookie },
})
assert.equal(b.statusCode, 429, `admin 第 2 台也应当 429(实际 ${b.statusCode}:${b.body})`)
assert.equal(b.json().error, 'device-quota-exceeded')
assert.equal(b.json().max, 1)
// ② 结构性判据:回执里的网名**只可能**是 admin 自己的网 ⇒ 请求体里没有可填的网名。
assert.equal(a.json().network, 'u:u-admin')
})
test('S0-3 形状错一律具名,⛔ 不吞:bad-body / bad-node-key / signer-key-unavailable', async (t) => {
const dir = tmpDir()
const { vars } = envOf(dir)
const s = session({ id: 'u-1002' })
const app = await makeApp(dir, vars, new Map([[hashSessionToken(s.token), s.row]]))
t.after(() => app.close())
const noBody = await app.inject({ method: 'POST', url: '/api/overlay/device-grant', headers: { cookie: s.cookie } })
assert.equal(noBody.statusCode, 400)
assert.equal(noBody.json().error, 'bad-body')
const badKey = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: 'not-a-key' },
headers: { cookie: s.cookie },
})
assert.equal(badKey.statusCode, 400)
assert.equal(badKey.json().error, 'bad-node-key')
// 签名者私钥读不到 ⇒ **503 具名**(⛔ 不是"先发一份不带签名的凭据")
process.env.DSHS_OVERLAY_SIGNER_KEY_FILE = join(dir, 'does-not-exist.pem')
const noSigner = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: generateNodeKey().publicKey },
headers: { cookie: s.cookie },
})
assert.equal(noSigner.statusCode, 503)
assert.equal(noSigner.json().error, 'signer-key-unavailable')
})
/* ─────────────────── ④ 遗留2:密钥表热加载(relay 不重启) ─────────────────── */
/** 热加载判据看 `mtimeMs` ⇒ 写文件前后必须**跨过一毫秒**,否则指纹相同会被跳过。 */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function waitFor(fn, ms = 5000) {
const end = Date.now() + ms
while (Date.now() < end) {
if (await fn()) return true
await sleep(50)
}
return false
}
test('S0-4 遗留2:密钥表热加载 —— 同一次 relay 进程内,新设备**不重启**即可认证', async (t) => {
const dir = tmpDir()
const keysFile = join(dir, 'relay-keys.json')
const known = randomBytes(32).toString('hex')
const fresh = randomBytes(32).toString('hex')
writeFileSync(keysFile, `${JSON.stringify({ 'ops/known': known }, null, 2)}\n`, { mode: 0o600 })
const logs = []
const srv = new RelayServer({
port: 0,
keys: loadKeysFile(keysFile), // 构造时只有 `known`
keysFile, // 🔴 本用例的被测项
/**
* 🔴 **白名单从头到尾都是满的**(`known` + `fresh`)—— A/B 的**唯一变量是密钥表**。
* 这样"第 ② 步被拒"就只可能是 `unknown-host`(密钥表没认),而不是 `no-ports`(白名单没列)。
* ⚠️ 老写法扁平 `Set<hostId>` ⇒ 都算 `ops` 网(R5 语义),与密钥表的 `ops/…` 键对齐。
*/
dialers: new Set(['known', 'fresh']),
instancePortBase: BASE,
instancePortSpan: SPAN,
log: (l) => logs.push(l),
})
await srv.start()
t.after(async () => {
await srv.stop()
})
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
/**
* 以**拨号方形态**拨一次,返回 `{settled, state, lastError}`。
*
* 🔴 拨号方 = `ports: []` + `dialer: true`(序46 步骤1 发现1:`main.js --client` 那条路
* **表达不了**设备形态,会撞 `dialer-must-not-declare-ports`)⇒ ⛔ 别照 `--ports` 写。
* ⚠️ **先取状态、后 stop**(反过来的话拨通了也拿不到 `up`)。
*/
async function dial(hostId, secret) {
const client = new RelayClient({ url, hostId, secret, ports: [], dialer: true, log: () => {} })
client.start()
const settled = await waitFor(() => client.status().state === 'up' || client.status().lastError !== undefined, 5000)
const st = client.status()
const out = { settled, state: st.state, lastError: st.lastError }
client.stop()
return out
}
// ① 已在表里的 `known` ⇒ 通(证明这条通道本身是活的,⛔ 免得把"压根不通"当成热加载成功)
const okKnown = await dial('known', known)
assert.equal(okKnown.state, 'up', `表内设备应当拨通(${JSON.stringify(okKnown)})`)
assert.equal(srv.status().counters.keyReloads, 0, '文件未变 ⇒ **不该**发生重载(稳态零成本)')
// ② 表里没有的新设备 ⇒ 拒 `unknown-host`(**这是"它确实还没进来"的证据**)
const denied = await dial('fresh', fresh)
assert.notEqual(denied.state, 'up', `表外设备不该拨通(${JSON.stringify(denied)})`)
assert.match(String(denied.lastError), /unknown-host/, `应当是 unknown-host 而不是别的(${denied.lastError})`)
// ③ **控制面**把新设备写进同一个文件(这里直接写,等价于 S0 端点的第 ④ 步)——
// ⚠️ 必须改 `mtimeMs`:同毫秒内写完再读会因指纹相同而被跳过(判据是 `mtimeMs:size`)。
await sleep(15)
writeFileSync(keysFile, `${JSON.stringify({ 'ops/known': known, 'ops/fresh': fresh }, null, 2)}\n`, { mode: 0o600 })
// ④ **不重启**再拨 ⇒ 必须通(热加载生效)
const okFresh = await dial('fresh', fresh)
assert.equal(okFresh.state, 'up', `热加载后新设备应当拨通(${JSON.stringify(okFresh)},日志 ${logs.join(' | ')})`)
assert.ok(srv.status().counters.keyReloads >= 1, '`keyReloads` 必须涨(判别器:不涨 ⇒ 文件没被读到)')
assert.ok(
logs.some((l) => l.includes('密钥表热加载')),
`启动/重载日志应当点名它(实际:${logs.join(' | ')})`,
)
// ⑤ **失败关闭**:文件写坏 ⇒ 保留旧表(`keyReloadFails` 涨,但**已在册的老设备照旧能进**)
await sleep(15)
writeFileSync(keysFile, '{ 这不是 JSON', { mode: 0o600 })
const stillOk = await dial('known', known)
assert.equal(stillOk.state, 'up', `读坏文件**不该**把老设备一起拒掉(${JSON.stringify(stillOk)})`)
assert.ok(srv.status().counters.keyReloadFails >= 1, '`keyReloadFails` 必须涨(判别器)')
})
test('S0-5 范围:未配 keysFile ⇒ 表**静态**(改造前语义一字不改)', async (t) => {
const dir = tmpDir()
const keysFile = join(dir, 'relay-keys.json')
const known = randomBytes(32).toString('hex')
writeFileSync(keysFile, `${JSON.stringify({ 'ops/known': known })}\n`, { mode: 0o600 })
const srv = new RelayServer({
port: 0,
keys: parseKeysInline(`ops/known:${known}`),
instancePortBase: BASE + 20,
instancePortSpan: SPAN,
log: () => {},
})
await srv.start()
t.after(async () => {
await srv.stop()
})
assert.equal(srv.status().counters.keyReloads, 0)
assert.equal(srv.status().counters.keyReloadFails, 0)
})
/* ─────────────────── 纯函数:hostId 规则与验收口径一致 ─────────────────── */
test('S0-6 hostId 规则 = `d-<userId>-<公钥前 8 位>`(§④ 已定,⛔ 不另立)', async () => {
const node = generateNodeKey()
const app = await import('../lib/web/routes/overlay-device.js')
assert.equal(app.deviceHostIdOf('u-9', node.publicKey), `d-u-9-${node.publicKey.slice(0, 8)}`)
assert.equal(app.tenantNetworkOf('u-9'), 'u:u-9')
// 指纹存在且为 16 hex(用于日志对账,⛔ 不参与准入)
assert.match(nodeKeyFingerprint(node.publicKey), /^[0-9a-f]{16}$/)
// 🔴 白盒:配额与外置键的**中性默认**不许被改成"无限"
assert.equal(app.DEVICE_GRANT_MAX_DEVICES_DEFAULT, 10)
assert.equal(app.DEVICE_GRANT_TTL_HOURS_DEFAULT, 24)
})
/** 保证本文件的 tmp 目录不会把 `readFileSync` 的懒加载路径掩盖掉(写盘即可见)。 */
test('S0-7 落盘可读:registry 与 relay 密钥表都**只**有公钥 / 密钥,⛔ 无节点私钥', async (t) => {
const dir = tmpDir()
const { vars } = envOf(dir)
const s = session({ id: 'u-1003' })
const app = await makeApp(dir, vars, new Map([[hashSessionToken(s.token), s.row]]))
t.after(() => app.close())
const node = generateNodeKey()
const r = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(r.statusCode, 200)
const regText = readFileSync(vars.DSHS_OVERLAY_NODES_FILE, 'utf8')
assert.equal(regText.includes('PRIVATE'), false, '注册表里不该出现私钥')
assert.equal(regText.includes(node.privateKeyPem.slice(0, 32)), false, '注册表里不该出现节点私钥')
})
/* ────────── ⑤⑥ S3:设备台账(v12)+ 停发 + 续签语义(§⑤ 步骤 6)────────── */
test('S0-8 台账:首次签发 renewals=1;同设备重签 ⇒ renewals=2 且 lease 后移、不占新配额位', async (t) => {
const dir = tmpDir()
const { vars } = envOf(dir, { DESKTOP_GRANT_TTL_HOURS: '24' })
const s = session({ id: 'u-2001' })
const ledger = ledgerStub()
const app = await makeApp(dir, vars, new Map([[hashSessionToken(s.token), s.row]]), ledger)
t.after(() => app.close())
const node = generateNodeKey()
const first = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(first.statusCode, 200, `首次签发应当 200(实际 ${first.statusCode}:${first.body})`)
const b1 = first.json()
assert.equal(b1.renewals, 1, '首次签发 renewals 应当是 1')
assert.equal(b1.ledger.status, 'active')
assert.equal(b1.ledger.table, 'overlay_devices')
// 台账与 grant 的租约必须**同一事实**(⛔ 不许"台账说 24h、grant 说别的")
assert.equal(b1.ledger.leaseExpiresAt, b1.grant.doc.expiresAt, '台账租约必须与 grant 的 expiresAt 逐字一致')
// ② 续签:同一 hostId 再来一次
await sleep(30)
const second = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(second.statusCode, 200)
const b2 = second.json()
assert.equal(b2.renewals, 2, `续签必须 renewals+1(实际 ${b2.renewals})`)
assert.equal(b2.devices.used, 1, '续签**不该**多占配额位')
assert.ok(
Date.parse(b2.ledger.leaseExpiresAt) > Date.parse(b1.ledger.leaseExpiresAt),
'续签必须把租约**往后推**(否则"续签"没意义)',
)
assert.equal(ledger.rows.size, 1, '续签 = 更新同一行,⛔ 不是插新行')
})
test('S0-9 停发(S3):台账 status=revoked ⇒ 403 device-revoked,且**不**轮换 relay 密钥', async (t) => {
const dir = tmpDir()
const { vars } = envOf(dir)
const s = session({ id: 'u-2002' })
const ledger = ledgerStub()
const app = await makeApp(dir, vars, new Map([[hashSessionToken(s.token), s.row]]), ledger)
t.after(() => app.close())
const node = generateNodeKey()
const ok = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(ok.statusCode, 200)
const issued = ok.json()
const logical = 'u:u-2002/' + issued.hostId
// 停发(管理面动作;这里直接调台账桩,等价于那个端点的效果)
const before = loadKeysFile(vars.DSHS_RELAY_KEYS_FILE)
const secretBefore = before.get(logical).secret
assert.equal(await ledger.setOverlayDeviceStatus(issued.network, issued.hostId, 'revoked'), true)
const denied = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(denied.statusCode, 403, `停发后应当 403(实际 ${denied.statusCode}:${denied.body})`)
assert.equal(denied.json().error, 'device-revoked')
// ⛔ 停发不是"再发一份":relay 密钥表**一字未动**
const after = loadKeysFile(vars.DSHS_RELAY_KEYS_FILE)
assert.equal(after.get(logical).secret, secretBefore, '停发后**不该**轮换密钥(那等于还在服务它)')
// 恢复 ⇒ 又能签(恢复的唯一入口是显式状态变更)
assert.equal(await ledger.setOverlayDeviceStatus(issued.network, issued.hostId, 'active'), true)
const back = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(back.statusCode, 200, `恢复后应当 200(实际 ${back.statusCode}:${back.body})`)
assert.equal(back.json().renewals, 2, '恢复后再签 = 第 2 次(renewals 继续累加,⛔ 不重置)')
})
test('S0-10 台账写失败 ⇒ 500 具名,且 relay 密钥表**没有**新增(顺序:台账先于密钥表)', async (t) => {
const dir = tmpDir()
const { vars } = envOf(dir)
const s = session({ id: 'u-2003' })
const app = await makeApp(dir, vars, new Map([[hashSessionToken(s.token), s.row]]), {
findOverlayDevice: async () => undefined,
upsertOverlayDevice: async () => {
throw new Error('注入的台账写失败')
},
})
t.after(() => app.close())
const node = generateNodeKey()
const r = await app.inject({
method: 'POST',
url: '/api/overlay/device-grant',
payload: { nodeKey: node.publicKey },
headers: { cookie: s.cookie },
})
assert.equal(r.statusCode, 500, `台账写失败应当 500(实际 ${r.statusCode}:${r.body})`)
assert.equal(r.json().error, 'device-ledger-write-failed')
// 🔴 顺序判据:台账在密钥表**之前** ⇒ 这条路上**没有**可用凭据被签发出去
// (文件可能**压根没建** —— 那也是"没写"的证据,⛔ 别把 ENOENT 当成测试错误)
const keys = existsSync(vars.DSHS_RELAY_KEYS_FILE) ? loadKeysFile(vars.DSHS_RELAY_KEYS_FILE) : new Map()
const logical = 'u:u-2003/d-u-2003-' + node.publicKey.slice(0, 8)
assert.equal(keys.has(logical), false, `台账失败时**不该**已经写好密钥(实际 ${[...keys.keys()].join(',')})`)
})
/* ─────────────── 管理面:设备台账只读 + 停发(requireAdmin)─────────────── */
test('S0-11 管理面台账:GET 只读列表(含指纹/租约/是否过期)+ 未鉴权 401 + 非 admin 403', async (t) => {
const ledger = ledgerStub()
const seeded = {
network: 'u:u-9',
hostId: 'd-u-9-abcdef12',
userId: 'u-9',
nodeKey: 'ab'.repeat(32),
leaseExpiresAt: Date.now() + 3_600_000,
}
await ledger.upsertOverlayDevice(seeded)
await ledger.upsertOverlayDevice({ ...seeded, leaseExpiresAt: Date.now() + 7_200_000 })
const user = session({ id: 'u-9', role: 'user' })
const admin = session({ id: 'u-admin', role: 'admin' })
const sessions = new Map([
[hashSessionToken(user.token), user.row],
[hashSessionToken(admin.token), admin.row],
])
const app = Fastify()
app.decorate('db', {
findSessionWithUser: async (h) => sessions.get(h),
...ledger,
})
await app.register(overlayNodeRoutes)
await app.ready()
t.after(() => app.close())
const anon = await app.inject({ method: 'GET', url: '/api/admin/overlay-devices' })
assert.equal(anon.statusCode, 401, '不带会话应当 401')
const nonAdmin = await app.inject({
method: 'GET',
url: '/api/admin/overlay-devices',
headers: { cookie: user.cookie },
})
assert.equal(nonAdmin.statusCode, 403, '普通用户应当 403(台账含归属 ⇒ 管理面)')
const ok = await app.inject({
method: 'GET',
url: '/api/admin/overlay-devices',
headers: { cookie: admin.cookie },
})
assert.equal(ok.statusCode, 200, `admin 应当 200(实际 ${ok.statusCode}:${ok.body})`)
const body = ok.json()
assert.equal(body.table, 'overlay_devices')
assert.equal(body.total, 1)
assert.equal(body.devices[0].grantRenewals, 2, '续签两次 ⇒ 台账里应当是 2')
assert.equal(body.devices[0].expired, false)
assert.match(body.devices[0].nodeKeyFingerprint, /^[0-9a-f]{16}$/, '⛔ 台账接口**不回**公钥原文,只回指纹')
assert.equal(JSON.stringify(body).includes('ab'.repeat(32)), false, '⛔ 响应里不许出现 nodeKey 原文')
})
test('S0-12 管理面停发:revoked 生效 + 续签不许复活;不存在的行 ⇒ 具名 404(⛔ 不静默成功)', async (t) => {
const ledger = ledgerStub()
const seeded = {
network: 'u:u-9',
hostId: 'd-u-9-abcdef12',
userId: 'u-9',
nodeKey: 'cd'.repeat(32),
leaseExpiresAt: Date.now() + 3_600_000,
}
await ledger.upsertOverlayDevice(seeded)
const admin = session({ id: 'u-admin', role: 'admin' })
const app = Fastify()
app.decorate('db', {
findSessionWithUser: async (h) => (h === hashSessionToken(admin.token) ? admin.row : undefined),
...ledger,
})
await app.register(overlayNodeRoutes)
await app.ready()
t.after(() => app.close())
const rev = await app.inject({
method: 'POST',
url: '/api/admin/overlay-devices/revoke',
payload: { network: 'u:u-9', hostId: 'd-u-9-abcdef12' },
headers: { cookie: admin.cookie },
})
assert.equal(rev.statusCode, 200, `停发应当 200(实际 ${rev.statusCode}:${rev.body})`)
assert.equal(rev.json().status, 'revoked')
assert.equal(ledger.rows.get('u:u-9/d-u-9-abcdef12').status, 'revoked', '台账必须真的改了')
// ⚠️ 停发后**续签也不能复活**(SQL 层不给 status 更新列 ⇒ 桩同样复刻了这条)
await ledger.upsertOverlayDevice({ ...seeded, leaseExpiresAt: Date.now() + 3_600_000 })
assert.equal(
ledger.rows.get('u:u-9/d-u-9-abcdef12').status,
'revoked',
'⚠️ 续签**不许**让被停发的设备自动复活(停发是黏性的)',
)
const miss = await app.inject({
method: 'POST',
url: '/api/admin/overlay-devices/revoke',
payload: { network: 'u:u-9', hostId: 'd-u-9-00000000' },
headers: { cookie: admin.cookie },
})
assert.equal(miss.statusCode, 404, `不存在的设备应当 404(实际 ${miss.statusCode}:${miss.body})`)
assert.equal(miss.json().error, 'device-not-found')
const bad = await app.inject({
method: 'POST',
url: '/api/admin/overlay-devices/revoke',
payload: { network: 'u:u-9', hostId: 'd-u-9-abcdef12', status: 'whatever' },
headers: { cookie: admin.cookie },
})
assert.equal(bad.statusCode, 400)
assert.equal(bad.json().error, 'bad-status')
})
+66 -7
View File
@@ -40,6 +40,7 @@ import {
DIRECT_OFF_VALUES,
DirectCooldown,
DirectPath,
describeDialers,
NODES_REGISTRY_VERSION,
PUNCH_PORT_BASE,
PUNCH_PORT_SPAN,
@@ -212,15 +213,31 @@ test('T4 准入策略**复用** server.ts 的那一条(⛔ 防两处写分叉
const netSrc = readFileSync(ssPath, 'utf8')
const srvSrc = readFileSync(serverPath, 'utf8')
// ① 唯一出口确实在 network.ts 里,且语义就是"该网桶里有没有这个 hostId"
// ① 唯一出口确实在 network.ts 里,且语义就是"该网桶里有没有这个 hostId / 是否整网放行"
assert.match(netSrc, /export function isAllowedDialer\(/)
assert.match(netSrc, /return map\.get\(network\)\?\.has\(hostId\) === true/)
assert.match(netSrc, /bucket\.has\(DIALER_WILDCARD\)/, '网级通配(序㊻ S2)应在唯一出口里判')
assert.match(netSrc, /if \(bucket\.has\(hostId\)\) return true/, '逐设备命中仍在唯一出口里判')
assert.match(
netSrc,
/TENANT_NETWORK_WILDCARD\)\?\.has\(DIALER_WILDCARD\)/,
'租户网全域兜底(序㊻ 步骤6/遗留1)必须在**唯一出口**里判,⛔ 不许在调用方另判一处',
)
// ② server.ts 的两处闸门与它**同策略**(同 map 、同 `=== true` 默认拒绝)
const registerGate = srvSrc.includes('const wantDialer = this.dialers.get(network)?.has(hostId) === true')
const dialGate = srvSrc.includes('if (this.dialers.get(session.network)?.has(session.hostId) !== true) {')
assert.ok(registerGate, '注册闸门(server.ts)应仍是 dialers.get(network)?.has(hostId) === true(⛔ 本棒不许改它的语义)')
assert.ok(dialGate, 'DIAL 闸门(server.ts)应仍是 dialers.get(session.network)?.has(session.hostId) !== true')
// ② server.ts 的两处闸门**调用**它(序㊻ S2 起:等价性 = 同一份代码,⛔ 不再是"两份逐字相同")
assert.ok(
srvSrc.includes('const wantDialer = isAllowedDialer(this.dialers, network, hostId)'),
'注册闸门(server.ts)应调用 isAllowedDialer(this.dialers, network, hostId)',
)
assert.ok(
srvSrc.includes('if (!isAllowedDialer(this.dialers, session.network, session.hostId)) {'),
'DIAL 闸门(server.ts)应调用 isAllowedDialer(this.dialers, session.network, session.hostId)',
)
// ②b ⛔ 反证:server.ts 里**不许**再出现内联形态 —— 否则网级通配会"漏改一处"(本线老病根)
assert.ok(
!/this\.dialers\.get\([^)]*\)\?\.has\(/.test(srvSrc),
'server.ts 里又出现了内联白名单判定 dialers.get(...)?.has(...) ⇒ 序㊻ S2 之后它必须走唯一出口',
)
// ③ 直连模块**不许**自己再判一遍(⛔ 防"同一事实两处写")
for (const f of ['direct/candidate.ts', 'direct/index.ts', 'direct/punch.ts']) {
@@ -232,6 +249,48 @@ test('T4 准入策略**复用** server.ts 的那一条(⛔ 防两处写分叉
// ④ 等价性:默认拒绝(空桶 / 未列网络 / 名字错)—— 与 server.ts 同为"默认拒绝"
assert.equal(isAllowedDialer(new Map(), 'ops', 'manager'), false)
assert.equal(isAllowedDialer(dialers(), 'ops', 'MANAGER'), false, 'hostId 大小写敏感(与 HOST_RE 口径一致)')
// ⑤ 网级通配(序㊻ S2):只对**写了通配的那张网**放行,⛔ 不外溢到别的网
const wild = normalizeDialers(new Set(['ops:manager', 'u:5/*']))
assert.equal(isAllowedDialer(wild, 'u:5', 'd-any-brand-new-device'), true, '通配网里:未枚举的 hostId 也可拨')
assert.equal(isAllowedDialer(wild, 'ops', 'w-2'), false, '⛔ 通配不外溢:ops 桶里没有 w-2 就是拒')
assert.equal(isAllowedDialer(wild, 'u:7', 'd-any'), false, '⛔ 通配不外溢:没列到的网一律拒')
assert.deepEqual(describeDialers(wild).sort(), ['manager', 'u:5/*'], '通配在 /status 里要看得见')
/**
* ⑥ **租户网全域兜底**(序㊻ 步骤 6/遗留1):配一条「网名位 `u:*` + hostId 位 `*`」(等价于
* 分桶形态的 `u:*` 桶)⇒ **没被单独列举过**的租户网也放行。
*
* 这条要解决的是上一棒实测到的"零介入"硬缺口:**租户 id 是运行时才生成的**(`users.id`),
* 所以"把新租户网加进白名单"在原理上做不到 ⇒ 只能表达为"任何租户网"。
*/
const tenantAll = normalizeDialers(new Set(['ops:manager', 'u:*/*']))
assert.equal(
isAllowedDialer(tenantAll, 'u:brand-new-tenant-id', 'd-brand-new-device'),
true,
'未列举过的租户网也应命中(这条就是"新用户零介入"的准入面依据)',
)
assert.equal(isAllowedDialer(tenantAll, 'u', 'd-1'), false, '⛔ 裸 `u` 不是合法租户网 ⇒ 兜底不生效(保留字仍被挡住)')
assert.equal(isAllowedDialer(tenantAll, 'ops', 'w-2'), false, '⛔ 兜底**不外溢到 ops**(结构性:只对租户网生效)')
assert.equal(isAllowedDialer(tenantAll, 'named-net', 'w-2'), false, '⛔ 兜底不外溢到命名网')
assert.equal(isAllowedDialer(normalizeDialers(new Set(['ops:manager'])), 'u:5', 'd-1'), false, '⛔ 没写兜底就照旧默认拒绝')
// 取**并集**语义:精确条目与兜底互不覆盖(⛔ 不会"兜底把 ops 的条目压掉")
assert.equal(isAllowedDialer(tenantAll, 'ops', 'manager'), true, '精确条目在兜底存在时照旧生效')
assert.deepEqual(describeDialers(tenantAll).sort(), ['manager', 'u:*/*'], '兜底在 /status 里要看得见(一眼能确认它开着)')
// ⑦ 非法用法**装载即抛**(本模块的一贯判据:配错就炸,⛔ 不静默变成"谁也没匹配上")
for (const bad of ['ops:*', 'named-net:*', 'u:*/d-1']) {
assert.throws(
() => normalizeDialers(new Set([bad])),
/只能用在租户网|半通配/,
`${JSON.stringify(bad)} 应当装载即抛(实际没抛)`,
)
}
assert.throws(
() => normalizeDialers(new Map([['u:*', new Set(['manager'])]])),
/只允许写/,
'`u:*` 桶里写具体 hostId 应当抛(该桶只表达"任何租户网整网放行")',
)
})
// ── T5 · 打洞成功路径(真 dgram + NAT 模拟) ────────────────────────────────────
+18
View File
@@ -29,6 +29,7 @@ import {
decodeMux,
describeDialers,
encodeJsonFrame,
isAllowedDialer,
logicalName,
normalizeDialers,
parseLogicalName,
@@ -186,6 +187,23 @@ test('U1 逻辑名唯一入口:旧形态 / 新形态 / `u:<租户>` 里的冒
// 桶内带了前缀就必须与本桶一致,否则说明配置自相矛盾 ⇒ 装载时就炸
assert.throws(() => normalizeDialers(new Map([['u:5', new Set(['ops:d1'])]])), /自相矛盾/)
// ⑤b 网级通配(序㊻ · S2):`u:<租户>/*` = 该网整网可拨
const wildCards = new Map([['u:5', new Set(['*'])]])
assert.equal(isAllowedDialer(normalizeDialers(wildCards), 'u:5', 'd-brand-new'), true)
assert.equal(isAllowedDialer(normalizeDialers(new Set(['u:5/*'])), 'u:5', 'd-brand-new'), true)
assert.equal(isAllowedDialer(normalizeDialers(new Set(['u:5:*'])), 'u:5', 'd-brand-new'), true)
assert.deepEqual(describeDialers(normalizeDialers(new Set(['u:5/*']))), ['u:5/*'])
// ⛔ 通配不外溢:别的网照旧默认拒绝(结构性隔离不因它松动)
assert.equal(isAllowedDialer(normalizeDialers(new Set(['u:5/*'])), OPS_NETWORK, 'manager'), false)
assert.equal(isAllowedDialer(normalizeDialers(new Set(['u:5/*'])), 'u:6', 'd1'), false)
// ⛔ 只准用在租户网:ops / 命名网上写通配 ⇒ 装载时就炸(否则会把声明端口的 worker 一并拒掉)
assert.throws(() => normalizeDialers(new Set(['ops/*'])), /租户网/)
assert.throws(() => normalizeDialers(new Set(['*'])), /租户网/)
assert.throws(() => normalizeDialers(new Set(['named-net/*'])), /租户网/)
assert.throws(() => normalizeDialers(new Map([['ops', new Set(['*'])]])), /租户网/)
// `*` 不是合法 hostId ⇒ 它**只**在白名单这一条通道上有意义,普通逻辑名照旧拒
assert.throws(() => parseLogicalName('u:5/*'), /hostId/)
// ⑥ 客户端侧同一条口径:非法 networkId ⇒ 构造时抛(不静默回落 ops)
assert.throws(
() => new RelayClient({ url: 'ws://127.0.0.1:1' + PATH, hostId: 'x', secret: randomBytes(32).toString('hex'), ports: [BASE], networkId: 'BAD' }),
+403
View File
@@ -0,0 +1,403 @@
/**
* 插件投放与分库线 ① · **S3 跨机装配**的回归测试。
*
* ## 这个文件防的是什么(每一件都对应一条会静默失效的判据)
*
* 1. **`RemoteSpawner.applyPlugins` 真的发到"实例所在那台"** —— 与 `restartAndProbe`
* 同一套 `hostIdFor` 路由。⛔ 若它回落到默认 host,就会把请求发给**另一台机**
* (那台上没有这个用户的 profile ⇒ 落盘落到一个空目录、或给错人装插件),
* 而症状只有"插件没生效",平台零日志。
* 2. **请求体形状与 agent 侧的入参校验对得上** —— `{userId, items:[{id,version,enabled,src}]}`。
* 两侧字段名漂一个字(如 `src` vs `source`)在 TS 里编译得过(一个 `Record<string,unknown>`),
* 但运行期是 400 ⇒ "用户点了启用永远失败"。
* 3. **`Spawner.applyPlugins` 是接口成员**:`LeasedSpawner` / `LocalSpawner` / `RemoteSpawner`
* 三处都得有 —— 漏一处 TS 会拦(这条由 `tsc` 保证),但**透传语义**(LeasedSpawner ⛔ 不碰
* 租约)是编译期看不出来的,故在此钉住。
* 4. **共享层路径换算两台机必须逐字一致**(`sharedDirNameOf`)—— manager 算出
* `dsh-plugin-mcn-suite` 而 worker 上铺的是别的名字 ⇒ `pnpm install` 报 ENOENT,
* 症状只是"某个用户装不上插件"。
* 5. **装配失败要"报错可读"**(S3-E ⑤):指向不存在的版本 ⇒ 抛带 `code` 的错,
* ⛔ 不静默跳过 —— 静默跳过会让任务显示 `success` 而插件根本没装。
*
* 运行:`node --test test/plugin-assembly.test.mjs`(已登记进 `npm test`)。
*
* @module test/plugin-assembly
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync, lstatSync, readlinkSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
applyProfileChanges,
depRefOf,
profileDirOf,
readProfileManifest,
sharedDirNameOf,
sharedDirOf,
syncWebProviderPatch,
wsDirOf,
WEB_PROVIDER_CLOSE,
WEB_PROVIDER_OPEN,
PNPM_INSTALL_ARGS,
} from '../lib/supervisor/plugin-assembly.js'
import { RemoteSpawner } from '../lib/supervisor/remote-spawner.js'
/* ─────────── 小工具 ─────────── */
/** 记下每次请求(url / method / body),回同一个形状的 JSON。 */
function recordingFetch(payload) {
const calls = []
const impl = async (url, init) => {
calls.push({ url: `${init?.method ?? 'GET'} ${url}`, body: init?.body === undefined ? undefined : JSON.parse(init.body) })
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
}
impl.calls = calls
return impl
}
/** 106 那台 worker(`via` 缺省 ⇒ 直连,不做 relay 翻译)。 */
const W106 = {
hostId: 'w-2',
agentUrl: 'http://127.0.0.1:19000',
token: 'tok-106',
}
/* ─────────── ① 跨机路由 ─────────── */
test('S3: applyPlugins 落到"该用户实例所在那台",⛔ 不回落到默认 host', async () => {
const fetchImpl = recordingFetch({ enabled: ['@dsh-local/trpg-kit'], disabled: [], bundles: ['@dsh-local/trpg-kit'] })
const sp = new RemoteSpawner({
agentUrl: 'http://127.0.0.1:19100', // 默认 host(w-1)
token: 'tok-local',
defaultHostId: 'w-1',
hostIdFor: async () => 'w-2', // 该用户的实例在 106
hostsProvider: async () => [W106],
fetchImpl,
})
const out = await sp.applyPlugins('u-1', [{ id: '@dsh-local/trpg-kit', enabled: true }])
assert.equal(fetchImpl.calls.length, 1)
// 🔴 打到 w-2(19000)而不是默认 host(19100)—— 这就是"装在 A、实例在 B"的判据。
assert.match(fetchImpl.calls[0].url, /^POST http:\/\/127\.0\.0\.1:19000\/plugins\/apply$/)
assert.deepEqual(out.bundles, ['@dsh-local/trpg-kit'])
})
test('S3: applyPlugins 请求体形状 = agent 侧约定(字段名漂一个就是 400)', async () => {
const fetchImpl = recordingFetch({ enabled: [], disabled: [], bundles: [] })
const sp = new RemoteSpawner({
agentUrl: 'http://127.0.0.1:19100',
token: 'tok',
fetchImpl,
})
await sp.applyPlugins('u-1', [
{ id: '@dsh-local/trpg-kit', version: '1.2.3', enabled: true },
{ id: 'mcn-suite', version: null, enabled: false, src: 'file' },
])
assert.deepEqual(fetchImpl.calls[0].body, {
userId: 'u-1',
items: [
{ id: '@dsh-local/trpg-kit', version: '1.2.3', enabled: true, src: 'bundled' },
{ id: 'mcn-suite', version: null, enabled: false, src: 'file' },
],
})
})
test('S3: applyPlugins 在 hostIdFor 缺失(单机形态)时走默认 host', async () => {
const fetchImpl = recordingFetch({ enabled: [], disabled: [], bundles: [] })
const sp = new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok', fetchImpl })
await sp.applyPlugins('u-1', [])
assert.match(fetchImpl.calls[0].url, /127\.0\.0\.1:19100\/plugins\/apply/)
})
test('S3: agent 回 4xx ⇒ 原样带上状态码与响应体抛错(⛔ 不被重试吞掉)', async () => {
let calls = 0
const impl = async () => {
calls += 1
return {
ok: false,
status: 409,
json: async () => ({}),
text: async () => '{"error":"plugin_bundle_missing"}',
}
}
const sp = new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok', fetchImpl: impl })
await assert.rejects(
() => sp.applyPlugins('u-1', [{ id: 'x', enabled: true }]),
(err) => {
assert.match(String(err.message), /409/)
assert.match(String(err.message), /plugin_bundle_missing/)
return true
},
)
// 4xx = 协议/参数错 ⇒ 重试没意义(与 call() 的既有纪律一致)
assert.equal(calls, 1)
})
/* ─────────── ② 路径换算:两台机必须逐字一致 ─────────── */
test('S3: 共享层目录名 = 扁平化包名(scope 的 `/` 变 `_`,⛔ 不带版本号)', () => {
assert.equal(sharedDirNameOf('@dsh-local/trpg-kit'), '_dsh-local_trpg-kit')
assert.equal(sharedDirNameOf('dsh-plugin-mcn-suite'), 'dsh-plugin-mcn-suite')
assert.equal(sharedDirNameOf('[email protected]'), 'univer-office_1.2.3')
// ⛔ 版本号不进路径(B 档:同名只留最新一版,写进路径会让老 profile 悬空)
assert.equal(sharedDirNameOf('dsh-plugin-mcn-suite'), sharedDirNameOf('dsh-plugin-mcn-suite'))
})
test('S3: sharedDirOf / profileDirOf / wsDirOf 的路径数学', () => {
// ⚠️ 用 `join` 拼期望值:本机是 Windows(`\`),而 47/106 是 Linux(`/`)——
// 判据要的是**路径数学**(拼哪个子目录),不是分隔符字面。
assert.equal(
sharedDirOf('/var/lib/dshs/bundled-plugins', '@dsh-local/trpg-kit'),
join('/var/lib/dshs/bundled-plugins', '_dsh-local_trpg-kit'),
)
assert.equal(profileDirOf('/var/lib/dshs/users/u-1'), join('/var/lib/dshs/users/u-1', 'home', 'profiles', 'web'))
assert.equal(wsDirOf('/var/lib/dshs/users/u-1'), join('/var/lib/dshs/users/u-1', 'ws'))
})
/* ─────────── ③ profile 读写与 web provider 段 ─────────── */
test('S3: readProfileManifest 读不出 ⇒ 空清单(⛔ 不抛)', () => {
const dir = mkdtempSync(join(tmpdir(), 'pa-'))
try {
assert.deepEqual(readProfileManifest(dir), { bundles: [], deps: {} })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('S3: syncWebProviderPatch 幂等(跑两次结果逐字相同)', () => {
const dir = mkdtempSync(join(tmpdir(), 'pa-'))
try {
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'dsh-profile-web',
private: true,
dependencies: { 'anysearch': 'file:/tmp/anysearch' },
dsh: { profile: { bundles: ['anysearch'] } },
}, null, 2))
// 插件自带 `cordis.patch.yml` 声明 searchProvider(`collectWebProviderClaims` 读它)
mkdirSync(join(dir, 'node_modules', 'anysearch'), { recursive: true })
writeFileSync(join(dir, 'node_modules', 'anysearch', 'cordis.patch.yml'), [
'- id: web',
' config:',
' searchProvider: anysearch',
'',
].join('\n'))
syncWebProviderPatch(dir)
const first = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
syncWebProviderPatch(dir)
const second = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
assert.equal(first, second)
// 托管段两侧标记必须都在(验证脚本按字面找它们 —— ⛔ 改名会让 verify-*.mjs 静默不判)
assert.ok(first.includes(WEB_PROVIDER_OPEN))
assert.ok(first.includes(WEB_PROVIDER_CLOSE))
assert.match(first, /searchProvider: anysearch/)
assert.match(first, /fetchProvider: http/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('S3: bundles 为空 ⇒ 托管段被整段摘掉(回到"自动选")', () => {
const dir = mkdtempSync(join(tmpdir(), 'pa-'))
try {
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'dsh-profile-web',
private: true,
dependencies: {},
dsh: { profile: { bundles: [] } },
}, null, 2))
writeFileSync(join(dir, 'cordis.patch.yml'), [
'other: true',
'',
WEB_PROVIDER_OPEN,
'- id: web',
' config:',
' searchProvider: anysearch',
WEB_PROVIDER_CLOSE,
'',
].join('\n'))
syncWebProviderPatch(dir)
const text = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
assert.ok(!text.includes(WEB_PROVIDER_OPEN))
assert.ok(!text.includes('searchProvider'))
assert.match(text, /other: true/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
/* ─────────── ④ 装配失败必须"报错可读"(S3-E ⑤) ─────────── */
test('S3 反证: 共享层里没有该包 ⇒ applyPlugins 抛错(⛔ 不静默跳过)', async () => {
// 用 RemoteSpawner 模拟:agent 回 409 plugin_bundle_missing
const impl = async () => ({
ok: false,
status: 409,
json: async () => ({}),
text: async () => '{"error":"plugin_bundle_missing","message":"共享层里没有该插件的包:@dsh-local/ghost"}',
})
const sp = new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok', fetchImpl: impl })
await assert.rejects(
() => sp.applyPlugins('u-1', [{ id: '@dsh-local/ghost', enabled: true }]),
/plugin_bundle_missing/,
)
})
/* ─────────── ⑤ 软链语义(D2:一份实体、多份链接) ─────────── */
test('S3: D2 语义 —— node_modules/<pkg> 是指向共享层的软链,⛔ 不是实体副本', () => {
const root = mkdtempSync(join(tmpdir(), 'pa-'))
try {
const shared = join(root, 'bundled-plugins', '_dsh-local_trpg-kit')
mkdirSync(shared, { recursive: true })
writeFileSync(join(shared, 'package.json'), '{"name":"@dsh-local/trpg-kit"}')
const profile = join(root, 'users', 'u-1', 'home', 'profiles', 'web')
mkdirSync(join(profile, 'node_modules'), { recursive: true })
symlinkSync(shared, join(profile, 'node_modules', '@dsh-local_trpg-kit'), 'dir')
const link = join(profile, 'node_modules', '@dsh-local_trpg-kit')
assert.ok(lstatSync(link).isSymbolicLink(), '必须是软链(实体只有共享层那一份)')
assert.equal(readlinkSync(link), shared, '必须指向共享层的那一份实体')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/* ─────────── ⑥ pnpm 参数契约(防"裸写无效 flag ⇒ 静默装不上")─────────── */
test('S3: pnpm install 参数 ⛔ 不含裸写的 ignore-workspace-root-check(pnpm 9 会 Unknown option 直接失败)', () => {
// 真机实测(106 · pnpm 9.15.9):`pnpm install --ignore-workspace-root-check` 报
// ERROR Unknown option: 'ignore-workspace-root-check'
// 且**退出即失败** ⇒ 装配整条链断在 pnpm 这一步,而症状只是"插件没装上"。
assert.ok(
!PNPM_INSTALL_ARGS.includes('--ignore-workspace-root-check'),
'⛔ 不得出现裸写的 --ignore-workspace-root-check(pnpm 9 不认)',
)
// 放行 workspace root 只能靠 `-w`(与 scripts/ensure-biz-plugins.cjs 的既有姿势逐字一致)
assert.ok(PNPM_INSTALL_ARGS.includes('-w'), '必须带 -w:profile 里有 pnpm-workspace.yaml ⇒ 需显式放行 workspace root')
assert.equal(PNPM_INSTALL_ARGS[0], 'install', '子命令必须是 install')
// ⛔ 命令行不重复传 auto-install-peers:dsh 插件的 @deepseek-ai/dsh-client-* 是 optional peer,
// 公网 registry 没有 ⇒ 自动装 peer 必失败;该开关由 profile 内 workspace 文件承载。
assert.ok(
!PNPM_INSTALL_ARGS.some((a) => String(a).startsWith('--config.auto-install-peers')),
'⛔ 别在命令行传 auto-install-peers(由 profile 的 workspace 文件承载)',
)
})
/* ─────────── ⑦ 原子性:缺失包 ⇒ 抛错且 profile 原样不动 ─────────── */
test('S3: 有包不在共享层 ⇒ applyProfileChanges 抛 plugin_bundle_missing 且 package.json 未被改动', () => {
// 这条防的是**半装状态**:若先写依赖引用再跑 pnpm,失败时 package.json 里会留下
// 悬挂引用而实际未安装 ⇒ 下次实例冷启动直接崩,用户却看到"装过了"。
// 判据:抛错 + 磁盘上的 package.json 与调用前**逐字节相同**。
const root = mkdtempSync(join(tmpdir(), 'pa-atomic-'))
try {
const userRoot = root
mkdirSync(join(userRoot, 'home', 'profiles', 'web'), { recursive: true })
mkdirSync(join(userRoot, 'ws'), { recursive: true })
const profile = join(userRoot, 'home', 'profiles', 'web')
const before = {
name: 'dsh-profile-web',
private: true,
dependencies: { 'keep-me': 'file:/somewhere/keep-me' },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'], patchReload: 'live' } },
}
const beforeText = JSON.stringify(before, null, 2) + '\n'
writeFileSync(join(profile, 'package.json'), beforeText)
const sharedRoot = join(root, 'shared')
const present = join(sharedRoot, 'present-plugin')
mkdirSync(present, { recursive: true })
writeFileSync(join(present, 'package.json'), '{"name":"present-plugin","version":"1.0.0"}')
assert.throws(
() => applyProfileChanges(
[
{ id: 'present-plugin', enabled: true, src: 'bundled' },
{ id: 'ghost-plugin', enabled: true, src: 'bundled' },
],
{
userRoot,
bundledPluginDir: sharedRoot,
resolveBundleDir: (id) => {
const d = join(sharedRoot, id)
return existsSync(join(d, 'package.json')) ? d : undefined
},
},
),
(e) => e && e.code === 'plugin_bundle_missing',
'缺失包必须抛带 code=plugin_bundle_missing 的错(⛔ 不静默跳过)',
)
// 🔴 核心断言:一个字节都不能变 —— 连"好的那个包"也不许被写进去
assert.equal(
readFileSync(join(profile, 'package.json'), 'utf8'),
beforeText,
'⛔ 有任一包缺失时 package.json 必须原样不动(否则留下半装状态)',
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/* ─────────── ⑧ 装配协议 = `link:`(2026-09-23 拍板,推翻原 D-h 的 `file:`)─────────── */
test('S3: 装配协议必须是 link: 且指向共享层 —— ⛔ 不得回到 file:(pnpm 9 会整份复制 6.5 MiB/用户/包)', () => {
// 真机实测(第 6 棒 · 106 · pnpm 9.15.9):
// `file:`(目录型)⇒ 插件包被**整份复制**进该用户 `.pnpm/`(6.5 MiB、116 文件、
// `links=1`、inode 与源不同)⇒ 不满足 D2「⛔ 不给每个用户复制一份」,用户数一涨磁盘线性膨胀。
// `link:` ⇒ 纯软链直达共享层(≈ 4.0 KiB),且**抗 reconcile**(加依赖 / `remove` 后软链仍在,
// 即 D-h 当初选 `file:` 的唯一理由依然成立)。
const target = '/var/lib/dshs/bundled-plugins/_dsh-local_storyforge'
assert.equal(
depRefOf(target),
`link:${target}`,
'⛔ 协议必须是 link: —— 回到 file: 会让每个用户各复制一份实体(D2 不成立)',
)
// 写进 profile 的**真实**依赖值也必须带 link: 前缀(防"只在工具函数里改了、调用点漏改")
//
// ⚠️ `DSHS_TEST_NO_PNPM=1` = 允许跳过 `setpriv`(Linux 专有;本机 Windows 上必 ENOENT)。
// 但**写 package.json 的那段照常跑** ⇒ 断言测的是真产物,不是桩。
// ⛔ 在 Linux 真机上该开关**不生效**(`pnpmExecDisabled` 会探测到 setpriv 存在)⇒ 生产不可能被绕过。
process.env.DSHS_TEST_NO_PNPM = '1'
const root = mkdtempSync(join(tmpdir(), 'pa-ref-'))
try {
const userRoot = root
const profile = join(userRoot, 'home', 'profiles', 'web')
mkdirSync(profile, { recursive: true })
mkdirSync(join(userRoot, 'ws'), { recursive: true })
writeFileSync(join(profile, 'package.json'), '{"name":"p","private":true}\n')
const sharedRoot = join(root, 'bundled-plugins')
const pkgDir = join(sharedRoot, '_dsh-local_probe')
mkdirSync(pkgDir, { recursive: true })
writeFileSync(join(pkgDir, 'package.json'), '{"name":"@dsh-local/probe"}')
applyProfileChanges([{ id: '@dsh-local/probe', enabled: true, src: 'bundled' }], {
userRoot,
bundledPluginDir: sharedRoot,
resolveBundleDir: (id) =>
existsSync(join(sharedRoot, sharedDirNameOf(id), 'package.json'))
? join(sharedRoot, sharedDirNameOf(id))
: undefined,
})
const written = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8'))
assert.equal(
written.dependencies['@dsh-local/probe'],
`link:${pkgDir}`,
'profile 里写死的依赖值必须是 link: 前缀',
)
assert.ok(
!String(written.dependencies['@dsh-local/probe']).startsWith('file:'),
'⛔ 不得写成 file:(会触发 pnpm 整份复制)',
)
} finally {
rmSync(root, { recursive: true, force: true })
delete process.env.DSHS_TEST_NO_PNPM
}
})
+616
View File
@@ -0,0 +1,616 @@
// 插件数据面 · 静态声明解析与校验(`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)
})
+174
View File
@@ -0,0 +1,174 @@
// 平台共享只读包库 · 用户面可见性清单(`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 })
}
})