Files
dsh_shenxian/test/overlay-pair.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

400 lines
20 KiB
JavaScript
Raw 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.
/**
* 覆盖网络 · **扫码配对的平台侧**(`src/web/routes/overlay-pair.ts`)HTTP 级夹具。
*
* 运行:`npm run build && node --test test/overlay-pair.test.mjs`
* (测的是 `lib/` 产物 ⇒ 必须先 build;已含进 `npm test` / `npm verify`)。
*
* ## 这个文件要回答的五个问题(= 本棒验收判据,逐条给机器可判的读数)
* | # | 判据 | 落点 |
* |---|---|---|
* | ① | **闭环**:`start → approve → poll` 真拿得到**能用**的凭据;且凭据**只交付一次**(再 poll ⇒ 404) | T1 |
* | ② | **过期码被拒**:TTL 到点后 `approve` ⇒ 410 具名;`poll` 报 `expired` 终态 | T2 |
* | ③ | **同码重复使用被拒**:第二次 `approve` ⇒ **409 `pair-already-used`** | T3 |
* | ④ | **未登录 `approve` ⇒ 401**(fail-closed,⛔ 不是"跳过校验放行") | T4 |
* | ⑤ | **换账号批准 ⇒ 凭据落到批准者**;且 `network` / `userId` **在请求体里无处可填** | T5 |
*
* ## 🔴 夹具纪律(与 `overlay-invites.test.mjs` / `overlay-device.test.mjs` 同一条)
* - ⛔ **不连库**:会话解析 = 假 `db`;设备台账 = 内存桩(`ledgerStub()`)。
* - ⛔ **不碰真文件系统**:签名私钥 = **运行时生成的一次性测试密钥**,写进 `mkdtemp` 的临时目录;
* relay 密钥表 / registry 全部用 `DSHS_*` 外置键指向同一临时目录。
* - ⛔ **不用真实账号**、⛔ **不起监听端口**(`app.inject` 走内存)。
* - ⛔ **不改任何判据常量**:TTL 的"到点"用 **注入时钟**(临时替换 `Date.now`)造,⛔ 不动 `PAIR_TTL_MS`。
*
* ## 已定项(可推翻)
* 1. **用 `lib/` 而不是 `src/` 作为被测对象** —— 与全仓既有 `test/*.test.mjs` 同口径。
* 被推翻的候选 = 直接 import `src/*.ts`:需要额外 loader,且生产跑的是 `lib/` ⇒ 验不到真东西。
* 2. **过期用注入时钟** —— 被推翻的候选 = 把 `PAIR_TTL_MS` 改小(会动到 5 分钟硬上限常量,
* 与"⛔ 不改硬上限"直接冲突),或真等 5 分钟(夹具慢到没人愿意跑)。
* 3. **归属断言走"行为 + 源码钉"两条腿** —— 只断行为会漏掉"以后有人往 body 里加 `userId`"这种回归;
* 只断源码则证明不了运行时行为。
* 4. **凭据完整性纳入判据** —— `poll` 交出的那份必须与 `overlay-device.ts` 的成功回执**同形**
* (`grant` + `hmacSecret`)。判据来源:`overlay-pair.ts` 自己的模块头写着
* 「长期设备凭据 = `grant`(Ed25519) + `hmacSecret`」,而桌面侧消费方
* (`overlay-device-enroll.mjs:114-115`)对缺 `hmacSecret` 的载荷**直接拒绝** ⇒
* 少了它就等于"配对成功但拨不进来"。
*
* @module test/overlay-pair
*/
import assert from 'node:assert/strict'
import { randomBytes } from 'node:crypto'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import Fastify from 'fastify'
import { generateNodeKey, normalizePublicKey } from '../lib/net/relay/identity.js'
import { hashSessionToken } from '../lib/web/auth.js'
import {
PAIR_CODE_LENGTH,
PAIR_TTL_MS,
__pairSessionCount,
__resetPairStateForTest,
overlayPairRoutes,
} from '../lib/web/routes/overlay-pair.js'
/* ─────────────────────────── 夹具 ─────────────────────────── */
const START = '/api/overlay/pair/start'
const POLL = '/api/overlay/pair/poll'
const APPROVE = '/api/overlay/pair/approve'
/** 站点对外 origin(夹具里用生产同形,让 `pairUrl` 与生产**同一条代码路径**产出)。 */
const HOST = { host: 'ai1net.com' }
function tmpDir() {
return mkdtempSync(join(tmpdir(), 'dshs-pair-'))
}
/** 一次性测试签名者 + 三个外置落点(⛔ 全在临时目录里,用完即随进程消失)。 */
function envOf(dir) {
const signer = generateNodeKey()
const signerKeyFile = join(dir, 'signer-key.pem')
writeFileSync(signerKeyFile, signer.privateKeyPem, { mode: 0o600 })
return {
signer,
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',
},
}
}
/**
* 设备台账(v12 `overlay_devices`)内存桩 —— 与 `overlay-device.test.mjs#ledgerStub` 同语义,
* 只复刻签发路径真正读到的两处(`findOverlayDevice` / `upsertOverlayDevice`)。
*/
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,
kind: input.kind ?? 'desktop',
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
},
}
}
/**
* 起一个**只挂配对路由**的 Fastify。
*
* 返回里的 `sessions` 是**活表**:`login()` 往里写,`requireAuth` 直接从它读
* ⇒ "有没有登录"就变成夹具里一行可读的状态。
*/
async function makeApp(dir) {
__resetPairStateForTest()
const { vars } = envOf(dir)
for (const [k, v] of Object.entries(vars)) process.env[k] = v
const sessions = new Map()
const app = Fastify()
app.decorate('config', { baseDomain: 'ai1net.com', secureCookies: true })
app.decorate('db', {
findSessionWithUser: async (tokenHash) => sessions.get(tokenHash),
...ledgerStub(),
})
await app.register(overlayPairRoutes)
await app.ready()
return { app, sessions, vars }
}
/** 造一个**已登录的手机**(写进会话表;cookie 就是它那把)。 */
function login(sessions, user) {
const token = randomBytes(8).toString('hex')
const session = {
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,
},
},
}
sessions.set(hashSessionToken(token), session.row)
return session
}
/** 发起配对(未认证)。 */
async function start(app, payload) {
const res = await app.inject({ method: 'POST', url: START, headers: HOST, payload })
return { status: res.statusCode, body: res.json() }
}
/** 桌面轮询(未认证)。 */
async function poll(app, code) {
const res = await app.inject({ method: 'GET', url: `${POLL}?code=${encodeURIComponent(code)}`, headers: HOST })
return { status: res.statusCode, body: res.json() }
}
/** 手机确认(`session === undefined` ⇒ 未登录)。 */
async function approve(app, code, session, extra = {}) {
const res = await app.inject({
method: 'POST',
url: APPROVE,
headers: session === undefined ? HOST : { ...HOST, cookie: session.cookie },
payload: { code, ...extra },
})
return { status: res.statusCode, body: res.json() }
}
/** 一枚合法形状的公钥(运行时生成,⛔ 不来自任何真设备)。 */
function anyPublicKey() {
return generateNodeKey().publicKey
}
/** 从 `pairUrl` 里把码抠出来 —— 桌面扫到的就是这一串。 */
function codeOfUrl(pairUrl) {
const m = /[?&]code=([^&]+)/u.exec(String(pairUrl))
assert.ok(m !== null, `pairUrl 里应当带一次性码(实际 ${pairUrl})`)
return decodeURIComponent(m[1])
}
/* ─────────────────────────── ① 闭环 ─────────────────────────── */
test('T1 闭环:start → approve → poll 拿到**能用**的凭据;且凭据只交付一次(再 poll ⇒ 404)', async (t) => {
const { app, sessions } = await makeApp(tmpDir())
t.after(() => app.close())
const node = generateNodeKey()
const began = await start(app, { nodeKey: node.publicKey, label: '夹具-闭环' })
assert.equal(began.status, 200, `start 应当 200(实际 ${began.status}:${JSON.stringify(began.body)})`)
assert.equal(began.body.ok, true)
assert.equal(began.body.status, 'pending_scan', '刚发起时只有桌面这一侧 ⇒ pending_scan')
assert.equal(began.body.loginToken.length, PAIR_CODE_LENGTH, '配对码须是 128 bit 的 26 位 base32')
assert.equal(began.body.ttlMs, PAIR_TTL_MS)
assert.ok(Date.parse(began.body.expiresAt) > Date.parse(began.body.serverTime), 'expiresAt 必须晚于 serverTime')
// `pairUrl` = 人可读兜底入口,码必须就在里面(桌面终端"链接模式"用的就是它)。
assert.equal(began.body.pairUrl, `https://ai1net.com/pair.html?code=${began.body.loginToken}`)
// 二维码里**只有**这一次性码,⛔ 不许塞任何长期凭据。
assert.equal(began.body.qrPayload.loginToken, began.body.loginToken)
assert.equal(began.body.qrPayload.userId, '', '发起端未认证 ⇒ 二维码里没有 userId(不是漏填)')
assert.equal('hmacSecret' in began.body, false, '⛔ start 不得回任何长期凭据')
assert.equal('grant' in began.body, false, '⛔ start 不得回任何长期凭据')
const code = codeOfUrl(began.body.pairUrl)
// 批准前轮询 ⇒ 只回状态,⛔ 不回账号信息。
const waiting = await poll(app, code)
assert.equal(waiting.status, 200)
assert.equal(waiting.body.status, 'pending_scan')
assert.equal(waiting.body.finished, false)
assert.equal('hmacSecret' in waiting.body, false)
// 🔴 未登录的手机**不能**批准。
const anon = await approve(app, code, undefined)
assert.equal(anon.status, 401, `无 cookie 的 approve 应当 401(实际 ${anon.status}:${JSON.stringify(anon.body)})`)
const phone = login(sessions, { id: 'u-1001' })
const yes = await approve(app, code, phone)
assert.equal(yes.status, 200, `approve 应当 200(实际 ${yes.status}:${JSON.stringify(yes.body)})`)
assert.equal(yes.body.status, 'approved')
assert.equal(yes.body.network, 'u:u-1001')
assert.equal('hmacSecret' in yes.body, false, '手机侧 ⛔ 不经过设备凭据')
/* 🔴 凭据只在 poll 取走那一次出现;形状必须与 `overlay-device.ts` 的成功回执**同形** ——
* 桌面侧 `overlay-device-enroll.mjs:114-115` 对缺 `hmacSecret` 的载荷**直接拒绝**
* ⇒ 少了它就等于"配对成功但拨不进来"。 */
const got = await poll(app, code)
assert.equal(got.status, 200)
assert.equal(got.body.status, 'consumed', '取走凭据那一次的状态是 consumed')
assert.equal(got.body.finished, true)
assert.equal(got.body.network, 'u:u-1001')
assert.equal(got.body.hostId, `d-u-1001-${node.publicKey.slice(0, 8)}`)
assert.deepEqual(Object.keys(got.body.grant).sort(), ['doc', 'sig'], 'grant 必须是 {doc,sig}(可直接落盘)')
assert.equal(got.body.grant.doc.nodeKey, normalizePublicKey(node.publicKey), 'grant 绑的必须是发起时那枚公钥')
assert.match(String(got.body.hmacSecret), /^[0-9a-f]{64}$/u, 'hmacSecret 必须是 64 hex(HELLO 的 MAC 用它)')
assert.equal(typeof got.body.expiresAt, 'string', '要带租约到期时间')
assert.equal(got.body.deviceName, '夹具-闭环')
// 一次性:同一个码再 poll ⇒ 已不存在(⛔ 不许"再交付一次")。
const again = await poll(app, code)
assert.equal(again.status, 404, `凭据取走后同一个码应当查不到(实际 ${again.status})`)
assert.equal(again.body.error, 'pair-not-found')
})
/* ─────────────────────────── ② 过期 ─────────────────────────── */
test('T2 过期:TTL 到点后 approve ⇒ 410 具名、poll 报 expired 终态(⛔ 不动 TTL 常量)', async (t) => {
const realNow = Date.now
/* 🔴 注入时钟:把"现在"推到 TTL 之后。⛔ `PAIR_TTL_MS` 一个字都没改。 */
const jumped = realNow() + PAIR_TTL_MS + 1_000
/* ⚠️ 两条腿各要**独立的 app**:`poll` 开头先 `sweep(now)`,而 sweep 会清掉**所有**过期条目
* (不只它要查的那一个)⇒ 先跑 poll 腿的话,approve 腿就只能看到 404 了。
* 两条路径的答案都是具名的、都带 `status: 'expired'`,但 HTTP 码不同 ⇒ 分开各钉一次。 */
/* ── 腿 A:桌面在过期之后轮询 —— 走 sweep ⇒ **404 `pair-not-found`**(正文带 expired 标记)。
* 桌面拿这个答案和拿 `200 + status:'expired'` **同义**:停轮询、提示重新生成。
* (路由里那条 `200 + status:'expired'` 仍可达:手机已批准、桌面超 TTL 才来取 ⇒
* 该条目带 `credential`,不在 sweep 的清理范围内。) */
const a = await makeApp(tmpDir())
t.after(() => a.app.close())
const codeA = codeOfUrl((await start(a.app, { nodeKey: anyPublicKey(), label: '夹具-过期-poll' })).body.pairUrl)
Date.now = () => jumped
try {
const asked = await poll(a.app, codeA)
assert.equal(asked.status, 404, `过期 poll 应当 404(实际 ${asked.status}:${JSON.stringify(asked.body)})`)
assert.equal(asked.body.error, 'pair-not-found')
assert.equal(asked.body.status, 'expired', '404 正文必须带 expired 标记 —— 桌面靠它区分"过期"与"查无此码"')
assert.equal('hmacSecret' in asked.body, false, '⛔ 过期路径不得夹带凭据')
assert.equal('grant' in asked.body, false, '⛔ 过期路径不得夹带凭据')
} finally {
Date.now = realNow
}
// 惰性清理真的落地了(⛔ 不留内存尾巴)。
assert.equal(__pairSessionCount(), 0, '过期条目应当被 sweep 就地清干净')
/* ── 腿 B:手机在过期之后才点确认,且**没有**任何 poll 先跑过
* ⇒ 走 `approve` 自己的 expired 判据 ⇒ **410 `pair-expired`**。 */
const b = await makeApp(tmpDir())
t.after(() => b.app.close())
const phone = login(b.sessions, { id: 'u-2002' })
const codeB = codeOfUrl((await start(b.app, { nodeKey: anyPublicKey(), label: '夹具-过期-approve' })).body.pairUrl)
Date.now = () => jumped
try {
const late = await approve(b.app, codeB, phone)
assert.equal(late.status, 410, `过期码 approve 应当 410(实际 ${late.status}:${JSON.stringify(late.body)})`)
assert.equal(late.body.error, 'pair-expired')
assert.equal(late.body.status, 'expired')
assert.equal('hmacSecret' in late.body, false, '⛔ 过期路径不得夹带凭据')
} finally {
Date.now = realNow
}
assert.equal(__pairSessionCount(), 0, 'approve 见到过期条目也应当就地清掉')
})
/* ─────────────────────────── ③ 重复使用 ─────────────────────────── */
test('T3 重复使用:第二次 approve 同一码 ⇒ 409 pair-already-used(⛔ 不许静默成功)', async (t) => {
const { app, sessions } = await makeApp(tmpDir())
t.after(() => app.close())
const began = await start(app, { nodeKey: anyPublicKey(), label: '夹具-重复' })
const code = codeOfUrl(began.body.pairUrl)
const phone = login(sessions, { id: 'u-3003' })
const first = await approve(app, code, phone)
assert.equal(first.status, 200, `第一次 approve 应当 200(实际 ${first.status}:${JSON.stringify(first.body)})`)
const second = await approve(app, code, phone)
assert.equal(second.status, 409, `第二次 approve 应当 409(实际 ${second.status}:${JSON.stringify(second.body)})`)
assert.equal(second.body.error, 'pair-already-used')
assert.equal(second.body.status, 'approved', '回执要带上"它现在处于哪个态",便于手机侧显示')
assert.equal('hmacSecret' in second.body, false, '⛔ 被拒的重放不得夹带凭据')
assert.equal('grant' in second.body, false, '⛔ 被拒的重放不得夹带凭据')
})
/* ─────────────────────────── ④ 未登录 ─────────────────────────── */
test('T4 未登录 approve ⇒ 401(fail-closed;且码**不被消耗**)', async (t) => {
const { app, sessions } = await makeApp(tmpDir())
t.after(() => app.close())
const began = await start(app, { nodeKey: anyPublicKey(), label: '夹具-401' })
const code = codeOfUrl(began.body.pairUrl)
for (const headers of [HOST, { ...HOST, cookie: 'sid=not-a-real-session' }]) {
const res = await app.inject({ method: 'POST', url: APPROVE, headers, payload: { code } })
assert.equal(res.statusCode, 401, `无有效会话的 approve 应当 401(实际 ${res.statusCode}:${res.body})`)
assert.equal(res.json().error, 'unauthorized')
}
// 401 是"拒绝",⛔ 不是"处理过了" —— 码还得能正常批准。
const phone = login(sessions, { id: 'u-4004' })
const ok = await approve(app, code, phone)
assert.equal(ok.status, 200, `401 之后码仍应可用(实际 ${ok.status}:${JSON.stringify(ok.body)})`)
})
/* ─────────────────────────── ⑤ 归属 ─────────────────────────── */
test('T5 换账号批准 ⇒ 凭据落到**批准者**;network / userId 在请求体里无处可填', async (t) => {
const { app, sessions } = await makeApp(tmpDir())
t.after(() => app.close())
// 桌面侧未认证 ⇒ "发起人"这个概念在平台侧根本不存在。
const began = await start(app, { nodeKey: anyPublicKey(), label: '夹具-归属' })
const code = codeOfUrl(began.body.pairUrl)
/* 🔴 恶意/误填的 body:把别人网名与别人 userId 一起塞进来。
* 判据 = **一点用都没有**(structurally impossible),不是"我们检查了一下发现不匹配"。 */
const approver = login(sessions, { id: 'u-5005' })
const res = await approve(app, code, approver, { network: 'u:victim', userId: 'u-victim', hostId: 'd-u-victim-deadbeef' })
assert.equal(res.status, 200, `批准应当 200(实际 ${res.status}:${JSON.stringify(res.body)})`)
assert.equal(res.body.network, 'u:u-5005', '归属必须 = 批准者,⛔ 不是 body 里填的那个')
assert.equal(res.body.hostId.startsWith('d-u-5005-'), true, `hostId 必须带批准者的 userId(实际 ${res.body.hostId})`)
const got = await poll(app, code)
assert.equal(got.body.network, 'u:u-5005', '取走的凭据归属 = 批准者')
assert.equal(got.body.grant.doc.network, 'u:u-5005')
assert.equal(got.body.grant.doc.hostId, res.body.hostId)
assert.equal(String(got.body.hostId).includes('victim'), false, '⛔ 批准者的凭据里不得出现 body 填的 victim')
// 换了**另一个**账号批准 ⇒ 凭据落在那个人名下(不是发起者、也不是前一个批准者)。
const other = await start(app, { nodeKey: anyPublicKey(), label: '夹具-归属2' })
const code2 = codeOfUrl(other.body.pairUrl)
const approver2 = login(sessions, { id: 'u-6006' })
const res2 = await approve(app, code2, approver2)
assert.equal(res2.status, 200)
assert.equal(res2.body.network, 'u:u-6006', '换账号批准 ⇒ 凭据落到那个账号')
assert.equal(res2.body.hostId.startsWith('d-u-6006-'), true)
/* 🔴 源码钉(防"以后有人往 body 里加 userId"这类回归):
* `approve` 的归属只允许来自 `user.id`,⛔ 不许从 body 取 `network` / `userId` / `hostId`。 */
const src = readFileSync(new URL('../src/web/routes/overlay-pair.ts', import.meta.url), 'utf8')
const from = src.indexOf("app.post('/api/overlay/pair/approve'")
assert.notEqual(from, -1, 'approve 端点必须在源码里找得到(钉子的前提)')
const rest = src.slice(from)
const approveHandler = rest.slice(0, rest.indexOf('/* ── ⑤'))
assert.match(approveHandler, /issueDeviceGrant\(\{/u, 'approve 必须走共用的签发实现')
assert.match(approveHandler, /userId: user\.id/u, 'approve 的归属必须是 user.id')
assert.doesNotMatch(approveHandler, /body\??\.(network|userId|hostId)/u, '⛔ approve 不得从请求体读 network / userId / hostId')
})