Files
dsh_shenxian/test/overlay-pair.test.mjs
T

399 lines
20 KiB
JavaScript
Raw Normal View History

/**
* 覆盖网络 · **扫码配对的平台侧**(`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')
})