字段与 manifest system.audit.l2 的 returns_schema(四 action 属性并集) 逐字对齐:status 全字段断言(platform/supported/auditpol/ legacy_policy_disabled/cmdline_logging/sysmon_installed/file/file_exists); collect 断 since/limit 回显、written<=collected、信任边界 note 必返; verify 断 ok/checked。 collect 对非提权环境(读 Security 日志需管理员)skip 容错 —— 与 winget 缺失同一哲学,不把「环境没条件」伪装成「功能坏了」。 Mac 上不跑 windows.mjs:node --check + 静态自证(参数名/能力名/字段 与 manifest returns_schema 逐字对齐),不假跑。e2e 总数 33 → 50。
291 lines
12 KiB
JavaScript
291 lines
12 KiB
JavaScript
/**
|
||
* nodeagent Windows 专属能力 E2E
|
||
*
|
||
* 仅在 Windows 上运行:验证 system.service.list / app.list / app.install(winget)等
|
||
* 只在 Windows 被控端可用的能力。
|
||
*
|
||
* 运行:node tests/e2e/windows.mjs
|
||
*/
|
||
import { spawn } from 'node:child_process';
|
||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import assert from 'node:assert/strict';
|
||
import { NodeAgentClient } from '../../packages/client/dist/index.js';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const root = join(__dirname, '../..');
|
||
|
||
const PORT = 18768;
|
||
const KEY = 'windows-e2e-key-0123456789abcdef0123456789abcdef';
|
||
const URL = `ws://127.0.0.1:${PORT}`;
|
||
|
||
let passed = 0;
|
||
let failed = 0;
|
||
let skipped = 0;
|
||
|
||
async function test(name, fn) {
|
||
try {
|
||
const r = await fn();
|
||
if (r === 'skip') {
|
||
console.log(` - ${name} [skip]`);
|
||
skipped += 1;
|
||
} else {
|
||
console.log(` \u2713 ${name}`);
|
||
passed += 1;
|
||
}
|
||
} catch (err) {
|
||
console.log(` \u2717 ${name}`);
|
||
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
||
failed += 1;
|
||
}
|
||
}
|
||
|
||
function startAgent() {
|
||
// NODEAGENT_HOME 直接作为数据目录(等价于 ~/.nodeagent)
|
||
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-win-e2e-'));
|
||
writeFileSync(
|
||
join(dataDir, 'agent.json'),
|
||
JSON.stringify({ node_id: 'win_e2e', host: '127.0.0.1', port: PORT, tls: false, key: KEY, log_level: 'warn' }),
|
||
);
|
||
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
|
||
// NODEAGENT_HOME 为准(跨平台一致);HOME/USERPROFILE 兜底
|
||
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
child.stdout.on('data', (d) => process.stdout.write(`[agent] ${d}`));
|
||
child.stderr.on('data', (d) => process.stderr.write(`[agent:err] ${d}`));
|
||
child.on('error', (err) => console.error(`\n⚠️ [agent spawn 失败] ${err.message}\n`));
|
||
child.on('exit', (code, signal) => {
|
||
console.log(`\n⚠️ [agent 进程退出] code=${code} signal=${signal}\n`);
|
||
});
|
||
return child;
|
||
}
|
||
|
||
async function waitReady(timeoutMs = 30_000, child = null) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (child && child.exitCode !== null) {
|
||
throw new Error(`被控端进程已退出 (code=${child.exitCode}),请查看上方 agent 输出`);
|
||
}
|
||
try {
|
||
const c = new NodeAgentClient({ url: URL, key: KEY, clientId: 'probe' });
|
||
await c.connect();
|
||
c.close();
|
||
return;
|
||
} catch {
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
}
|
||
}
|
||
throw new Error('被控端未在超时内就绪');
|
||
}
|
||
|
||
async function connect() {
|
||
const c = new NodeAgentClient({ url: URL, key: KEY, clientId: 'win_e2e_mac' });
|
||
await c.connect();
|
||
return c;
|
||
}
|
||
|
||
async function main() {
|
||
if (process.platform !== 'win32') {
|
||
console.log('\n非 Windows 平台,跳过 Windows 专属测试。');
|
||
process.exit(0);
|
||
}
|
||
|
||
console.log('\nnodeagent Windows 专属能力 E2E\n');
|
||
const agent = startAgent();
|
||
|
||
try {
|
||
await waitReady(30_000, agent);
|
||
console.log(` 被控端已就绪 ${URL}\n`);
|
||
|
||
await test('system.info 报告 Windows 平台', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.info');
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.match(String(r.data.os), /Windows/i, `os 字段异常: ${r.data.os}`);
|
||
assert.ok(r.data.cpu_cores > 0);
|
||
assert.ok(r.data.memory_total > 0);
|
||
});
|
||
|
||
await test('system.status 返回 Windows 磁盘(盘符)', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.status');
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.ok(Array.isArray(r.data.disks) && r.data.disks.length > 0, 'disks 为空');
|
||
assert.match(String(r.data.disks[0].drive), /^[A-Z]:/i, `盘符格式异常: ${r.data.disks[0].drive}`);
|
||
});
|
||
|
||
await test('system.service.list 返回 Windows 服务', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.service.list', { limit: 50 });
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.ok(r.data.services.length > 0, '服务列表为空');
|
||
const s = r.data.services[0];
|
||
assert.ok(s.name && s.state, `服务字段异常: ${JSON.stringify(s)}`);
|
||
});
|
||
|
||
await test('system.service.list 支持按名称筛选', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.service.list', { filter: { name_pattern: 'win' } });
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.ok(r.data.services.every((s) => /win/i.test(s.name) || /win/i.test(s.display_name)));
|
||
});
|
||
|
||
await test('system.process.list 返回进程(含 CPU 百分比)', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.process.list', { limit: 10 });
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.ok(r.data.processes.length > 0, '进程列表为空');
|
||
assert.equal(typeof r.data.processes[0].cpu_pct, 'number');
|
||
});
|
||
|
||
await test('system.shell.exec 执行 PowerShell 命令', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.shell.exec', { command: 'Get-Date -Format "yyyy-MM-dd"', shell: 'powershell' });
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.equal(r.data.exit_code, 0);
|
||
assert.match(r.data.stdout, /\d{4}-\d{2}-\d{2}/);
|
||
});
|
||
|
||
await test('system.shell.exec 支持 cmd 外壳', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.shell.exec', { command: 'echo cmd-ok', shell: 'cmd' });
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.equal(r.data.exit_code, 0);
|
||
assert.match(r.data.stdout, /cmd-ok/);
|
||
});
|
||
|
||
await test('app.list 返回已安装软件(注册表)', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('app.list');
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.ok(Array.isArray(r.data.apps), 'apps 应为数组');
|
||
assert.ok(r.data.apps.length > 0, '未读到任何已安装软件');
|
||
});
|
||
|
||
await test('app.install 用 winget 安装软件', async () => {
|
||
const c = await connect();
|
||
const probe = await c.invoke('system.shell.exec', { command: 'winget --version' });
|
||
if (probe.status !== 'ok' || probe.data.exit_code !== 0) {
|
||
c.close();
|
||
return 'skip'; // CI 环境无 winget
|
||
}
|
||
const r = await c.invoke('app.install', { package: 'jqlang.jq', timeout_ms: 600_000 }, 660_000);
|
||
c.close();
|
||
assert.equal(r.status, 'ok', `app.install 协议层失败: ${JSON.stringify(r.error)}`);
|
||
assert.equal(r.data.installed, true, `winget 安装失败:\n${r.data.detail}`);
|
||
});
|
||
|
||
await test('安装后可在 app.list 中检索到', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('app.list', { filter: { name_pattern: 'jq' } });
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
// jq 由 winget 通过 MSI 安装,注册表中应可见;找不到则视为跳过(安装方式差异)
|
||
if (!r.data.apps.some((a) => /jq/i.test(a.name))) return 'skip';
|
||
assert.ok(true);
|
||
});
|
||
|
||
// ---------- v2 图形接管(Windows 真机) ----------
|
||
await test('v2 screen.info 返回 Windows 显示器', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('screen.info');
|
||
c.close();
|
||
assert.equal(r.status, 'ok');
|
||
assert.ok(r.data.displays.length > 0, '应有显示器');
|
||
assert.ok(r.data.displays[0].width > 0, '宽度应大于 0');
|
||
});
|
||
|
||
await test('v2 screen.capture 真实截屏返回图片', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('screen.capture', { scale: 0.5, format: 'jpeg' }, 60_000);
|
||
c.close();
|
||
assert.equal(r.status, 'ok', `截屏失败: ${JSON.stringify(r.error)}`);
|
||
assert.ok(typeof r.data.image === 'string' && r.data.image.length > 1000, '图片数据过小');
|
||
assert.ok(r.data.width > 0 && r.data.height > 0, '尺寸无效');
|
||
return `${r.data.width}x${r.data.height} ${(r.data.bytes / 1024).toFixed(1)}KB`;
|
||
});
|
||
|
||
await test('v2 输入控制默认禁用(E_CAPABILITY_DISABLED)', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('input.mouse.move', { x: 1, y: 1 });
|
||
c.close();
|
||
assert.equal(r.status, 'failed');
|
||
assert.equal(r.error.name, 'E_CAPABILITY_DISABLED');
|
||
});
|
||
|
||
// ---------- v2.1.0 L2 系统级审计(T05,Windows 真机)----------
|
||
// 字段与 packages/protocol/src/capabilities/manifest/system.ts 的
|
||
// system.audit.l2 returns_schema(四种 action 的属性并集)逐字对齐:
|
||
// status → platform/supported/auditpol/legacy_policy_disabled/
|
||
// cmdline_logging/sysmon_installed/file/file_exists(/note)
|
||
// collect → collected/written/since/limit/file(/note)
|
||
// verify → ok/checked(/broken_at)
|
||
// 信任边界(不夸大):本地 audit-l2.log 只是归一化副本,防篡改依赖转发出机。
|
||
await test('v2.1.0 L2 status:Windows 审计策略状态(returns_schema 全字段)', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.audit.l2', { action: 'status' }, 90_000);
|
||
c.close();
|
||
assert.equal(r.status, 'ok', `L2 status 失败: ${JSON.stringify(r.error)}`);
|
||
assert.equal(r.data.platform, 'windows', 'platform 必须回 windows');
|
||
assert.equal(r.data.supported, true, 'Windows 上必须 supported=true');
|
||
assert.equal(typeof r.data.auditpol, 'string', 'auditpol 原始输出应为字符串');
|
||
assert.equal(typeof r.data.legacy_policy_disabled, 'boolean', 'SCENoApplyLegacyAuditPolicy 判定应为布尔');
|
||
assert.equal(typeof r.data.cmdline_logging, 'boolean', '命令行记录开关应为布尔');
|
||
assert.equal(typeof r.data.sysmon_installed, 'boolean', 'Sysmon 检测结果应为布尔');
|
||
assert.equal(typeof r.data.file, 'string', 'L2 副本路径应为字符串');
|
||
assert.equal(typeof r.data.file_exists, 'boolean', 'file_exists 应为布尔');
|
||
});
|
||
|
||
await test('v2.1.0 L2 collect:拉取 Security 事件归一化落盘(since/limit 回显)', async () => {
|
||
const c = await connect();
|
||
const since = Date.now() - 60 * 60 * 1000; // 近 1 小时:缩小扫描窗口,CI 上更快
|
||
const r = await c.invoke('system.audit.l2', { action: 'collect', since, limit: 100 }, 120_000);
|
||
c.close();
|
||
// 读 Security 日志需要管理员;非提权环境明确跳过(与 winget 缺失同一容错哲学,
|
||
// 不把「环境没条件」伪装成「功能坏了」)
|
||
if (r.status === 'failed' && r.error?.name === 'E_EXECUTION_FAILED') return 'skip';
|
||
assert.equal(r.status, 'ok', `L2 collect 失败: ${JSON.stringify(r.error)}`);
|
||
assert.equal(r.data.since, since, 'since 必须原样回显(默认 24h 的参数契约)');
|
||
assert.equal(r.data.limit, 100, 'limit 必须原样回显');
|
||
assert.ok(Number.isInteger(r.data.collected) && r.data.collected >= 0, 'collected 应为非负整数');
|
||
assert.ok(
|
||
Number.isInteger(r.data.written) && r.data.written >= 0 && r.data.written <= r.data.collected,
|
||
'written 应为非负整数且不超过 collected',
|
||
);
|
||
assert.equal(typeof r.data.file, 'string', '落盘路径应为字符串');
|
||
assert.equal(typeof r.data.note, 'string', '信任边界提示必须随 collect 返回(不得假装本地副本不可篡改)');
|
||
});
|
||
|
||
await test('v2.1.0 L2 verify:归一化链完整性校验(空链亦为 ok)', async () => {
|
||
const c = await connect();
|
||
const r = await c.invoke('system.audit.l2', { action: 'verify' }, 60_000);
|
||
c.close();
|
||
assert.equal(r.status, 'ok', `L2 verify 失败: ${JSON.stringify(r.error)}`);
|
||
assert.equal(r.data.ok, true, `L2 链应完整${r.data.ok ? '' : `: ${JSON.stringify(r.data.broken_at)}`}`);
|
||
assert.ok(Number.isInteger(r.data.checked) && r.data.checked >= 0, 'checked 应为非负整数');
|
||
});
|
||
} finally {
|
||
agent.kill();
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
}
|
||
|
||
console.log(`\n结果: ${passed} 通过, ${failed} 失败, ${skipped} 跳过\n`);
|
||
process.exit(failed > 0 ? 1 : 0);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('测试异常:', err);
|
||
process.exit(1);
|
||
});
|