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