test: QA 补充用例(代理重定向/NO_PROXY 组合、看门狗边界与恢复日志、rollback 双匹配路径、README 数字精度)

- A:httpGet 重定向链经代理逐跳绝对形态+共享预算;maxRedirects 上限边界;
  NO_PROXY 命中直连/端口不匹配不误短路/miss+死代理大声报错(agent-proxy 已有
  单跳经代理与直连重定向预算,组合形态空缺)
- B:看门狗宽限期精确边界(livedMs === STARTUP_GRACE_MS 按运行期不计数);
  无指纹匹配时恢复日志须带回候选指纹且 install-dir 不动
- C:pickBackupByHash 在 hash_full 在场时不许仅凭 12 位前缀命中;
  编排层老 LKG(无 hash_full)端到端前缀回滚 —— 后者当前为红,是路由给
  工程师的 P0 缺陷(rollback.ts:462 全量 64 位指纹与 12 位 targetHash 严格
  比较,详见缺陷单),修复后自绿
- D:README e2e 拆分口径(34 通用 + 16 Windows 专属按文件分别核对);
  命令速查必须含 nodeagent rollback [--dry-run]
- README 单测数 467 → 476(docs-numbers 守卫强制耦合,非手滑)
This commit is contained in:
jkooe committed 2026-10-26 01:22:55 +08:00
1 parent a7827ba986
commit dadd861fdd
2 files changed
+413 -1

No files matched your search

