/** * 覆盖网络 · **序㊻ 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` ⇒ 都算 `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--<公钥前 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') })