Files
dsh_ai1net_server/test/overlay-join.test.mjs
T

634 lines
30 KiB
JavaScript
Raw Normal View History

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