把这批「已 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)
635 lines
30 KiB
JavaScript
635 lines
30 KiB
JavaScript
/**
|
||
* 覆盖网络 · **序㊱ 「节点一键加入与分组准入」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 必须真的改写注册表文件路径')
|
||
})
|