文档库:目录改为编号制(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:
1 parent
3d8f50e366
commit
e6207aa691
239 files changed
+34477
-14633
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 的内部结构')
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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}`)
|
||||
})
|
||||
@@ -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 缺失时仍可读)')
|
||||
})
|
||||
@@ -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, '台账保留旧行为审计痕迹(⛔ 不删行)')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 模拟) ────────────────────────────────────
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user