Files
dsh_shenxian/test/overlay-join.test.mjs
T
admin c452013129 feat(overlay): 覆盖网络线 序㉔–㊸ 补提交 —— 源码与已部署产物对齐
把这批「已 scp 到 47 / 106 生产并在跑、但一直未入 git」的实现补进版本库
(其中 P2 的全部新源码此前一直是 untracked)。

分棒内容:
- 序㉔ 内容分发块级寻址:src/net/relay/content/{runtime,source}.ts
- 序㉖ 骨干稳定选路(jitter):src/net/relay/{index,network}.ts、src/web/server.ts
- 序㉘ 组密钥加密(GCM 确定性):src/net/relay/content/{runtime,source}.ts
- 序㉛ P1 一键加入 + 分组准入:src/net/relay/{join,registry}.ts、
  src/web/routes/overlay-nodes.ts、scripts/overlay-node-{join,admit}.cjs、
  test/overlay-join.test.mjs
- 序㊵/㊶ 直连打洞 + peer 档:src/net/relay/direct/{candidate,index,punch}.ts、
  scripts/overlay-direct-probe.cjs、test/overlay-direct.test.mjs
- 序㊷/㊸ 观测面:scripts/overlay-probe.cjs、scripts/overlay-failover-drill.cjs

零回归三件套(2026-09-18 16:2x 提交前复跑,全绿):
- npm test                                  201 tests / pass 200 / fail 0 / skipped 1(Node v22.22.2)
- overlay-probe.cjs --table                 28 PASS / 0 SKIP / 0 FAIL
- overlay-failover-drill.cjs --scene all --table  12 PASS / 0 SKIP / 0 FAIL

⛔ 未纳入:_tmp_seq24/、_tmp_seq40/、_中间产物_待清理/(本机临时产物,仍 untracked)
🔴 未实施:D8 云安全组乙-1(待人工在云控制台落地,见
   交接单_覆盖网络直连与P2P_20260918.md §8.11)
2026-09-18 16:37:09 +08:00

