/** * 覆盖网络 · **序㊱ 「节点一键加入与分组准入」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 …`,任何参数键名混用都会在这里具名转红。 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 必须真的改写注册表文件路径') })