Files
dsh_shenxian/test/im-gateway-access.test.mjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(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/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

362 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
})