+412
View File
@@ -0,0 +1,412 @@
/**
* QA 独立验证补充用例(v2.1.0「全面完善」轮,严过关)。
*
* 定位:工程师自验(第一层)已覆盖主轴后,本文件补**组合/边界/精度**三类薄面,
* 全部「能挂掉错误实现、不 tautological」:
*
* A. 出站代理(apps/agent/src/net/proxy.ts):
* ① 重定向链**经代理**时每跳仍走代理(绝对形态)且整链共享一个超时预算
* —— 既有守卫只覆盖「直连重定向共享预算」与「单跳经代理」,组合形态空缺;
* ② maxRedirects 上限边界:超限返回最后一个重定向响应本身(不跟死、不抛错);
* ③ NO_PROXY 组合:命中 → 直连;端口不匹配 → 不得误短路;miss + 死代理 →
* 必须大声报错(静默直连正是本轮修复的缺陷本体)。
*
* B. 看门狗(scripts/lib/startup-guard-core.mjs + scripts/startup-guard.mjs):
* ④ 宽限期**精确边界** livedMs === STARTUP_GRACE_MS 按运行期处理(>= 的 off-by-one);
* ⑤ 无指纹匹配时 restoreFromLkg 必须带回各候选指纹(恢复日志的排查依据),
* 且日志 JSONL 如实承载 reason/candidates,install-dir 一个字节不动。
*
* C. rollback(apps/cli/src/cmd/rollback.ts)两条指纹匹配路径:
* ⑥ LKG 持 hash_full(64 位)时,仅前 12 位相同**不得**算命中(全量精度优先);
* ⑦ 老 LKG 只有 12 位 hash → 编排层端到端按前缀命中并完成回滚(向后兼容路径)。
*
* D. 文档数字(README ↔ 实测)精度补强:
* ⑧ e2e 拆分口径:「34 通用 + 16 Windows 专属」与两个文件分别实测一致
* (既有 docs-numbers 只校验总和,挪用例不换总数的漂移漏网);
* ⑨ README 命令速查必须包含 `nodeagent rollback [--dry-run]`
* (v2.1.0 新 CLI 命令的文档同步,与 na_audit_l2 工具表守卫同款)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import http from 'node:http';
import { httpGet, resolveProxy } from '../../apps/agent/dist/net/proxy.js';
import { STARTUP_GRACE_MS, decideAfterExit, initialState } from '../../scripts/lib/startup-guard-core.mjs';
import { appendGuardLog, restoreFromLkg } from '../../scripts/startup-guard.mjs';
import { pickBackupByHash, runRollback } from '../../apps/cli/dist/cmd/rollback.js';
const root = join(import.meta.dirname, '../..');
const readme = readFileSync(join(root, 'README.md'), 'utf8');
// ===========================================================================
// A. 出站代理:重定向预算与 NO_PROXY 组合
// ===========================================================================
/** 起一个最小 HTTP 源站,返回 {srv, url}(url(p) 拼路径)。 */
async function startOrigin(handler) {
const srv = http.createServer(handler);
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
return { srv, url: (p) => `http://127.0.0.1:${port}${p}` };
}
/** 起一个「真转发」的 HTTP 代理;记录它看到的请求行(判定绝对形态/逐跳经过)。 */
async function startHttpProxy() {
const seen = [];
const srv = http.createServer((req, res) => {
seen.push(req.url ?? '');
let u;
try {
u = new URL(req.url ?? '');
} catch {
res.writeHead(400);
res.end('not absolute-form');
return;
}
const up = http.request(
{
host: u.hostname,
port: u.port,
path: `${u.pathname}${u.search}`,
method: req.method,
headers: { ...req.headers, host: u.host },
},
(r) => {
res.writeHead(r.statusCode, r.headers);
r.pipe(res);
},
);
up.on('error', () => {
res.writeHead(502);
res.end('upstream error');
});
req.pipe(up);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
return { srv, seen, url: `http://127.0.0.1:${srv.address().port}` };
}
test('A① 重定向链经代理:每一跳都走代理(绝对形态)且整链共享一个超时预算', async () => {
// /a → 302 /b → 302 /hang(挂起)。组合守卫盯两个回归:
// - 跟随后「忘了还走代理」:第二跳退化成直连(代理只见一条请求行);
// - 每跳各自计时:总耗 = 3×timeout,会穿过上层 RPC 超时。
const origin = await startOrigin((req, res) => {
if (req.url === '/a' || req.url === '/b') {
res.writeHead(302, { location: req.url === '/a' ? '/b' : '/hang' });
res.end();
return;
}
// /hang:什么都不回
});
const proxy = await startHttpProxy();
const timeoutMs = 600;
const t0 = Date.now();
try {
await assert.rejects(httpGet(origin.url('/a'), { proxy: new URL(proxy.url), timeoutMs }), /abort|中止|超时/i);
const dt = Date.now() - t0;
assert.ok(
dt < timeoutMs * 2,
`经代理的重定向链也应共享预算(总耗 ≈ ${timeoutMs}ms),实际 ${dt}ms`,
);
// 两个 302 跳都必须以绝对形态经过代理(证明跟随重定向没有偷偷直连)
assert.ok(
proxy.seen.some((u) => u.endsWith('/a')) && proxy.seen.some((u) => u.endsWith('/b')),
`两跳都应经代理(绝对形态),实际看到: ${JSON.stringify(proxy.seen)}`,
);
assert.ok(
proxy.seen.every((u) => u.startsWith('http://')),
`代理收到的请求行必须是绝对形态,实际: ${JSON.stringify(proxy.seen)}`,
);
} finally {
origin.srv.closeAllConnections();
origin.srv.close();
proxy.srv.closeAllConnections();
proxy.srv.close();
}
});
test('A② httpGet:超过 maxRedirects 上限 → 返回最后一个重定向响应本身(不跟死、不抛错)', async () => {
// 无限 302 链:/r0 → /r1 → /r2 …。跟到上限必须停下并把最后一个 3xx 交还给调用方
// (selfupdate 会按 HTTP 状态报错),而不是死循环打满 CPU 或把 3xx 当成功。
const origin = await startOrigin((req, res) => {
const n = Number((req.url ?? '/r0').slice(2));
res.writeHead(302, { location: `/r${n + 1}` });
res.end();
});
try {
const capped = await httpGet(origin.url('/r0'), { timeoutMs: 5000, maxRedirects: 2 });
assert.equal(capped.status, 302, '超限后应返回最后一个重定向响应,而不是继续跟');
assert.equal(capped.url, origin.url('/r2'), 'url 应报出停留的那一跳');
// 对照组:跳数未超限时照常跟随到 200(防「修过头」把正常重定向也掐了)
const origin2 = await startOrigin((req, res) => {
if (req.url === '/x') {
res.writeHead(302, { location: '/y' });
res.end();
return;
}
res.writeHead(200);
res.end('OK');
});
try {
const ok = await httpGet(origin2.url('/x'), { timeoutMs: 5000, maxRedirects: 2 });
assert.equal(ok.status, 200);
assert.equal(ok.body.toString(), 'OK');
} finally {
origin2.srv.close();
}
} finally {
origin.srv.close();
}
});
test('A③ NO_PROXY 组合:命中直连 / 端口不匹配不误短路 / miss+死代理大声报错', async () => {
// 目标用 https:ENV_ORDER['https:'] 才含 HTTPS_PROXY(http 目标按协议只读
// HTTP_PROXY 系,这是 pickEnvProxy 的既定优先级,别在用例里搞错协议)。
const dl = 'https://dl.example.com/agent.mjs';
const deadProxy = 'http://127.0.0.1:1';
// ⑴ 精确命中 → 直连(proxy=null)且 note 说明原因
const hit = await resolveProxy({ targetUrl: dl, env: { HTTPS_PROXY: deadProxy, NO_PROXY: 'dl.example.com' } });
assert.equal(hit.proxy, null, 'NO_PROXY 命中必须直连,不被 env 代理抢走');
assert.match(hit.notes.join(';'), /NO_PROXY/, '命中要有 note(可诊断)');
// ⑵ 端口不匹配 → 不得误短路(NO_PROXY 写了 host:9433,目标走 443,代理仍生效)
const portMiss = await resolveProxy({
targetUrl: dl,
env: { HTTPS_PROXY: deadProxy, NO_PROXY: 'dl.example.com:9443' },
});
assert.equal(portMiss.proxy?.href, `${deadProxy}/`, 'NO_PROXY 端口不匹配时不得误判为直连');
// ⑶ miss + 代理是死端口 → httpGet 必须报错(静默直连正是本轮修复的缺陷本体)
const miss = await resolveProxy({ targetUrl: dl, env: { HTTPS_PROXY: deadProxy } });
assert.equal(miss.proxy?.href, `${deadProxy}/`, '未配 NO_PROXY 时应选到 env 代理');
const origin = await startOrigin((_req, res) => {
res.writeHead(200);
res.end('SHOULD-NOT-BE-REACHED');
});
try {
await assert.rejects(
httpGet(origin.url('/agent.mjs'), { proxy: miss.proxy, timeoutMs: 3000 }),
/ECONNREFUSED|代理/,
'代理不可达时必须大声失败;若实现「代理失败就自动直连」这里会拿到 200',
);
// ⑷ 组合闭环:决议为直连(proxy=null)时同一源站必须拉得到 —— NO_PROXY 命中后
// 的「直连」是真的能连,而不是把请求扔进黑洞。
const direct = await httpGet(origin.url('/agent.mjs'), { proxy: hit.proxy, timeoutMs: 3000 });
assert.equal(direct.status, 200);
} finally {
origin.srv.close();
}
});
// ===========================================================================
// B. 看门狗:宽限期边界与恢复日志内容
// ===========================================================================
test('B④ decideAfterExit:宽限期精确边界 livedMs === STARTUP_GRACE_MS 按运行期处理(不计数)', () => {
// >= 与 > 的 off-by-one:恰好活满宽限期的死亡是运行期事件(计划内重启杀的
// 正是长命进程),一旦把它计成启动失败,N 次计划内重启后就会误触发回滚,
// 把好版本回滚掉 —— 比坏版本停摆更糟。
const at = decideAfterExit({
code: null, signal: 'SIGKILL', livedMs: STARTUP_GRACE_MS, stopping: false, serviceUp: false,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(at.counted, false, '恰好活满宽限期不得计入启动失败');
assert.equal(at.reason, 'runtime-exit');
assert.equal(at.persist, false, '状态没变不写盘');
const below = decideAfterExit({
code: 1, signal: null, livedMs: STARTUP_GRACE_MS - 1, stopping: false, serviceUp: false,
state: { failures: 2, updated_at: '' }, nowIso: 'T',
});
assert.equal(below.counted, true, '差 1ms 没活过宽限期 → 启动失败,计数 +1');
assert.equal(below.state.failures, 3);
});
test('B⑤ restoreFromLkg:无指纹匹配 → 带回候选指纹(恢复日志排查依据)且 install-dir 不动', () => {
const box = mkdtempSync(join(tmpdir(), 'na-guard-nomatch-'));
try {
const { dataDir, lkgDir, installDir } = { dataDir: box, lkgDir: box, installDir: box };
// LKG 指向一份内容,磁盘上两个备份都不是它
const want = Buffer.from('LKG-GOOD-CONTENT');
const wantFull = createHash('sha256').update(want).digest('hex');
writeFileSync(join(dataDir, 'agent-lkg.json'), JSON.stringify({
hash: wantFull.slice(0, 12), hash_full: wantFull, bytes: want.length,
script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux',
}));
writeFileSync(join(lkgDir, 'agent.mjs.bak'), Buffer.from('UNRELATED-1'));
writeFileSync(join(lkgDir, 'agent.mjs.bak-1729300000000'), Buffer.from('UNRELATED-2'));
const entryBefore = Buffer.from('BAD-ENTRY');
writeFileSync(join(installDir, 'agent.mjs'), entryBefore);
const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' });
assert.equal(r.restored, false, '无匹配不得恢复');
assert.equal(r.reason, 'no-match');
// 恢复日志的排查依据:每个候选都要带「路径 + 12 位指纹」(main 会原样写进 JSONL)
assert.equal(r.candidates.length, 2, '候选指纹清单必须完整带回');
for (const line of r.candidates) {
assert.match(line, /^\S+ [0-9a-f]{12}$/, `候选清单行应形如「路径 12位指纹」: ${line}`);
}
assert.ok(
readFileSync(join(installDir, 'agent.mjs')).equals(entryBefore),
'无匹配时 install-dir 一个字节都不许动',
);
// ⑵ 日志内容:按 main() 的口径落一条 restore 日志,JSONL 必须如实承载
// reason 与 candidates —— 没有它们,运维拿到「未恢复」三个字无从排查。
appendGuardLog(dataDir, {
action: 'restore', failures: 3, threshold: 3,
restored: r.restored, backup: r.backup, entry: r.entry, reason: r.reason, candidates: r.candidates,
});
const logPath = join(dataDir, 'logs', 'startup-guard.log');
assert.ok(existsSync(logPath), '恢复尝试必须留日志');
const rec = JSON.parse(readFileSync(logPath, 'utf8').trim().split('\n').pop());
assert.equal(rec.action, 'restore');
assert.equal(rec.reason, 'no-match');
assert.equal(rec.restored, false);
assert.equal(rec.failures, 3);
assert.equal(rec.threshold, 3);
assert.equal(rec.candidates.length, 2);
assert.ok(rec.ts, '日志必须带时间戳');
} finally {
rmSync(box, { recursive: true, force: true });
}
});
// ===========================================================================
// C. rollback:hash_full 与 12 位前缀两条匹配路径
// ===========================================================================
test('C⑥ pickBackupByHash:LKG 持 hash_full(64 位)时,仅前 12 位相同不得算命中', () => {
// 全量指纹在手就不允许用前缀糊弄:否则「另一个只是前 12 位碰巧相同的备份」
// 会被当成 LKG 写进入口 —— 回滚恢复成一个从来没上线过的版本。
const wantFull = 'a'.repeat(64);
const prefixOnly = 'a'.repeat(12) + 'b'.repeat(52); // 前 12 位相同、全量不同
assert.equal(
pickBackupByHash([{ path: '/x/agent.mjs.bak', hash: prefixOnly, mtime: 1 }], wantFull),
null,
'hash_full 在场时必须全量相等,前缀相同不算命中',
);
// 对照组:全量相等 → 命中;老记录 12 位 → 前缀命中(两条路各自有效)
assert.equal(
pickBackupByHash([{ path: '/x/agent.mjs.bak', hash: wantFull, mtime: 1 }], wantFull)?.path,
'/x/agent.mjs.bak',
);
assert.equal(
pickBackupByHash([{ path: '/x/agent.mjs.bak', hash: wantFull, mtime: 1 }], wantFull.slice(0, 12))?.path,
'/x/agent.mjs.bak',
'老 LKG 只有 12 位时按前缀命中(向后兼容)',
);
});
test('C⑦ 编排层:老 LKG 只有 12 位 hash(无 hash_full)→ 按前缀命中并完成回滚', async () => {
// 端到端走「向后兼容」那条路:planRollback 回落 12 位 → hashRemoteFile 流式指纹
// → pickBackupByHash 前缀命中 → 覆盖写入 → 写后校验。单测层覆盖过纯函数,
// 这里钉死编排层接线(lkg.hash_full ?? lkg.hash)在老记录上确实走得通。
const LKG_BYTES = Buffer.from('LKG-AGENT-CONTENT-v2.0.3');
const CUR_BYTES = Buffer.from('CURRENT-AGENT-CONTENT-v2.1.0-broken');
const lkgFull = createHash('sha256').update(LKG_BYTES).digest('hex');
const lkg12 = lkgFull.slice(0, 12);
const files = new Map([
['__info__', {
agent_script: '/opt/nodeagent/agent.mjs',
build: { hash: createHash('sha256').update(CUR_BYTES).digest('hex').slice(0, 12) },
lkg: {
lkg: {
hash: lkg12, // 老记录:没有 hash_full
bytes: LKG_BYTES.length,
script_path: '/opt/nodeagent/agent.mjs',
recorded_at: 1_700_000_000_000,
node_version: 'v22.20.0',
platform: 'linux',
},
current_hash: 'x',
matches: false,
byte_delta: 0,
},
}],
['/opt/nodeagent/agent.mjs', CUR_BYTES],
['/opt/nodeagent/agent.mjs.bak', LKG_BYTES],
]);
const calls = [];
const fake = {
async invoke(cap, args) {
calls.push({ cap, args });
switch (cap) {
case 'system.info':
return { status: 'ok', data: files.get('__info__') };
case 'fs.list':
return {
status: 'ok',
data: {
entries: [...files.entries()]
.filter(([p]) => !p.startsWith('__'))
.map(([p, buf]) => ({ name: p.split(/[\\/]/).pop(), path: p, type: 'file', size: buf.length, mtime: 100 })),
},
};
case 'fs.read': {
const buf = files.get(args.path);
const off = args.offset ?? 0;
const chunk = buf.subarray(off, off + (args.max_bytes ?? 512 * 1024));
return {
status: 'ok',
data: { data: chunk.toString('base64'), bytes: chunk.length, eof: off + chunk.length >= buf.length },
};
}
case 'fs.write': {
const incoming = Buffer.from(args.data ?? '', 'base64');
const cur = args.append ? (files.get(args.path) ?? Buffer.alloc(0)) : Buffer.alloc(0);
files.set(args.path, Buffer.concat([cur, incoming]));
return { status: 'ok', data: { bytes: incoming.length } };
}
default:
return { status: 'failed', error: { name: 'E_CAPABILITY_NOT_FOUND', message: cap } };
}
},
};
const r = await runRollback(fake, { json: true });
assert.equal(r.code, 0, '老 LKG 前缀路径应能完成回滚');
assert.equal(r.plan.action, 'match');
assert.equal(r.plan.targetHash, lkg12, 'targetHash 应回落 12 位(hash_full ?? hash)');
assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak');
assert.equal(r.needsRestart, true);
assert.deepEqual(files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES, '入口内容应被替换为 LKG 内容');
// 只走了读 + 写入口,没有多余调用(如 restart 由 cmdRollback 负责,runRollback 不碰)
assert.ok(calls.every((c) => ['system.info', 'fs.list', 'fs.read', 'fs.write'].includes(c.cap)));
});
// ===========================================================================
// D. 文档数字:e2e 拆分口径与新命令上榜
// ===========================================================================
test('D⑧ README e2e 拆分口径:「N 通用 + M Windows 专属」与两个文件分别实测一致', () => {
// 既有 docs-numbers 只校验总和(50):把用例从 run.mjs 挪进 windows.mjs、
// 总数不变的漂移会漏网。这里按文件分别核对(括号段缺省时退化为只信总和,
// 与 docs-numbers 的可选后缀口径一致,不新增脆弱性)。
const m = /\*\*(\d+) 项端到端用例\*\*((\d+) 通用 \+ (\d+) Windows 专属)/.exec(readme);
if (!m) return; // 文档未写拆分:总和守卫已覆盖,不强制
const runSrc = readFileSync(join(root, 'tests/e2e/run.mjs'), 'utf8');
const winSrc = readFileSync(join(root, 'tests/e2e/windows.mjs'), 'utf8');
const runCount = [...runSrc.matchAll(/await test\(/g)].length;
const winCount = [...winSrc.matchAll(/await test\(/g)].length;
assert.equal(Number(m[2]), runCount, `README「${m[2]} 通用」与 run.mjs 实测不符(实测 ${runCount})`);
assert.equal(Number(m[3]), winCount, `README「${m[3]} Windows 专属」与 windows.mjs 实测不符(实测 ${winCount})`);
assert.equal(Number(m[1]), runCount + winCount, '拆分之和应等于总数');
});
test('D⑨ README 命令速查必须包含 nodeagent rollback [--dry-run](新命令文档同步)', () => {
// 与 docs-numbers 的 na_audit_l2 守卫同款思路:v2.1.0 新增的 CLI 命令必须上榜。
// 改了命令名/签名而不同步 README → 红。(help.ts ⇄ parseArgs 的 L-01 总闸管
// CLI 侧一致,本守卫管文档侧一致。)
assert.match(
readme,
/nodeagent rollback \[--dry-run\]/,
'README 命令速查缺少 nodeagent rollback [--dry-run] —— 新 CLI 命令必须同步文档',
);
});