Files
dsh_shenxian/test/overlay-device.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

642 lines
28 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.
/**
* 覆盖网络 · **序㊻ 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')
})