build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(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/;交接单不入库(政策)。
642 lines
28 KiB
JavaScript
642 lines
28 KiB
JavaScript
/**
|
||
* 覆盖网络 · **序㊻ 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')
|
||
})
|