Files
nodeagent/tests/unit/net-scripts.test.mjs
T
jkooe ae02252a1d fix(agent): 修复 18 条实机复现缺陷(2×P0 / 5×P1 / 10×P2 / 1×P3)
一轮「静态审查 + 真机实测」的完整普查。基线 v2.0.2 / ad90634 上 unit 291 + e2e 30
全绿 —— 也就是说 18 条**全部落在既有覆盖之外**。报告见 bug-report-2026-10-13.html。

P0 两条:
- W-01:window.focus 把 JSON.stringify(title) 插进 PowerShell **双引号**串(PS 里 `\` 不是
  转义符、双引号串还会展开 $(...))→ 命令注入。真机实测最险的是它**静默**:接口返回的是
  「正常业务错误」E_EXECUTION_FAILED: 未找到匹配的窗口,而 payload 已经把文件创建出来了。
  新增 apps/agent/src/util/quote.ts 作 psLit/shLit 单一真源,14 处注入点收敛(window.focus /
  uiaFind / ocrFind / system.ts 计划任务参数 / ps-helper.ts 的 -File 裸拼 …)。
- A-01:ACL 省略 clients 时 aclPolicy.clients.find 抛错,而这段落在 if (hasAcl) 块**之外**
  无条件执行 → 配置少写一个字段,每条 invoke 都崩掉被控端。
  ⚠️ 静态审查当时判它是「fail-open 放行」,实测是崩溃(更严重)。

崩溃链共同根因:ws.on('message') 用 void handleMessage() 且无 .catch,全仓又没有任何
process.on('unhandledRejection') —— Node ≥22 遇未处理 rejection 直接终止进程。两条畸形报文
(hello {protocol:123} / auth {hmac:1})就能在**认证之前**打死被控端(verifyNonce 有 try/catch,
psk 默认路径的 verifyHmac 没有)。index.ts 新增进程级兜底:日志每次打、审计按 10s 限流
(否则畸形报文可高频刷爆 audit.log),且明确不退出进程(可用性优先,取舍写在注释里)。

其余 15 条:
- clients: [] → fail-open 全放行(判据改为「对象是否存在」而非「数组长度」)
- macOS screen.find 的 text 拼 bash → $(...) 实测执行(走 shLit)
- 控制台审计筛选项与协议 enum 漂移,除 invoke 外全 E_PARAM_INVALID
  → 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生 + 编译期双向守卫
- monitor.start 的 target 注入、monitor id 路径穿越(读/删)→ psLit + guardPath
- keys{} + client_id:"constructor" 崩 → hasOwnProperty(原型链取值)
- 事件订阅跨控制端可见/可删、event.watch 绕开 fs_roots → 有状态资源落 owner + 统一白名单
- screen.capture 的 region 无上界 → assertRegionSane() 且**提前到枚举显示器之前**(fail fast)
- ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当 /0)
- CLI --timeout 静默失效(strict:false 下未声明选项被当 boolean,值掉进 positionals)
- 拒绝类事件不写审计 → 补 request.rejected / agent.error

回归:unit 321/321(+21)、e2e 33/33(+3 组硬化用例)、rust 11/11。
e2e 新增用例的姿势:每场景**独占端口 + 独立临时 NODEAGENT_HOME**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
2026-10-13 17:16:36 +08:00

102 lines
5.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildApplyCommand,
buildRollbackScript,
buildSelfCheck,
maskToPrefix,
} from '../../apps/agent/dist/capabilities/network.js';
const snap = {
alias: 'Ethernet',
dhcp: false,
ipv4: [{ ip: '192.168.1.159', prefix: 24 }],
gateway: ['192.168.1.1'],
dns: ['223.5.5.5'],
};
const args = { mode: 'static', ip: '192.168.1.88', mask: '255.255.255.0', gateway: '192.168.1.1' };
test('掩码换算:常见掩码与前缀长度互转', () => {
assert.equal(maskToPrefix('255.255.255.0'), 24);
assert.equal(maskToPrefix('255.255.0.0'), 16);
assert.equal(maskToPrefix('255.255.255.192'), 26);
assert.equal(maskToPrefix('255.255.255.255'), 32);
assert.equal(maskToPrefix('乱码'), 24, '非法输入回退 24');
});
/**
* 真机事故回归:曾经「先删旧地址 → 再加新地址」,Windows 残留的默认路由让
* New-NetIPAddress 报 already exists → 新地址没加上 → 机器掉到 APIPA 彻底失联。
* 下面几条断言就是那道防线。
*/
test('apply(static):必须先加新地址、再清旧地址(顺序不可颠倒)', () => {
const script = buildApplyCommand('static', 'Ethernet', args, snap);
const iAdd = script.indexOf('New-NetIPAddress');
const iRemove = script.indexOf('Remove-NetIPAddress');
assert.ok(iAdd > 0 && iRemove > 0, '既要加也要清');
assert.ok(iAdd < iRemove, '必须先 New-NetIPAddress 后 Remove-NetIPAddress(事故根因)');
});
test('apply(static):建路由之前必须先清掉旧默认路由(否则 already exists)', () => {
const script = buildApplyCommand('static', 'Ethernet', args, snap);
const iRmRoute = script.indexOf('Remove-NetRoute');
const iAddRoute = script.indexOf('New-NetRoute');
assert.ok(iRmRoute > 0, '必须显式清理默认路由');
assert.ok(iAddRoute > 0, '必须重建默认路由');
assert.ok(iRmRoute < iAddRoute, 'Remove-NetRoute 必须在 New-NetRoute 之前');
});
test('apply:不得使用会在 Win11 挂起的 netsh set address', () => {
const staticScript = buildApplyCommand('static', 'Ethernet', args, snap);
const dhcpScript = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
for (const s of [staticScript, dhcpScript]) {
assert.ok(!/netsh interface ipv4 set address/.test(s), 'netsh set address 在 Win11 会挂起(真机实测)');
assert.ok(!/netsh interface ipv4 set dnsservers/.test(s), 'netsh set dnsservers 同类问题');
}
assert.match(staticScript, /Set-NetIPInterface|New-NetIPAddress/, '应使用原生 cmdlet');
});
test('apply(dhcp):切 DHCP 用原生 cmdlet', () => {
const s = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
// ⚠️ 断言方向在 v2.0.3 **翻转过**:原来写的是 `-InterfaceAlias "Ethernet"`(双引号)。
// 双引号形态正是 W-01 的根因 —— PS 双引号串会做变量插值与 `$(...)` 展开,
// 而 `JSON.stringify` 产出的 `\"` 在 PS 里根本不是转义(PS 用反引号)。
// 现在接口名统一走 `psLit()`(单引号字面量,什么都不展开)。
// 「CI 断言会过期、而且方向可能反」—— 这条就是活的例子。
assert.match(s, /Set-NetIPInterface -InterfaceAlias 'Ethernet' -Dhcp Enabled/);
assert.match(s, /Set-DnsClientServerAddress/);
assert.ok(
!/Set-NetIPInterface[^\n]*"/.test(s),
'接口名不得再出现在双引号串里(PS 双引号会插值,`${`…`}` 形同注入入口)',
);
});
test('自检:失败时自动执行回滚脚本(自愈)', () => {
const chk = buildSelfCheck('Ethernet', '192.168.1.88', 'C:/rb.ps1');
assert.match(chk, /Get-NetIPAddress/);
assert.match(chk, /192\.168\.1\.88/, '应校验目标地址是否真的生效');
assert.match(chk, /selfcheck FAILED -> auto rollback/);
// 同上:路径改为 PS 单引号字面量(原为 `& "C:/rb.ps1"`)
assert.match(chk, /& 'C:\/rb\.ps1'/, '失败必须回滚,而不是把机器留在无地址状态');
});
test('rollback:同样先加后清 + 先清路由(否则回滚自己也会失败)', () => {
const rb = buildRollbackScript(snap, 'C:/backup.txt', 'C:/rb.log');
const iAdd = rb.indexOf('New-NetIPAddress');
const iRemove = rb.indexOf('Remove-NetIPAddress');
const iRmRoute = rb.indexOf('Remove-NetRoute');
const iAddRoute = rb.indexOf('New-NetRoute');
assert.ok(iAdd < iRemove, '回滚也必须先加后清');
assert.ok(iRmRoute < iAddRoute, '回滚也必须先清路由');
assert.match(rb, /192\.168\.1\.159/, '必须恢复快照里的原地址');
assert.match(rb, /192\.168\.1\.1/, '必须恢复原网关');
assert.match(rb, /223\.5\.5\.5/, '必须恢复原 DNS');
assert.ok(!/netsh -f/.test(rb), '不得用 netsh -f 回滚(备份编码 + set address 双重问题)');
});
test('rollback:DHCP 快照走「切回 DHCP」而非设静态', () => {
const rb = buildRollbackScript({ ...snap, dhcp: true, ipv4: [] }, 'C:/backup.txt', 'C:/rb.log');
assert.match(rb, /-Dhcp Enabled/);
assert.ok(!/New-NetIPAddress/.test(rb), 'DHCP 快照不应写静态地址');
});