635 lines
30 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.
/**
* 覆盖网络 · **序㊱ 「节点一键加入与分组准入」P1(S1–S4)** 单测。
*
* ## 这个文件要回答的六个问题(= `交接单_节点一键加入与分组准入_20260918.md §5` 的 J1–J6)
* | 组 | 判据 | 落在哪个 J |
* |---|---|---|
* | A | **邀请凭据**:网络绑定 / 有效期 / 受信签名者 / 载荷形状,四件都**具名**拒 | J1 前半 |
* | B | **一次性**:同一 nonce 二次使用被拒;**并发**下**恰好一台**拿到(内核原子占位) | **J1** |
* | C | **网注册表**:`pending` 不进白名单、`approved` 才进;批准不存在的节点被具名拒 | J2/J4 |
* | D | **白名单派生**:分桶形状 + 与 `normalizeDialers` **同一入口** + **跨网零共享**(结构性) | **J5** |
* | E | **join 编排**:四步真跑;申请单**字节级**不含私钥;失败**具名且带步迹** | J4/J6 |
* | F | **「⛔ 不许静默拒绝」的机器判据**:join / registry 产物里**零空 `catch` 块** | §9-5 |
*
* ## 🔴 「先红后绿」在本文件里的落点(⛔ 不是口头声明)
* `lib/net/relay/registry.js` 里把 `consumeNonce` 的 `'wx'` 改成 `'w'`(= 不再原子占位)
* ⇒ **B 组的 `T9`(并发恰好一台)与 `T8`(二次使用)转红**,其余全绿;改回 ⇒ 全绿。
* 该实验的原文级输出记录在执行棒回报 §8.2(⚠️ 破坏的是 `lib` 产物、`src` 未动)。
*
* 运行:`node --test test/overlay-join.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
* ⚠️ **刻意不进 `npm test`** —— 那个脚本是**硬编码文件列表**,加进去会改测试总数(与序㉔/㉘/㉚ 同纪律)。
*
* @module test/overlay-join
*/
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { generateAuthorityKey, generateNodeKey, publicKeyOfPrivate, signPayloadWith } from '../lib/net/relay/identity.js'
import {
NETWORK_INVITE_TAG,
applyApplication,
approveNode,
auditDerivation,
consumeNonce,
deriveDialers,
deriveDropIn,
emptyRegistry,
loadRegistry,
newInviteNonce,
parseNetworkInvite,
parseRegistry,
removeNode,
saveRegistry,
summarizeNetworks,
verifyNetworkInvite,
} from '../lib/net/relay/registry.js'
import { JOIN_STEPS, joinReasonOfInvite, parseApplication, readApplicationFile, runJoin } from '../lib/net/relay/join.js'
import { normalizeDialers } from '../lib/net/relay/network.js'
// ── 夹具 ────────────────────────────────────────────────────────────────────
const tmps = []
function mkTmp(tag) {
const d = mkdtempSync(join(tmpdir(), `dshs-join-${tag}-`))
tmps.push(d)
return d
}
process.on('exit', () => {
for (const d of tmps) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* 临时目录清理失败不影响判据 */
}
}
})
/** 一把一次性使用的**测试签名者**(⛔ 与生产密钥无关;用完即丢)。 */
function signerFixture() {
const k = generateAuthorityKey()
return { pem: k.privateKeyPem, pub: k.publicKey }
}
function inviteFixture(signer, network, opts = {}) {
const doc = {
version: 1,
network,
nonce: opts.nonce ?? newInviteNonce(randomBytes),
issuedAt: new Date(opts.now ?? Date.now()).toISOString(),
expiresAt: opts.expiresAt ?? new Date((opts.now ?? Date.now()) + 10 * 60 * 1000).toISOString(),
}
return { doc, sig: signPayloadWith(signer.pem, invitePayloadOf(doc)) }
}
/** 与 `registry.ts#networkInvitePayload` 同一条规范拼接(⛔ 测试里不另造口径)。 */
function invitePayloadOf(doc) {
return [
NETWORK_INVITE_TAG,
`version=${String(doc.version)}`,
`network=${doc.network}`,
`nonce=${doc.nonce}`,
`issuedAt=${doc.issuedAt}`,
`expiresAt=${doc.expiresAt}`,
].join('\n')
}
/** 一个**完全离线**的 `JoinIo`:真写临时目录,⛔ 不发网络。 */
function fakeIo(calls = {}) {
return {
exists: existsSync,
read: (p) => readFileSync(p, 'utf8'),
write: (p, text, mode) => {
mkdirSync(join(p, '..'), { recursive: true })
writeFileSync(p, text, { mode })
},
generateNodeKey,
publicKeyOfPrivate,
hostname: () => 'test-host',
post: async (url, body) => {
calls.posts = calls.posts ?? []
calls.posts.push({ url, body })
return calls.postReply ?? { status: 200, body: '{"ok":true}' }
},
}
}
function joinOpts(dir, network, invite, over = {}) {
return {
network,
hostId: 'node-a',
invite,
trustedSigners: [over.signerPub],
nodeKeyFile: join(dir, 'node.key'),
localConfigFile: join(dir, 'node.json'),
outFile: join(dir, 'application.json'),
...over,
}
}
// ── A 组 · 邀请凭据(S2)─────────────────────────────────────────────────────
test('A1 正当邀请:验签通过(网络/有效期/形状四件都过)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'ops' })
assert.equal(v.ok, true, `期望通过,实际 ${JSON.stringify(v)}`)
assert.equal(v.doc.network, 'ops')
})
test('A2 坏签名 ⇒ 具名拒(invite-signature-mismatch,由 joinReasonOfInvite 映射)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, `${inv.sig.slice(0, -6)}AAAAAA`, [s.pub], { network: 'ops' })
assert.equal(v.ok, false)
assert.notEqual(v.reason, 'ok')
// 映射必须落在**具名**的 invite-* 上,⛔ 不许落成通用失败
assert.match(joinReasonOfInvite(v.reason), /^invite-/)
})
test('A3 过期 ⇒ expired(有效期是真判据)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops', { now: Date.now() - 3600_000, expiresAt: new Date(Date.now() - 60_000).toISOString() })
const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'ops' })
assert.equal(v.ok, false)
assert.equal(v.reason, 'expired')
assert.equal(joinReasonOfInvite(v.reason), 'invite-expired')
})
test('A4 网络绑定:拿 ops 的邀请去加入别的网 ⇒ network-mismatch(⛔ 不静默放行)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'u:5' })
assert.equal(v.ok, false)
assert.equal(v.reason, 'network-mismatch')
})
test('A5 无受信签名者 ⇒ 不可验 = 不接受(⛔ 不降级)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, inv.sig, [], { network: 'ops' })
assert.equal(v.ok, false)
assert.equal(v.reason, 'no-trusted-keys')
assert.equal(joinReasonOfInvite(v.reason), 'invite-no-trusted-signer')
})
test('A6 载荷形状非法(nonce 不是 32 hex)⇒ bad-payload(⛔ 不猜、不补默认值)', () => {
assert.equal(parseNetworkInvite({ version: 1, network: 'ops', nonce: 'short', issuedAt: 'x', expiresAt: '' }), undefined)
assert.equal(parseNetworkInvite({ version: 1, network: '', nonce: 'a'.repeat(32), issuedAt: 'x', expiresAt: '' }), undefined)
assert.equal(parseNetworkInvite(null), undefined)
// ⛔ 裸 `u` 是保留字:不是合法网名 ⇒ 邀请形状也不成立
assert.equal(parseNetworkInvite({ version: 1, network: 'u', nonce: 'a'.repeat(32), issuedAt: 'x', expiresAt: '' }), undefined)
})
test('A7 🔴 邀请凭据载荷**逐字不含任何密钥本体**(只有网 / nonce / 有效期)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const text = JSON.stringify(inv)
const privBody = s.pem.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('')
assert.ok(privBody.length > 40, '夹具本身要有可探的私钥主体')
assert.equal(text.includes(privBody), false, '⛔ 邀请里出现了签名者私钥')
// 键集合也钉死:多一个 `key` 字段就是回归
assert.deepEqual(Object.keys(inv.doc).sort(), ['expiresAt', 'issuedAt', 'network', 'nonce', 'version'])
})
// ── B 组 · 一次性(S2 · J1)──────────────────────────────────────────────────
test('B1 同一 nonce 第二次使用 ⇒ invite-already-used(一次性成立)', () => {
const dir = mkTmp('ledger')
const led = { dir: join(dir, 'consumed') }
const n = newInviteNonce(randomBytes)
const first = consumeNonce(led, n, 'first')
const second = consumeNonce(led, n, 'second')
assert.equal(first.ok, true, `首次应成功:${JSON.stringify(first)}`)
assert.equal(second.ok, false)
assert.equal(second.reason, 'invite-already-used')
// 台账文件确实落了盘(⇒ "失败"不是因为"根本没写")
assert.equal(readdirSync(led.dir).length, 1)
})
test('B2 🔴 并发:12 个进程同时抢同一个 nonce ⇒ **恰好 1 个**拿到', () => {
const dir = mkTmp('race')
const ledDir = join(dir, 'consumed')
mkdirSync(ledDir, { recursive: true })
const nonce = newInviteNonce(randomBytes)
// 真并发:走**独立进程**(同进程内的"并发"是假并发,⛔ 判不出竞态)
const worker = join(dir, 'worker.cjs')
writeFileSync(
worker,
[
"const reg = require(process.env.REG_LIB)",
"const r = reg.consumeNonce({ dir: process.env.NONCE_DIR }, process.env.NONCE, 'race')",
"process.stdout.write(r.ok ? 'ok' : r.reason)",
].join('\n'),
'utf8',
)
const regLib = join(process.cwd(), 'lib', 'net', 'relay', 'registry.js')
const results = []
for (let i = 0; i < 12; i++) {
results.push(
execFileSync(process.execPath, [worker], {
encoding: 'utf8',
env: { ...process.env, NONCE_DIR: ledDir, NONCE: nonce, REG_LIB: regLib },
}),
)
}
const oks = results.filter((r) => r === 'ok').length
assert.equal(oks, 1, `⛔ 竞态未守住:ok=${oks}(须恰好 1)|读数 ${JSON.stringify(results)}`)
assert.equal(results.filter((r) => r === 'invite-already-used').length, 11)
assert.equal(readdirSync(ledDir).length, 1)
})
test('B3 nonce 形状非法 ⇒ 拒(⛔ 不许"非法也当未用过"放过去)', () => {
const dir = mkTmp('bad-nonce')
const led = { dir: join(dir, 'consumed') }
const r = consumeNonce(led, 'ZZZZ', 'x')
assert.equal(r.ok, false)
assert.equal(r.reason, 'invite-already-used')
})
// ── C 组 · 网注册表(S1)────────────────────────────────────────────────────
test('C1 空注册表 ⇒ 派生为空 ⇒ 默认拒绝一切拨号(不是"默认放行")', () => {
const r = emptyRegistry()
const d = deriveDialers(r)
assert.equal(d.size, 0)
assert.equal(auditDerivation(r).ok, true)
assert.equal(summarizeNetworks(r).length, 0)
})
test('C2 收单只产生 pending;🔴 pending **不进**派生白名单', () => {
const r = emptyRegistry()
const e = applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
assert.equal(e.ok, true)
assert.equal(e.record.status, 'pending')
assert.equal(deriveDialers(r).get('ops')?.has('w-108') ?? false, false, '⛔ 待批节点不许进白名单')
})
test('C3 批准才进白名单;移除即退出', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
const ap = approveNode(r, 'ops', 'w-108', 'T1')
assert.equal(ap.ok, true)
assert.equal(deriveDialers(r).get('ops')?.has('w-108'), true)
const rm = removeNode(r, 'ops', 'w-108')
assert.equal(rm.ok, true)
assert.equal(deriveDialers(r).get('ops')?.has('w-108') ?? false, false)
})
test('C4 🔴 批准一个**没申请过**的节点 ⇒ unknown-node(⛔ 不凭空批准)', () => {
const r = emptyRegistry()
const ap = approveNode(r, 'ops', 'never-applied', 'T1')
assert.equal(ap.ok, false)
assert.equal(ap.reason, 'unknown-node')
// 非法网名也要**具名**,⛔ 不抛异常
assert.deepEqual(approveNode(r, 'u', 'x', 'T1'), { ok: false, reason: 'unknown-network' })
})
test('C5 已批准节点重复申请 ⇒ **保持 approved**(幂等:重装 / 重跑 join 不该被踢回待批)', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
approveNode(r, 'ops', 'w-108', 'T1')
const again = applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'b'.repeat(64), at: 'T2' })
assert.equal(again.ok, true)
assert.equal(again.record.status, 'approved')
assert.equal(again.record.approvedAt, 'T1', '批准时刻不该被重申请刷新')
})
test('C6 解析**严格**:status 拼错 / 键与记录不符 ⇒ undefined(⛔ 不静默修复)', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
assert.notEqual(parseRegistry(r), undefined)
const bad = JSON.parse(JSON.stringify(r))
bad.nodes['ops/w-108'].status = 'aproved'
assert.equal(parseRegistry(bad), undefined)
const misfiled = JSON.parse(JSON.stringify(r))
misfiled.nodes['u:5/w-108'] = misfiled.nodes['ops/w-108']
assert.equal(parseRegistry(misfiled), undefined, '键(逻辑名)与记录里的网/节点名必须一致')
})
test('C7 落盘往返:稳定排序 ⇒ 无改动时字节级一致', () => {
const dir = mkTmp('roundtrip')
const file = join(dir, 'nodes.json')
const r = emptyRegistry()
applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'c'.repeat(64), at: 'T0' })
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
saveRegistry(file, r)
const first = readFileSync(file, 'utf8')
const back = loadRegistry(file)
saveRegistry(file, back)
assert.equal(readFileSync(file, 'utf8'), first, '往返必须逐字一致(否则 diff 永远"有改动")')
assert.equal(loadRegistry(join(dir, 'absent.json')).nodes && Object.keys(loadRegistry(join(dir, 'absent.json')).nodes).length, 0)
})
// ── D 组 · 白名单派生(S4 · J5)─────────────────────────────────────────────
test('D1 分桶形态:两张网各自独立,同一 hostId 在两网**不串桶**', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'pc-1', nodeKey: 'a'.repeat(64), at: 'T0' })
applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'a'.repeat(64), at: 'T0' })
approveNode(r, 'ops', 'pc-1', 'T1')
approveNode(r, 'u:5', 'pc-1', 'T1')
const d = deriveDialers(r)
assert.equal(d.get('ops')?.has('pc-1'), true)
assert.equal(d.get('u:5')?.has('pc-1'), true)
// 🔴 独立性判据:**移除一张网里的它**,另一张网**不受影响**
removeNode(r, 'u:5', 'pc-1')
assert.equal(deriveDialers(r).get('ops')?.has('pc-1'), true, '⛔ 移除 u:5 的 pc-1 连带把 ops 的也删了')
assert.equal(deriveDialers(r).get('u:5')?.has('pc-1') ?? false, false)
})
test('D2 🔴 派生结果**必须能被 normalizeDialers 吃**(与手写 drop-in 同一条归一化入口)', () => {
const r = emptyRegistry()
for (const [net, host] of [
['ops', 'w-108'],
['u:5', 'pc-1'],
['lab-net', 'lab-1'],
]) {
applyApplication(r, { network: net, hostId: host, nodeKey: 'd'.repeat(64), at: 'T0' })
approveNode(r, net, host, 'T1')
}
const derived = deriveDialers(r)
const normalized = normalizeDialers(derived)
assert.equal(normalized.size, 3, `期望 3 张网,实际 ${[...normalized.keys()].join(',')}`)
assert.equal(normalized.get('ops')?.has('w-108'), true)
assert.equal(normalized.get('u:5')?.has('pc-1'), true)
assert.equal(normalized.get('lab-net')?.has('lab-1'), true)
// ⛔ 跨网串桶 = 结构性隔离失效;这里钉死"每张网只看得到自己的成员"
assert.equal(normalized.get('ops')?.has('pc-1') ?? false, false)
assert.equal(normalized.get('u:5')?.has('w-108') ?? false, false)
})
test('D3 auditDerivation:人工造"错桶" ⇒ misfiled **点名**(跨网零共享的机器判据)', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
approveNode(r, 'ops', 'w-108', 'T1')
applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'b'.repeat(64), at: 'T0' })
approveNode(r, 'u:5', 'pc-1', 'T1')
assert.equal(auditDerivation(r).ok, true)
// 造错桶:把 u:5 的 pc-1 塞进 ops 桶(模拟"实现把两张网合并了")
const bad = new Map(deriveDialers(r))
bad.get('ops').add('pc-1')
const audit = auditDerivation(r, bad)
assert.equal(audit.ok, false)
assert.ok(audit.misfiled.some((m) => m.includes('ops/pc-1')), `misfiled 应点名 ops/pc-1:${JSON.stringify(audit.misfiled)}`)
// 另一类错:集合比 approved 多了/少了
const short = new Map()
short.set('ops', new Set())
short.set('u:5', new Set(['pc-1']))
const audit2 = auditDerivation(r, short)
assert.equal(audit2.ok, false)
assert.ok(audit2.mismatches.some((m) => m.includes('ops') && m.includes('缺[w-108]')), JSON.stringify(audit2.mismatches))
})
test('D4 deriveDropIn 用**逻辑名**形态、且写明"手写 drop-in 是应急通道"', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'lab-net', hostId: 'lab-1', nodeKey: 'e'.repeat(64), at: 'T0' })
approveNode(r, 'lab-net', 'lab-1', 'T1')
const content = deriveDropIn(r, { network: 'lab-net' })
assert.match(content, /Environment="DSHS_RELAY_DIALERS=lab-net\/lab-1"/, content)
assert.match(content, /应急通道/, '必须写明"手写 drop-in 是应急通道,⛔ 不删"')
assert.match(content, /\[Service\]/, '必须是 systemd drop-in 形态')
})
// ── E 组 · join 编排(S3 · J4/J6)──────────────────────────────────────────
test('E1 四步真跑全绿:真生成密钥 + 真落盘 + 真出申请单', async () => {
const dir = mkTmp('join-ok')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true, `期望成功:${JSON.stringify(out)}`)
assert.deepEqual(
out.steps.map((x) => x.step),
[...JOIN_STEPS],
'步迹必须覆盖四步且顺序一致',
)
assert.equal(existsSync(join(dir, 'node.key')), true)
assert.equal(existsSync(join(dir, 'node.json')), true)
assert.equal(existsSync(join(dir, 'application.json')), true)
})
test('E2 🔴 申请单**字节级**不含私钥,但含公钥(私钥不出机)', async () => {
const dir = mkTmp('join-leak')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true)
const app = readFileSync(join(dir, 'application.json'), 'utf8')
const priv = readFileSync(join(dir, 'node.key'), 'utf8')
const body = priv.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('')
assert.ok(body.length > 40)
assert.equal(app.includes(body), false, '⛔ 申请单里出现了节点私钥本体')
assert.equal(app.includes(out.nodeKey), true, '公钥必须在(否则控制面无法登记)')
// 本机配置里也只能记"私钥在哪",不能记私钥本身
assert.equal(readFileSync(join(dir, 'node.json'), 'utf8').includes(body), false, '⛔ 本机配置里出现了私钥本体')
})
test('E3 节点密钥已存在 ⇒ **复用**(重跑 join 不换钥匙,公钥不变)', async () => {
const dir = mkTmp('join-reuse')
const s = signerFixture()
const first = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(first.ok, true)
const before = readFileSync(join(dir, 'node.key'), 'utf8')
const second = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(second.ok, true)
assert.equal(second.nodeKey, first.nodeKey, '⛔ 重跑 join 换了公钥 ⇒ 会导致控制面登记与实物不符')
assert.equal(readFileSync(join(dir, 'node.key'), 'utf8'), before)
assert.match(second.steps[1].detail, /复用既有/)
})
test('E4 ⛔ 既没 --portal 也没 --out ⇒ register-malformed(不静默成功)', async () => {
const dir = mkTmp('join-nochan')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '' }), fakeIo())
assert.equal(out.ok, false)
assert.equal(out.step, 'register')
assert.equal(out.reason, 'register-malformed')
})
test('E5 HTTP 通道:4xx 必须带回控制面的原因码(⛔ 不许说成"网络不通")', async () => {
const dir = mkTmp('join-http')
const s = signerFixture()
const io = fakeIo({ postReply: { status: 409, body: '{"error":"invite-already-used"}' } })
const out = await runJoin(
joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '', portalUrl: 'https://cp.example/dshs-overlay/join' }),
io,
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'register-rejected')
assert.match(out.detail, /invite-already-used/, '控制面的原因码必须原样带回来')
})
test('E6 HTTP 通道:连不上 ⇒ register-unreachable(与"被拒"可分)', async () => {
const dir = mkTmp('join-http2')
const s = signerFixture()
const io = fakeIo()
io.post = async () => {
throw new Error('ECONNREFUSED')
}
const out = await runJoin(
joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '', portalUrl: 'https://cp.example/x' }),
io,
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'register-unreachable')
})
test('E7 失败**带步迹**:停在第一步时能看到是哪一步、哪种原因', async () => {
const dir = mkTmp('join-trace')
const s = signerFixture()
const bad = inviteFixture(s, 'ops')
bad.sig = `${bad.sig.slice(0, -6)}AAAAAA`
const out = await runJoin(joinOpts(dir, 'ops', bad, { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, false)
assert.equal(out.step, 'verify-invite')
assert.equal(out.steps.length, 1)
assert.equal(out.steps[0].ok, false)
// 🔴 第一步失败 ⇒ ⛔ 不许产生后面几步的副作用
assert.equal(existsSync(join(dir, 'node.key')), false, '⛔ 凭据没过就生成了密钥(无谓副作用)')
})
test('E8 无受信签名者 ⇒ 连密钥都不生成(不可验 = 不接受,且不浪费副作用)', async () => {
const dir = mkTmp('join-nosigner')
const s = signerFixture()
const out = await runJoin(
{ ...joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), trustedSigners: [] },
fakeIo(),
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'invite-no-trusted-signer')
assert.equal(existsSync(join(dir, 'node.key')), false)
})
// ── F 组 · 机器判据:「⛔ 不许静默拒绝」──────────────────────────────────────
test('F1 join / registry 产物里**零空 catch 块**(静默失败的可断言面)', () => {
const files = [join(process.cwd(), 'lib', 'net', 'relay', 'join.js'), join(process.cwd(), 'lib', 'net', 'relay', 'registry.js')]
const emptyCatch = /catch\s*(\([^)]*\))?\s*\{\s*\}/
// ⚠️ **先剥注释再扫** —— 本文件的 doc 注释里**引用**了 `catch {}` 这个写法(在讲"我们不用它"),
// 不剥就会把自己讲道理的那句话判成违规(= 判据打在文字上,不打在代码上)。
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
const offenders = []
for (const f of files) {
assert.equal(existsSync(f), true, `缺产物 ${f}(先 npm run build)`)
const code = stripComments(readFileSync(f, 'utf8'))
if (emptyCatch.test(code)) offenders.push(f)
}
assert.deepEqual(offenders, [], `⛔ 出现空 catch(= 静默拒绝):${offenders.join(', ')}`)
})
test('F2 节点私钥落点权限:**只在 Linux 上可判**,Windows 上判据必须说"不可判"而非 PASS', async () => {
const dir = mkTmp('join-mode')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true)
const mode = statSync(join(dir, 'node.key')).mode & 0o777
if (process.platform === 'linux') {
assert.equal(mode.toString(8), '600', `⛔ 私钥权限不是 600:${mode.toString(8)}`)
} else {
// 🔴 「没测到」与「不成立」必须可分 —— ⛔ 不许把"这台机器测不出来"当绿
assert.notEqual(process.platform, 'linux')
assert.ok(true, `platform=${process.platform} ⇒ 权限判据**本平台不可判**(真机腿 = 47 上 stat -c %a 实测)`)
}
})
test('E9 CLI 回环:join 产出的申请单必须能被控制面 apply 的解析读回(真机首轮栽在这)', async () => {
const dir = mkTmp('join-roundtrip')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true)
// 🔴 这两步合起来就是控制面 `apply` 的真实路径:读文件 → 解析申请单
const app = readApplicationFile(join(dir, 'application.json'))
assert.equal(app.hostId, 'node-a')
assert.equal(app.network, 'ops')
assert.equal(app.nodeKey, out.nodeKey)
const v = verifyNetworkInvite(app.invite.doc, app.invite.sig, [s.pub], { network: 'ops' })
assert.equal(v.ok, true, `申请单里内嵌的邀请必须能验通:${JSON.stringify(v)}`)
const led = { dir: join(dir, 'consumed') }
assert.equal(consumeNonce(led, v.doc.nonce, 'apply').ok, true)
assert.equal(consumeNonce(led, v.doc.nonce, 'apply again').ok, false)
const r = emptyRegistry()
const e = applyApplication(r, { network: app.network, hostId: app.hostId, nodeKey: app.nodeKey, at: 'T0' })
assert.equal(e.ok, true)
assert.equal(e.record.status, 'pending', '⛔ 收单只产生 pending(批准权在控制面)')
// 形状严格:缺 invite / nodeKey 非 64 hex / 空对象 ⇒ undefined
assert.equal(parseApplication({ hostId: 'a', network: 'ops', nodeKey: 'x'.repeat(64), appliedAt: 't', invite: {} }), undefined)
assert.equal(parseApplication({ hostId: 'a', network: 'ops', nodeKey: 'x', appliedAt: 't', invite: { doc: {}, sig: 'x' } }), undefined)
assert.equal(parseApplication({}), undefined)
assert.equal(parseApplication(null), undefined)
})
// ── E10:**CLI 级**真回环(真子进程,⛔ 不是调库)─────────────────────────────
// 🔴 为什么必须有这条:E9 是**调库**回环,它绕过了 CLI 的**参数解析**,
// 所以「`apply --file <申请单>` 把注册表文件也指到申请单」这个真机缺陷
// **E9 抓不到**(真机首轮就栽在这:收单炸在 `loadReg`,而 nonce 已被占位
// ⇒ 表现为「收单失败 + 同一张邀请再也用不了」)。
// 本用例只走 `node <cli> …`,任何参数键名混用都会在这里具名转红。
test('E10 CLI 真回环:`apply --file` ⛔ 不得改写注册表路径(--file 与 --registry 两键分离)', () => {
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)))
const admit = join(repo, 'scripts', 'overlay-node-admit.cjs')
const joinCli = join(repo, 'scripts', 'overlay-node-join.cjs')
const keyring = join(repo, 'scripts', 'overlay-keyring.cjs')
const root = mkTmp('cli-roundtrip')
const regDir = join(root, 'reg')
const run = (file, args) =>
execFileSync(process.execPath, [file, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
const runFail = (file, args) => {
try {
run(file, args)
return ''
} catch (err) {
return `${err.stdout ?? ''}${err.stderr ?? ''}`
}
}
// (1) 一次性测试签名者 / init / 签发邀请
run(keyring, ['init-signer', '--key', join(root, 'signer.key')])
const pub = readFileSync(join(root, 'signer.key.pub'), 'utf8').trim()
run(admit, ['init', '--dir', regDir])
run(admit, [
'issue-invite', '--network', 'lab-net',
'--signer-key', join(root, 'signer.key'),
'--out', join(root, 'invite.json'), '--dir', regDir,
])
// (2) 节点一条命令 join ⇒ 申请单
run(joinCli, [
'--network', 'lab-net', '--host', 'lab-1',
'--invite', join(root, 'invite.json'), '--signer-pub', pub,
'--key', join(root, 'node.key'), '--config', join(root, 'node.json'),
'--out', join(root, 'app.json'),
])
// (3) 🔴 判据本体:`apply --file <申请单>` 必须**成功**,且注册表仍落在 regDir
const outApp = run(admit, ['apply', '--file', join(root, 'app.json'), '--signer-pub', pub, '--dir', regDir])
assert.match(outApp, /已收单/, '⛔ `apply --file` 必须成功收单')
const regFile = join(regDir, 'nodes.json')
assert.equal(existsSync(regFile), true, '⛔ 注册表必须落在 `--dir` 下的 nodes.json')
const saved = loadRegistry(regFile)
assert.equal(saved.nodes['lab-net/lab-1']?.status, 'pending', '⛔ 收单只产生 pending')
// (4) 同一张邀请二次收单 ⇒ 必须是 **invite-already-used**;
// ⛔ 一旦出现「注册表 …形状非法」就说明 `--file` 又被当成注册表文件了(键名混用回归)
const err2 = runFail(admit, ['apply', '--file', join(root, 'app.json'), '--signer-pub', pub, '--dir', regDir])
assert.match(err2, /invite-already-used/, '⛔ 二次收单必须具名拒 invite-already-used')
assert.doesNotMatch(err2, /形状非法/, '⛔ 出现"形状非法"= `--file` 键名混用回归')
// (5) 批准 ⇒ 派生里必须出现这条拨号
run(admit, ['approve', '--network', 'lab-net', '--host', 'lab-1', '--dir', regDir])
const d = run(admit, ['derive', '--network', 'lab-net', '--dir', regDir])
assert.match(d, /Environment="DSHS_RELAY_DIALERS=lab-net\/lab-1"/, '⛔ approved 必须进派生白名单')
// (6) `--registry` 是注册表文件的唯一覆盖键 —— 指到别处 ⇒ 那里为空表(= 确实换了文件)
const dAlt = run(admit, ['list', '--registry', join(root, 'alt.json'), '--dir', regDir])
assert.match(dAlt, /0 张网 0 个节点/, '⛔ --registry 必须真的改写注册表文件路径')
})