From e955b6a2f75d0f2b05e1cc94c9f70b0027ab3503 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:20:02 +0800 Subject: [PATCH] =?UTF-8?q?test(v16):=20=E7=BD=91=E7=BB=9C=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=9B=9E=E5=BD=92=E6=96=AD=E8=A8=80=208=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=88=E6=8A=8A=E4=BA=8B=E6=95=85=E6=A0=B9=E5=9B=A0=E5=8F=98?= =?UTF-8?q?=E6=88=90=E9=98=B2=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对本次真机事故(机器掉 APIPA 失联)逐条设防: - apply 必须先 New-NetIPAddress 再 Remove-NetIPAddress(顺序颠倒 → already exists → 无地址) - 建默认路由前必须先 Remove-NetRoute(否则 New-NetRoute 报实例已存在) - 断言脚本里**不得出现** netsh set address / set dnsservers(Win11 上会挂起) - 自检片段必须校验目标地址并在失败时执行回滚(自愈) - 回滚脚本同样受「先加后清 / 先清路由」约束,且必须恢复原地址/网关/DNS - DHCP 快照的回滚走 -Dhcp Enabled,不得写静态地址 - 掩码→前缀换算边界(含非法输入回退) 单测 116 -> 124 --- apps/agent/src/capabilities/network.ts | 8 +-- tests/unit/net-scripts.test.mjs | 91 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/unit/net-scripts.test.mjs diff --git a/apps/agent/src/capabilities/network.ts b/apps/agent/src/capabilities/network.ts index 3184c3b..d1b2b75 100644 --- a/apps/agent/src/capabilities/network.ts +++ b/apps/agent/src/capabilities/network.ts @@ -96,7 +96,7 @@ export function maskToPrefix(mask: string): number { } /** 结构化快照(回滚脚本据此用**原生 cmdlet** 重建配置)。 */ -interface IfaceSnapshot { +export interface IfaceSnapshot { alias: string; dhcp: boolean; ipv4: Array<{ ip: string; prefix: number }>; @@ -143,7 +143,7 @@ $o | ConvertTo-Json -Compress -Depth 4 } /** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */ -function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string { +export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string { const a = JSON.stringify(snap.alias); const L = JSON.stringify(logPath); const lines = [ @@ -384,7 +384,7 @@ export async function netApply(args: Args): Promise { * 而 `Set-NetIPAddress` / `Set-NetIPInterface` / `Set-DnsClientServerAddress` 同场景 <1s。 * netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。 */ -function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string { +export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string { const a = JSON.stringify(iface); if (mode === 'command') { const cmd = args['command'] as string | undefined; @@ -443,7 +443,7 @@ function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceS * ⚠️ 这段是**事故换来的**:没有自检时,变更失败会静默把机器留在「无地址」状态, * 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。 */ -function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string { +export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string { const a = JSON.stringify(iface); const rb = JSON.stringify(rollbackScriptPath); const cond = expectIp diff --git a/tests/unit/net-scripts.test.mjs b/tests/unit/net-scripts.test.mjs new file mode 100644 index 0000000..b1c861b --- /dev/null +++ b/tests/unit/net-scripts.test.mjs @@ -0,0 +1,91 @@ +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.100', prefix: 24 }], + gateway: ['192.168.0.1'], + dns: ['223.5.5.5'], +}; +const args = { mode: 'static', ip: '192.168.1.100', mask: '255.255.255.0', gateway: '192.168.0.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); + assert.match(s, /Set-NetIPInterface -InterfaceAlias "Ethernet" -Dhcp Enabled/); + assert.match(s, /Set-DnsClientServerAddress/); +}); + +test('自检:失败时自动执行回滚脚本(自愈)', () => { + const chk = buildSelfCheck('Ethernet', '192.168.1.100', 'C:/rb.ps1'); + assert.match(chk, /Get-NetIPAddress/); + assert.match(chk, /192\.168\.0\.88/, '应校验目标地址是否真的生效'); + assert.match(chk, /selfcheck FAILED -> auto rollback/); + 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\.0\.159/, '必须恢复快照里的原地址'); + assert.match(rb, /192\.168\.0\.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 快照不应写静态地址'); +});