From bc0684aaf396497705e9655f00f33cf554a3aa7e Mon Sep 17 00:00:00 2001 From: Windsander Date: Mon, 14 Sep 2026 22:50:59 +0800 Subject: [PATCH] =?UTF-8?q?test(cross):=20G6.6=20=E8=B7=A8=E7=BD=91=20e2e?= =?UTF-8?q?=20=E7=BA=A2=E7=81=AF=E9=97=A8=E7=A6=81=20+=20=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E9=80=BB=E8=BE=91=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(scripts): wan-egress.mjs 抽出出口判据(lookupEgress/isPrivateIp/judgeDifferentNetwork),wan-sync 改为复用 - feat(scripts): verify-mcp-cross.mjs 门禁——无环境退出码 1 并打印所需 env;环境齐备驱动 A memory_write / B memory_sync / 双端 memory_status,产出 mcp-cross-evidence.json 并以 judgeCrossEvidence 判定 - feat(scripts): mcp-cross-lib.mjs 纯函数(checkCrossEnv/judgeCrossEvidence/printCrossGuidance)便于单测 - test(cross): tests/wan/mcp-cross.test.mjs(13 例)覆盖缺 env、逐项不达标、出口判据、真实调用脚本无环境→退出码 1 - chore: package.json 增 verify:mcp:cross(不纳入 CI——无环境必红) G6.6 仍阻塞未勾选;本提交只落门禁,不产生跨网证据。 --- package.json | 1 + scripts/mcp-cross-lib.mjs | 68 +++++++++++++++ scripts/verify-mcp-cross.mjs | 157 +++++++++++++++++++++++++++++++++++ scripts/wan-egress.mjs | 56 +++++++++++++ scripts/wan-sync.mjs | 55 +----------- tests/wan/mcp-cross.test.mjs | 112 +++++++++++++++++++++++++ 6 files changed, 395 insertions(+), 54 deletions(-) create mode 100644 scripts/mcp-cross-lib.mjs create mode 100644 scripts/verify-mcp-cross.mjs create mode 100644 scripts/wan-egress.mjs create mode 100644 tests/wan/mcp-cross.test.mjs diff --git a/package.json b/package.json index ca454f8..72e8ca0 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "verify:mcp:stdio": "node scripts/verify-mcp-stdio.mjs", "verify:mcp:http": "node scripts/verify-mcp-http.mjs", "verify:mcp:oauth": "node scripts/verify-mcp-oauth-hardening.mjs", + "verify:mcp:cross": "node scripts/verify-mcp-cross.mjs", "verify:mcp:skill": "node scripts/verify-mcp-skill.mjs", "verify:mcp:publish": "node scripts/verify-mcp-publish.mjs", "dev": "node --loader ts-node/esm src/index.ts" diff --git a/scripts/mcp-cross-lib.mjs b/scripts/mcp-cross-lib.mjs new file mode 100644 index 0000000..990de42 --- /dev/null +++ b/scripts/mcp-cross-lib.mjs @@ -0,0 +1,68 @@ +// G6.6 跨网 e2e 门禁:需求检查与证据判定(纯函数,可单测) +// +// 与 `verify-mcp-cross.mjs` 分离,使「红/绿判定逻辑」可在无两台主机时被单元测试覆盖; +// 真实跨网执行仍只在环境齐备时由 verify-mcp-cross.mjs 驱动。 + +export const REQUIRED_ENV = [ + ['MEBULAR_WAN_PEER', 'A 机可达 libp2p multiaddr(含 relay circuit),用作 memory_sync.address'], + ['MEBULAR_WAN_PEER_ID', 'A 机 deviceId,用作 memory_sync.peerId 并做身份核对'], + ['MEBULAR_WAN_MCP_A', 'A 机 serve base URL(非环回须 TLS + auth != none,D45),如 https://A.example:7331'], + ['MEBULAR_WAN_MCP_A_TOKEN', '访问 A 机 /mcp 的 bearer/oauth 令牌'], +]; + +export const OPTIONAL_ENV = [ + ['MEBULAR_WAN_MCP_B', 'B 机 serve base(默认 http://127.0.0.1:7331)'], + ['MEBULAR_WAN_MCP_B_TOKEN', 'B 机 /mcp 令牌(环回 auth=none 时可省)'], + ['MEBULAR_WAN_RELAY', 'relay multiaddr(预检其 TCP 可达性)'], + ['MEBULAR_WAN_PEER_EGRESS', 'A 机实测公网出口 IP(用于 differentPublicNetwork;缺省则判据为 null → 失败)'], + ['MEBULAR_MCP_CROSS_OUT', '证据 JSON 输出路径(默认 mcp-cross-evidence.json)'], +]; + +/** 检查跨网门禁所需 env;缺失即返回 ok:false 并列出 missing。 */ +export function checkCrossEnv(env = process.env) { + const missing = REQUIRED_ENV.filter(([k]) => !env[k]).map(([k]) => k); + return { + ok: missing.length === 0, + missing, + config: { + peer: env.MEBULAR_WAN_PEER ?? null, + peerId: env.MEBULAR_WAN_PEER_ID ?? null, + mcpA: env.MEBULAR_WAN_MCP_A ?? null, + tokenA: env.MEBULAR_WAN_MCP_A_TOKEN ?? null, + mcpB: env.MEBULAR_WAN_MCP_B ?? 'http://127.0.0.1:7331', + tokenB: env.MEBULAR_WAN_MCP_B_TOKEN ?? null, + relay: env.MEBULAR_WAN_RELAY ?? null, + peerEgress: env.MEBULAR_WAN_PEER_EGRESS ?? null, + out: env.MEBULAR_MCP_CROSS_OUT ?? 'mcp-cross-evidence.json', + }, + }; +} + +export function printCrossGuidance(log = console.log) { + log(''); + log('G6.6 跨网 e2e 所需环境(真实两台不同公网主机 + 公网可达 relay):'); + log(' 必需:'); + for (const [k, d] of REQUIRED_ENV) log(` ${k} — ${d}`); + log(' 可选:'); + for (const [k, d] of OPTIONAL_ENV) log(` ${k} — ${d}`); + log(' 另需:'); + log(' · A/B 两机各跑 `mebular serve`:.mebular/config.json 设 network.enabled=true、'); + log(' network.libp2p.listen 与 relayServers(该 relay)、relayUnlimited=true。'); + log(' · A 机 serve 若供网络访问,须 TLS 且 auth != none(D45 fail-closed)。'); + log(' · 带外交换 A 的 deviceId 与可达 multiaddr(无共享文件)。'); + log(' 执行清单:docs.design/g6.6-cross-network-blocker-2026-09-14.md'); + log(' 门禁:本脚本无上述环境时退出码 1(红),环境齐备且断言全过才退出码 0。'); +} + +/** 判定跨网证据是否达成 G6.6(全部为 true / 0 才算通过)。 */ +export function judgeCrossEvidence(evidence) { + const failures = []; + if (evidence?.markerFound !== true) failures.push('markerFound !== true(B 未召回 A 的写入)'); + if (evidence?.stateMatches !== true) failures.push('stateMatches !== true(双端 stateHash 不一致)'); + if (evidence?.identityShared !== true) failures.push('identityShared !== true(未以同一主密钥完成握手同步)'); + if (evidence?.differentPublicNetwork !== true) { + failures.push(`differentPublicNetwork !== true(${evidence?.differentPublicNetworkBasis ?? 'unknown'})`); + } + if (evidence?.pendingPeers !== 0) failures.push('pendingPeers !== 0'); + return { passed: failures.length === 0, failures }; +} diff --git a/scripts/verify-mcp-cross.mjs b/scripts/verify-mcp-cross.mjs new file mode 100644 index 0000000..7fd2218 --- /dev/null +++ b/scripts/verify-mcp-cross.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +// G6.6 跨网 e2e 门禁(E1,环境齐备时) +// +// 在两台不同公网网络的主机上:A/B 各跑 `mebular serve`(network.enabled,经 relay); +// 本脚本在 B 运行,经 MCP 驱动: +// A: memory_write 标记 → A/B: memory_status(取 peerId/stateHash) +// B: memory_sync { peerId:A-deviceId, address:A-multiaddr } → B: memory_query 命中 +// 产出机器可读证据 JSON,并以 judgeCrossEvidence 判定,退出码 0/1。 +// +// 无环境时**退出码 1** 并打印所需环境(红门禁),绝不静默通过。 +// 注意:本门禁需真实两机,不纳入 CI(CI 无环境必红);其判定逻辑由 tests/wan/mcp-cross.test.mjs 覆盖。 + +import { writeFile } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { Client } from '@modelcontextprotocol/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { preflightCross } from './wan-preflight.mjs'; +import { lookupEgress, judgeDifferentNetwork } from './wan-egress.mjs'; +import { checkCrossEnv, printCrossGuidance, judgeCrossEvidence } from './mcp-cross-lib.mjs'; + +const log = (line) => console.log(line); +const structuredOf = (result) => { + if (result?.structuredContent !== undefined) return result.structuredContent; + try { + return JSON.parse(result?.content?.find((c) => c.type === 'text')?.text ?? '{}'); + } catch { + return null; + } +}; + +async function connect(base, token) { + const headers = token ? { authorization: `Bearer ${token}` } : {}; + const transport = new StreamableHTTPClientTransport(new URL(`${String(base).replace(/\/$/, '')}/mcp`), { + requestInit: { headers }, + }); + const client = new Client({ name: 'mebular-mcp-cross', version: '0.1.0' }); + await client.connect(transport); + return client; +} + +log('Mebular G6.6 跨网 e2e 门禁'); +log('========================='); + +const env = checkCrossEnv(process.env); +if (!env.ok) { + log(`✗ 缺少跨网环境(${env.missing.join(', ')});未开始同步,退出码 1(红)。`); + printCrossGuidance(log); + process.exit(1); +} + +const { config } = env; +const evidence = { + generatedAt: new Date().toISOString(), + coordination: 'stable-address+out-of-band', + peer: config.peer, + peerId: config.peerId, + relay: config.relay, + mcpA: config.mcpA, + mcpB: config.mcpB, +}; + +let clientA = null; +let clientB = null; +try { + // 1) 前置预检(A 的 p2p 地址与 relay 的 TCP 可达性) + const pre = await preflightCross({ peer: config.peer, peerId: config.peerId, relay: config.relay, log }); + if (!pre) { + log('✗ 前置预检未通过,判失败(红)。'); + process.exit(1); + } + + // 2) 连接双端 MCP + clientA = await connect(config.mcpA, config.tokenA); + clientB = await connect(config.mcpB, config.tokenB); + + // 3) A 基线状态:核对 deviceId 与网络运行 + const statusA0 = structuredOf(await clientA.callTool({ name: 'memory_status', arguments: {} })); + const A = { deviceId: statusA0?.deviceId, peerId: statusA0?.peerId, listenAddrs: statusA0?.listenAddrs, relays: statusA0?.relays }; + evidence.A = { ...A, nodeCount: statusA0?.nodeCount, stateHash: statusA0?.stateHash }; + if (statusA0?.running !== true) { + log('✗ A 机 libp2p 未运行(network.enabled=false?),判失败(红)。'); + process.exit(1); + } + if (statusA0?.deviceId !== config.peerId) { + log(`✗ A 机 deviceId=${statusA0?.deviceId} 与 MEBULAR_WAN_PEER_ID=${config.peerId} 不符,判失败(红)。`); + process.exit(1); + } + + // 4) A 写入唯一标记 + const marker = `g6.6-cross-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; + const markerHash = createHash('sha256').update(marker).digest('hex').slice(0, 12); + await clientA.callTool({ name: 'memory_write', arguments: { items: [{ type: 'fact', content: marker, metadata: { g66: markerHash } }] } }); + evidence.marker = marker; + + // 5) B 基线状态 + 触发同步 + const statusB0 = structuredOf(await clientB.callTool({ name: 'memory_status', arguments: {} })); + evidence.B = { deviceId: statusB0?.deviceId, peerId: statusB0?.peerId, listenAddrs: statusB0?.listenAddrs, relays: statusB0?.relays, nodeCountBefore: statusB0?.nodeCount }; + let syncError = null; + try { + const sync = structuredOf(await clientB.callTool({ name: 'memory_sync', arguments: { peerId: config.peerId, address: config.peer } })); + evidence.sync = sync ?? null; + } catch (error) { + syncError = String(error?.message ?? error); + } + + // 6) B 复查:召回 + 状态 + const q = structuredOf(await clientB.callTool({ name: 'memory_query', arguments: { query: marker } })); + const statusB1 = structuredOf(await clientB.callTool({ name: 'memory_status', arguments: {} })); + evidence.markerFound = (q?.totalMatches ?? 0) >= 1; + + // 7) A 复查状态 + const statusA1 = structuredOf(await clientA.callTool({ name: 'memory_status', arguments: {} })); + evidence.A.nodeCount = statusA1?.nodeCount; + evidence.A.stateHash = statusA1?.stateHash; + evidence.B.nodeCount = statusB1?.nodeCount; + evidence.B.stateHash = statusB1?.stateHash; + evidence.B.pendingPeers = statusB1?.pendingPeers; + evidence.pendingPeers = statusB1?.pendingPeers; + evidence.stateMatches = Boolean(statusA1?.stateHash) && statusA1?.stateHash === statusB1?.stateHash; + evidence.identityShared = syncError === null && evidence.markerFound === true; + if (syncError) evidence.syncError = syncError; + + // 8) 公网出口判据(本地实测 + A 机实测值) + const localEgress = await lookupEgress(); + evidence.localEgress = localEgress; + evidence.peerEgress = { ip: config.peerEgress, source: process.env.MEBULAR_WAN_IP_ECHO ?? 'env:MEBULAR_WAN_PEER_EGRESS' }; + evidence.egressService = localEgress.source; + const judge = judgeDifferentNetwork(localEgress.ip, config.peerEgress); + evidence.differentPublicNetwork = judge.value; + evidence.differentPublicNetworkBasis = judge.basis; + + // 9) 判定 + 证据落盘 + const verdict = judgeCrossEvidence(evidence); + evidence.passed = verdict.passed; + evidence.failures = verdict.failures; + await writeFile(config.out, `${JSON.stringify(evidence, null, 2)}\n`, 'utf-8'); + + log(` A deviceId=${evidence.A.deviceId} nodeCount=${evidence.A.nodeCount} stateHash=${evidence.A.stateHash}`); + log(` B nodeCount=${evidence.B.nodeCount} stateHash=${evidence.B.stateHash} pendingPeers=${evidence.B.pendingPeers}`); + log(` markerFound=${evidence.markerFound} stateMatches=${evidence.stateMatches} identityShared=${evidence.identityShared}`); + log(` egress(local/peer)=${localEgress.ip ?? 'unknown'}/${config.peerEgress ?? 'unset'} basis=${judge.basis}`); + log(` 证据写入 ${config.out}`); + + if (verdict.passed) { + log('✓ G6.6 跨网 e2e 达成(E1):证据齐备且断言全过。'); + process.exit(0); + } + log('✗ G6.6 未达成:'); + for (const f of verdict.failures) log(` - ${f}`); + process.exit(1); +} catch (error) { + log(`✗ G6.6 执行失败:${String(error?.message ?? error)}`); + process.exit(1); +} finally { + await clientA?.close().catch(() => undefined); + await clientB?.close().catch(() => undefined); +} diff --git a/scripts/wan-egress.mjs b/scripts/wan-egress.mjs new file mode 100644 index 0000000..f53458d --- /dev/null +++ b/scripts/wan-egress.mjs @@ -0,0 +1,56 @@ +// 公网出口判据(G3-P2,抽为共享模块供 wan-sync 与 G6.6 跨网门禁复用) +// +// 用出口 IP(默认经 MEBULAR_WAN_IP_ECHO)判定两端是否位于不同公网网络; +// 私网/回环/未知一律不判为「不同」,绝不以网卡接口 IP 为判据。 + +export const DEFAULT_IP_ECHO = 'https://api.ipify.org?format=json'; + +export async function lookupEgress(timeoutMs = 4000) { + const source = process.env.MEBULAR_WAN_IP_ECHO || DEFAULT_IP_ECHO; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(source, { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = (await res.text()).trim(); + let ip = null; + let org = null; + try { + const j = JSON.parse(text); + ip = typeof j.ip === 'string' ? j.ip : null; + org = typeof j.org === 'string' ? j.org : null; + } catch { + ip = text; + } + if (!ip || !/^[0-9a-fA-F:.]+$/.test(ip)) throw new Error('unexpected egress payload'); + return { ip, org, asn: org ? org.split(/\s+/)[0] : null, source, error: null }; + } catch (error) { + return { ip: null, org: null, asn: null, source, error: String(error?.message ?? error) }; + } finally { + clearTimeout(timer); + } +} + +export function isPrivateIp(ip) { + if (!ip) return true; + const lower = ip.toLowerCase(); + if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true; + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); + if (!m) return true; // 非 IPv4 且非已知公网 v6 → 保守判私网/未知 + const a = Number(m[1]); + const b = Number(m[2]); + if (a === 10 || a === 127) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; +} + +/** 出口 IP 判据:任一未知/私网 → false/未知;均公网且不同 → true(绝不基于接口 IP) */ +export function judgeDifferentNetwork(localIp, peerIp) { + if (!localIp || !peerIp) return { value: null, basis: 'egress-unknown' }; + if (isPrivateIp(localIp) || isPrivateIp(peerIp)) return { value: false, basis: 'private-or-loopback' }; + if (localIp === peerIp) return { value: false, basis: 'same-egress-ip' }; + return { value: true, basis: 'distinct-public-egress' }; +} diff --git a/scripts/wan-sync.mjs b/scripts/wan-sync.mjs index 88035a5..1a505bf 100644 --- a/scripts/wan-sync.mjs +++ b/scripts/wan-sync.mjs @@ -29,6 +29,7 @@ const selfPath = fileURLToPath(import.meta.url); const mebular = await import(join(rootDir, 'dist', 'index.js')); const { Mebular, IdentityManager, Libp2pProvider } = mebular; import { preflightCross } from './wan-preflight.mjs'; +import { lookupEgress, judgeDifferentNetwork } from './wan-egress.mjs'; const TYPES = ['entity', 'fact', 'episode', 'skill', 'meta']; @@ -402,60 +403,6 @@ async function runRelay() { process.on('SIGTERM', shutdown); } -// ---------- 出口 IP 判据(P2-3:公网出口,非网卡接口) ---------- - -const DEFAULT_IP_ECHO = 'https://api.ipify.org?format=json'; - -async function lookupEgress(timeoutMs = 4000) { - const source = process.env.MEBULAR_WAN_IP_ECHO || DEFAULT_IP_ECHO; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(source, { signal: controller.signal }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const text = (await res.text()).trim(); - let ip = null; - let org = null; - try { - const j = JSON.parse(text); - ip = typeof j.ip === 'string' ? j.ip : null; - org = typeof j.org === 'string' ? j.org : null; - } catch { - ip = text; - } - if (!ip || !/^[0-9a-fA-F:.]+$/.test(ip)) throw new Error('unexpected egress payload'); - return { ip, org, asn: org ? org.split(/\s+/)[0] : null, source, error: null }; - } catch (error) { - return { ip: null, org: null, asn: null, source, error: String(error?.message ?? error) }; - } finally { - clearTimeout(timer); - } -} - -function isPrivateIp(ip) { - if (!ip) return true; - const lower = ip.toLowerCase(); - if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true; - const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); - if (!m) return true; // 非 IPv4 且非已知公网 v6 → 保守判私网/未知 - const a = Number(m[1]); - const b = Number(m[2]); - if (a === 10 || a === 127) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; - if (a === 169 && b === 254) return true; - if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT - return false; -} - -/** 出口 IP 判据:任一未知/私网 → false/未知;均公网且不同 → true(绝不基于接口 IP) */ -function judgeDifferentNetwork(localIp, peerIp) { - if (!localIp || !peerIp) return { value: null, basis: 'egress-unknown' }; - if (isPrivateIp(localIp) || isPrivateIp(peerIp)) return { value: false, basis: 'private-or-loopback' }; - if (localIp === peerIp) return { value: false, basis: 'same-egress-ip' }; - return { value: true, basis: 'distinct-public-egress' }; -} - // ---------- peer:单端三阶段(稳定地址,无共享文件) ---------- function b64(bytes) { diff --git a/tests/wan/mcp-cross.test.mjs b/tests/wan/mcp-cross.test.mjs new file mode 100644 index 0000000..5c74007 --- /dev/null +++ b/tests/wan/mcp-cross.test.mjs @@ -0,0 +1,112 @@ +// G6.6 跨网 e2e 门禁单元测试 +// +// 覆盖:需求检查(缺失即红)、证据判定(全绿/逐项红)、出口判据、以及 +// 真实调用门禁脚本「无环境 → 退出码 1 且打印所需环境」。不依赖真实两机。 + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from '@jest/globals'; +import { checkCrossEnv, judgeCrossEvidence, REQUIRED_ENV } from '../../scripts/mcp-cross-lib.mjs'; +import { judgeDifferentNetwork, isPrivateIp } from '../../scripts/wan-egress.mjs'; + +const gateScript = fileURLToPath(new URL('../../scripts/verify-mcp-cross.mjs', import.meta.url)); + +const FULL_ENV = { + MEBULAR_WAN_PEER: '/ip4/203.0.113.9/tcp/4001/p2p/relay/p2p-circuit/p2p/abc', + MEBULAR_WAN_PEER_ID: 'device-A', + MEBULAR_WAN_MCP_A: 'https://a.example:7331', + MEBULAR_WAN_MCP_A_TOKEN: 'tok-a', + MEBULAR_WAN_PEER_EGRESS: '198.51.100.7', +}; + +describe('mcp-cross.checkCrossEnv', () => { + it('空环境:ok=false,且列出全部必需 env', () => { + const r = checkCrossEnv({}); + expect(r.ok).toBe(false); + for (const [k] of REQUIRED_ENV) expect(r.missing).toContain(k); + expect(r.missing).toHaveLength(REQUIRED_ENV.length); + }); + + it('缺一项:ok=false 且只点名缺的那项', () => { + const r = checkCrossEnv({ ...FULL_ENV, MEBULAR_WAN_MCP_A_TOKEN: '' }); + expect(r.ok).toBe(false); + expect(r.missing).toEqual(['MEBULAR_WAN_MCP_A_TOKEN']); + }); + + it('必需齐全:ok=true,B 端与输出取默认值', () => { + const r = checkCrossEnv(FULL_ENV); + expect(r.ok).toBe(true); + expect(r.config.mcpB).toBe('http://127.0.0.1:7331'); + expect(r.config.out).toBe('mcp-cross-evidence.json'); + expect(r.config.peerId).toBe('device-A'); + }); +}); + +describe('mcp-cross.judgeCrossEvidence', () => { + const green = { + markerFound: true, + stateMatches: true, + identityShared: true, + differentPublicNetwork: true, + differentPublicNetworkBasis: 'distinct-public-egress', + pendingPeers: 0, + }; + + it('全绿 → passed=true 无失败项', () => { + const v = judgeCrossEvidence(green); + expect(v.passed).toBe(true); + expect(v.failures).toEqual([]); + }); + + it.each([ + ['markerFound', false, 'markerFound'], + ['stateMatches', false, 'stateMatches'], + ['identityShared', false, 'identityShared'], + ['differentPublicNetwork', null, 'differentPublicNetwork'], + ['pendingPeers', 3, 'pendingPeers'], + ])('单项不达标 %s → passed=false 且点名', (key, value, needle) => { + const v = judgeCrossEvidence({ ...green, [key]: value }); + expect(v.passed).toBe(false); + expect(v.failures.join('\n')).toContain(needle); + }); + + it('缺字段(undefined)视为不达标', () => { + const v = judgeCrossEvidence({}); + expect(v.passed).toBe(false); + expect(v.failures.length).toBe(5); + }); +}); + +describe('wan-egress.judgeDifferentNetwork', () => { + it('均公网且不同 → true;同 IP → false;任一私网/未知 → false/null', () => { + expect(judgeDifferentNetwork('198.51.100.7', '203.0.113.9')).toEqual({ value: true, basis: 'distinct-public-egress' }); + expect(judgeDifferentNetwork('198.51.100.7', '198.51.100.7')).toEqual({ value: false, basis: 'same-egress-ip' }); + expect(judgeDifferentNetwork('10.0.0.1', '203.0.113.9')).toEqual({ value: false, basis: 'private-or-loopback' }); + expect(judgeDifferentNetwork(null, '203.0.113.9')).toEqual({ value: null, basis: 'egress-unknown' }); + expect(judgeDifferentNetwork('::1', '203.0.113.9')).toEqual({ value: false, basis: 'private-or-loopback' }); + }); + + it('isPrivateIp:RFC1918/CGNAT/回环/链路本地为真,公网为假', () => { + for (const ip of ['10.1.2.3', '172.20.0.1', '192.168.1.1', '100.64.5.5', '169.254.1.1', '127.0.0.1', '::1', 'fd00::1']) { + expect(isPrivateIp(ip)).toBe(true); + } + expect(isPrivateIp('203.0.113.9')).toBe(false); + expect(isPrivateIp(null)).toBe(true); + }); +}); + +describe('verify-mcp-cross 红灯门禁(真实调用脚本,无环境)', () => { + it('清除 MEBULAR_WAN_* 后运行 → 退出码 1 且打印所需环境', () => { + const clean = { ...process.env }; + for (const key of Object.keys(clean)) { + if (key.startsWith('MEBULAR_WAN_') || key.startsWith('MEBULAR_MCP_CROSS')) delete clean[key]; + } + const res = spawnSync(process.execPath, [gateScript], { env: clean, encoding: 'utf-8' }); + expect(res.status).toBe(1); + const out = `${res.stdout}\n${res.stderr}`; + expect(out).toContain('缺少跨网环境'); + expect(out).toContain('MEBULAR_WAN_PEER'); + expect(out).toContain('MEBULAR_WAN_MCP_A'); + expect(out).toContain('退出码 1'); + }); +});