aps-agent/apps/desktop/test/updater.test.cjs

647 lines
26 KiB
JavaScript
Raw Normal View History

const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
UpgradeError,
Updater,
compareVersions,
loadManifest,
verifyPackage,
ensureNewerVersion,
backupInstall,
restoreBackup,
probeHealth,
} = require('../updater.cjs');
const EOL = '\n';
/** 临时目录夹具:installDir(模拟安装目录)+ 升级包 + 清单 */
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-updater-'));
const installDir = path.join(root, 'install');
const backupDir = path.join(root, 'backup');
const packageDir = path.join(root, 'upgrade');
const apsHome = path.join(root, '.aps');
fs.mkdirSync(path.join(installDir, 'resources'), { recursive: true });
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'old-exe-v0.1.0');
fs.writeFileSync(path.join(installDir, 'resources', 'index.html'), '<!doctype html>old');
return { root, installDir, backupDir, packageDir, apsHome };
}
/** 写一个升级包 + 对应清单;返回 { manifestPath, packagePath, manifest } */
function makeUpgrade({ packageDir, version = '0.2.0', payload = 'installer-payload-v0.2.0', size, sha256 }) {
const fileName = 'aps-agent-desktop-' + version + '.exe';
const packagePath = path.join(packageDir, fileName);
fs.writeFileSync(packagePath, payload);
const crypto = require('node:crypto');
const manifest = {
schemaVersion: 1,
product: 'aps-agent-desktop',
version,
platform: process.platform,
package: {
fileName,
sha256: sha256 || crypto.createHash('sha256').update(payload).digest('hex'),
size: size ?? Buffer.byteLength(payload),
},
releasedAt: new Date().toISOString(),
};
const manifestPath = path.join(packageDir, 'upgrade-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
return { manifestPath, packagePath, manifest };
}
/** 403 健康服务(模拟 sidecar nonce 门禁未通过) */
function startHealthServer403() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('Forbidden');
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, base: 'http://127.0.0.1:' + port });
});
});
}
/** 本地健康检查服务:/api/health 返回 200 + 可选 version */
function startHealthServer({ version } = {}) {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
if (req.url === '/api/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, ...(version ? { version } : {}) }));
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, base: 'http://127.0.0.1:' + port });
});
});
}
test('compareVersions: 0.2.0 > 0.1.0, equal, downgrade', () => {
assert.equal(compareVersions('0.2.0', '0.1.0'), 1);
assert.equal(compareVersions('0.1.0', '0.1.0'), 0);
assert.equal(compareVersions('0.1.0', '0.2.0'), -1);
assert.equal(compareVersions('1.10.0', '1.9.9'), 1);
});
test('ensureNewerVersion rejects downgrade and same version', () => {
assert.throws(
() => ensureNewerVersion({ currentVersion: '0.2.0', targetVersion: '0.1.0' }),
(err) => err.code === 'VERSION_NOT_NEWER',
);
assert.throws(
() => ensureNewerVersion({ currentVersion: '0.2.0', targetVersion: '0.2.0' }),
(err) => err.code === 'VERSION_NOT_NEWER',
);
assert.equal(ensureNewerVersion({ currentVersion: '0.1.0', targetVersion: '0.2.0' }), true);
});
test('loadManifest rejects malformed manifest', () => {
const { root } = fixture();
try {
const bad = path.join(root, 'bad.json');
fs.writeFileSync(bad, '{}');
assert.throws(() => loadManifest(bad), (err) => err.code === 'MANIFEST_INVALID');
fs.writeFileSync(bad, 'not json');
assert.throws(() => loadManifest(bad), (err) => err.code === 'MANIFEST_READ_FAILED');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('verifyPackage rejects checksum mismatch (tampered installer)', async () => {
const { root, packageDir } = fixture();
try {
const { manifestPath, packagePath, manifest } = makeUpgrade({ packageDir });
manifest.package.sha256 = '0'.repeat(64); // 篡改清单哈希
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
await assert.rejects(
verifyPackage({ manifest, packagePath }),
(err) => err.code === 'CHECKSUM_MISMATCH',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('verifyPackage rejects size mismatch', async () => {
const { root, packageDir } = fixture();
try {
const { manifestPath, packagePath, manifest } = makeUpgrade({ packageDir, payload: 'abc' });
manifest.package.size = 9999;
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
await assert.rejects(
verifyPackage({ manifest, packagePath }),
(err) => err.code === 'SIZE_MISMATCH',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('verifyPackage rejects missing package and platform mismatch', async () => {
const { root, packageDir } = fixture();
try {
const { manifest, packagePath } = makeUpgrade({ packageDir });
await assert.rejects(
verifyPackage({ manifest, packagePath: path.join(packageDir, 'nope.exe'), platform: process.platform }),
(err) => err.code === 'PACKAGE_NOT_FOUND',
);
await assert.rejects(
verifyPackage({ manifest, packagePath, platform: 'darwin' }),
(err) => err.code === 'PLATFORM_MISMATCH',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('backup/restore roundtrip restores files (copy mode)', async () => {
const { root, installDir, backupDir } = fixture();
try {
const backup = await backupInstall({ installDir, backupDir, mode: 'copy' });
assert.ok(backup.fileCount >= 2);
assert.ok(fs.existsSync(path.join(backupDir, 'backup-manifest.json')));
// 破坏安装目录
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'corrupted');
fs.rmSync(path.join(installDir, 'resources'), { recursive: true, force: true });
const restored = await restoreBackup({ backupDir, installDir, expectedMode: 'copy' });
assert.equal(restored.restored, true);
assert.equal(fs.readFileSync(path.join(installDir, '工业智核 APS.exe'), 'utf8'), 'old-exe-v0.1.0');
assert.equal(fs.readFileSync(path.join(installDir, 'resources', 'index.html'), 'utf8'), '<!doctype html>old');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('listing mode backup cannot restore content', async () => {
const { root, installDir, backupDir } = fixture();
try {
const backup = await backupInstall({ installDir, backupDir, mode: 'listing' });
assert.ok(backup.fileCount >= 2);
await assert.rejects(
restoreBackup({ backupDir, installDir, expectedMode: 'listing' }),
(err) => err.code === 'BACKUP_LISTING_ONLY',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('applyUpdate happy path: verify → backup → install → health check → success audit', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
const { server, base } = await startHealthServer({ version: '0.2.0' });
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir });
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
// 注入 fake installer:模拟 NSIS 写入新版本文件
const install = async () => {
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'new-exe-v0.2.0');
};
const report = await updater.applyUpdate({
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion: '0.1.0',
healthBase: base,
expectedVersion: '0.2.0',
install,
});
assert.equal(report.ok, true);
assert.equal(report.targetVersion, '0.2.0');
assert.equal(fs.readFileSync(path.join(installDir, '工业智核 APS.exe'), 'utf8'), 'new-exe-v0.2.0');
// 审计事件齐全
const audit = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
const events = audit.map((line) => JSON.parse(line).event);
assert.ok(events.includes('upgrade.verified'));
assert.ok(events.includes('upgrade.backup'));
assert.ok(events.includes('upgrade.installed'));
assert.ok(events.includes('upgrade.health-check'));
assert.ok(events.includes('upgrade.succeeded'));
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('applyUpdate failure auto-rolls back and records rollback audit', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
const { server, base } = await startHealthServer({ version: '0.2.0' });
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir });
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
// 注入 installer:写坏文件 → 健康检查失败(版本不匹配)
const install = async () => {
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'broken-exe');
fs.writeFileSync(path.join(installDir, 'resources', 'index.html'), 'broken');
};
await assert.rejects(
updater.applyUpdate({
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion: '0.1.0',
healthBase: base,
expectedVersion: '0.3.0', // 与健康检查返回的 0.2.0 不匹配 → 失败
install,
}),
(err) => err.code === 'HEALTH_VERSION_MISMATCH',
);
// 自动回滚:文件应恢复为备份内容
assert.equal(fs.readFileSync(path.join(installDir, '工业智核 APS.exe'), 'utf8'), 'old-exe-v0.1.0');
assert.equal(fs.readFileSync(path.join(installDir, 'resources', 'index.html'), 'utf8'), '<!doctype html>old');
// 审计:upgrade.failed + rollback.done
const audit = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
const events = audit.map((line) => JSON.parse(line).event);
assert.ok(events.includes('upgrade.failed'));
assert.ok(events.includes('rollback.done'));
const failed = audit.map((line) => JSON.parse(line)).find((e) => e.event === 'upgrade.failed');
assert.equal(failed.payload.reason, 'HEALTH_VERSION_MISMATCH');
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('applyUpdate rejects tampered installer before touching install dir', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
try {
const { manifestPath, packagePath, manifest } = makeUpgrade({ packageDir });
manifest.package.sha256 = 'f'.repeat(64); // 篡改哈希
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
await assert.rejects(
updater.applyUpdate({
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion: '0.1.0',
install: async () => { throw new Error('should not install'); },
}),
(err) => err.code === 'CHECKSUM_MISMATCH',
);
// 未备份、未安装
assert.equal(fs.existsSync(backupDir), false);
assert.equal(fs.readFileSync(path.join(installDir, '工业智核 APS.exe'), 'utf8'), 'old-exe-v0.1.0');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('applyUpdate rejects version downgrade', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir, version: '0.0.9' });
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
await assert.rejects(
updater.applyUpdate({
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion: '0.1.0',
install: async () => { throw new Error('should not install'); },
}),
(err) => err.code === 'VERSION_NOT_NEWER',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('checkForUpdate reports updateAvailable/verified', async () => {
const { root, packageDir, apsHome } = fixture();
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir, version: '0.2.0' });
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
const result = await updater.checkForUpdate({ manifestPath, packagePath, currentVersion: '0.1.0' });
assert.equal(result.updateAvailable, true);
assert.equal(result.verified, true);
assert.equal(result.targetVersion, '0.2.0');
const stale = await updater.checkForUpdate({ manifestPath, packagePath, currentVersion: '0.3.0' });
assert.equal(stale.updateAvailable, false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('probeHealth succeeds and detects version mismatch', async () => {
const { server, base } = await startHealthServer({ version: '0.2.0' });
try {
const ok = await probeHealth({ base, expectedVersion: '0.2.0' });
assert.equal(ok.ok, true);
assert.equal(ok.version, '0.2.0');
await assert.rejects(
probeHealth({ base, expectedVersion: '9.9.9' }),
(err) => err.code === 'HEALTH_VERSION_MISMATCH',
);
} finally {
server.close();
}
});
test('UpgradeError carries code', () => {
const err = new UpgradeError('SIZE_MISMATCH', 'size mismatch', { expected: 1, actual: 2 });
assert.equal(err.code, 'SIZE_MISMATCH');
assert.equal(err.details.expected, 1);
});
/** 伪造审计上报端点:记录 POST /api/gov/audit 的 method/url/headers/body;可选返回状态码 */
function startAuditServer({ status = 200 } = {}) {
return new Promise((resolve) => {
const received = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
received.push({ method: req.method, url: req.url, headers: req.headers, body });
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: status < 400 }));
});
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, received, base: 'http://127.0.0.1:' + port });
});
});
}
test('probeHealth falls back to HTTP-200-only check when body has no version', async () => {
const { server, base } = await startHealthServer({}); // 响应体无 version 字段
try {
const ok = await probeHealth({ base, expectedVersion: '0.2.0' });
assert.equal(ok.ok, true);
assert.equal(ok.version, null);
const plain = await probeHealth({ base });
assert.equal(plain.ok, true);
assert.equal(plain.version, null);
} finally {
server.close();
}
});
test('writeAudit uploads audit with one-time bearer token and sidecar nonce', async () => {
const { root, apsHome } = fixture();
const { server, received, base } = await startAuditServer();
try {
const updater = new Updater({
apsHome,
logger: { info() {}, error() {}, warn() {} },
auditApiUrl: base,
auditToken: 'audit-token-ff',
sidecarNonce: 'n'.repeat(64),
});
const entry = await updater.writeAudit({ event: 'upgrade.verified', payload: { fromVersion: '0.1.0', toVersion: '0.2.0' } });
assert.equal(entry.event, 'upgrade.verified');
assert.equal(received.length, 1);
assert.equal(received[0].method, 'POST');
assert.equal(received[0].url, '/api/gov/audit/events');
assert.equal(received[0].headers.authorization, 'Bearer audit-token-ff');
assert.equal(received[0].headers['x-aps-sidecar-nonce'], 'n'.repeat(64));
const sent = JSON.parse(received[0].body);
assert.equal(sent.action, 'upgrade.verified'); // 本地 event 字段映射为链上 action
assert.equal(sent.category, 'UPGRADE');
assert.equal(sent.actor, 'desktop-updater');
assert.equal(sent.power, 'P0');
assert.equal(sent.result, 'SUCCESS');
assert.equal(sent.timestamp, entry.ts);
assert.equal(typeof sent.idempotencyKey, 'string');
assert.equal(sent.idempotencyKey.length, 64); // sha256 确定性指纹
assert.deepEqual(sent.rationale.payload, { fromVersion: '0.1.0', toVersion: '0.2.0' });
// 本地 JSONL 仍为默认审计通道
const local = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
assert.equal(JSON.parse(local[0]).event, 'upgrade.verified');
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('writeAudit without token stays local-only (no upload)', async () => {
const { root, apsHome } = fixture();
const { server, received, base } = await startAuditServer();
try {
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
await updater.writeAudit({ event: 'rollback.done' });
assert.equal(received.length, 0);
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('audit upload failure is non-fatal and keeps local JSONL', async () => {
const { root, apsHome } = fixture();
const { server, received, base } = await startAuditServer({ status: 500 });
const warnings = [];
try {
const updater = new Updater({
apsHome,
logger: { info() {}, error() {}, warn: (message) => warnings.push(String(message)) },
auditApiUrl: base,
auditToken: 'audit-token-ff',
});
const entry = await updater.writeAudit({ event: 'upgrade.failed', result: 'FAILED' });
assert.equal(entry.event, 'upgrade.failed'); // 上传失败不抛错
assert.equal(received.length, 1);
assert.ok(warnings.length >= 1);
const local = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
assert.equal(JSON.parse(local[0]).event, 'upgrade.failed');
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
/** 本地健康检查服务:/api/health 要求 x-aps-sidecar-nonce 匹配 expectedNonce(否则 403)。 */
function startNonceHealthServer({ version, expectedNonce } = {}) {
return new Promise((resolve) => {
const seen = [];
const server = http.createServer((req, res) => {
seen.push({ url: req.url, nonce: req.headers['x-aps-sidecar-nonce'] || null });
if (req.headers['x-aps-sidecar-nonce'] !== expectedNonce) {
res.writeHead(403);
res.end();
return;
}
if (req.url === '/api/health') {
res.writeHead(200, { 'content-type': 'application/json', 'x-aps-sidecar-nonce': expectedNonce });
res.end(JSON.stringify({ ok: true, ...(version ? { version } : {}) }));
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, seen, base: 'http://127.0.0.1:' + port });
});
});
}
test('probeHealth sends sidecar nonce and flags nonce-gated 403', async () => {
const expectedNonce = 'e'.repeat(64);
const { server, seen, base } = await startNonceHealthServer({ version: '0.2.0', expectedNonce });
try {
const ok = await probeHealth({ base, expectedVersion: '0.2.0', sidecarNonce: expectedNonce });
assert.equal(ok.ok, true);
assert.equal(ok.version, '0.2.0');
assert.equal(ok.nonce, expectedNonce);
assert.equal(seen[0].nonce, expectedNonce);
// 缺失 nonce → 新网关 nonce 门禁 403
await assert.rejects(
probeHealth({ base }),
(err) => err.code === 'HEALTH_HTTP_STATUS' && err.details.statusCode === 403,
);
// 旧/错误 nonce → 403 且 nonceMismatch=true(升级后 nonce 已轮换的可诊断信号)
await assert.rejects(
probeHealth({ base, sidecarNonce: 'd'.repeat(64) }),
(err) => err.code === 'HEALTH_HTTP_STATUS' && err.details.statusCode === 403
&& err.details.nonceMismatch === true,
);
} finally {
server.close();
}
});
test('applyUpdate post-install health check uses the new gateway nonce', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
const newNonce = 'f'.repeat(64);
const { server, seen, base } = await startNonceHealthServer({ version: '0.2.0', expectedNonce: newNonce });
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir });
// 升级前构造时侧写的是旧 nonce;applyUpdate 显式传入升级后新 sidecar 的 nonce 续接健康检查
const updater = new Updater({
apsHome,
logger: { info() {}, error() {}, warn() {} },
auditApiUrl: base,
auditToken: 'audit-token-ff',
sidecarNonce: 'old-nonce-' + '0'.repeat(54),
});
const install = async () => {
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'new-exe-v0.2.0');
};
const report = await updater.applyUpdate({
manifestPath,
packagePath,
installDir,
backupDir,
currentVersion: '0.1.0',
healthBase: base,
expectedVersion: '0.2.0',
install,
sidecarNonce: newNonce, // 新网关 nonce(main 在 sidecar 重启后获取并传入)
});
assert.equal(report.ok, true);
assert.equal(report.nonce, newNonce);
assert.equal(report.health.nonce, newNonce);
// 健康检查请求必须携带新网关 nonce(seen[0] 是升级前的审计上报,用旧 nonce 属预期 fail-soft)
const healthSeen = seen.find((entry) => entry.url === '/api/health');
assert.equal(healthSeen.nonce, newNonce);
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('audit upload 404/403 (endpoint missing / nonce gate) stays local-only', async () => {
for (const status of [404, 403]) {
const { root, apsHome } = fixture();
const { server, received, base } = await startAuditServer({ status });
const warnings = [];
try {
const updater = new Updater({
apsHome,
logger: { info() {}, error() {}, warn: (message) => warnings.push(String(message)) },
auditApiUrl: base,
auditToken: 'audit-token-ff',
sidecarNonce: 'n'.repeat(64),
});
const entry = await updater.writeAudit({ event: 'upgrade.installed' });
assert.equal(entry.event, 'upgrade.installed'); // 上报失败不抛错(fail-soft)
assert.equal(received.length, 1);
assert.ok(warnings.length >= 1);
const local = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
assert.equal(JSON.parse(local[0]).event, 'upgrade.installed');
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
}
});
test('applyUpdate health 403 with nonce defers verification (no rollback, round-46)', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
const { server, base } = await startHealthServer403();
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir });
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
const install = async () => {
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'new-exe-v0.2.0');
fs.writeFileSync(path.join(installDir, 'resources', 'index.html'), 'new');
};
const report = await updater.applyUpdate({
manifestPath, packagePath, installDir, backupDir,
currentVersion: '0.1.0', healthBase: base, install,
sidecarNonce: 'deadbeef' + '0'.repeat(56),
});
assert.equal(report.ok, false);
assert.equal(report.deferredVerification, true);
assert.equal(report.reason, 'NONCE_MISMATCH');
assert.equal(fs.readFileSync(path.join(installDir, '工业智核 APS.exe'), 'utf8'), 'new-exe-v0.2.0');
assert.equal(fs.readFileSync(path.join(installDir, 'resources', 'index.html'), 'utf8'), 'new');
const audit = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
const events = audit.map((line) => JSON.parse(line).event);
assert.ok(events.includes('upgrade.health-pending'));
assert.ok(!events.includes('rollback.done'));
const pending = audit.map((line) => JSON.parse(line)).find((e) => e.event === 'upgrade.health-pending');
assert.equal(pending.result, 'PENDING');
assert.equal(pending.payload.reason, 'NONCE_MISMATCH');
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('applyUpdate health 403 without nonce still rolls back (round-46)', async () => {
const { root, installDir, backupDir, packageDir, apsHome } = fixture();
const { server, base } = await startHealthServer403();
try {
const { manifestPath, packagePath } = makeUpgrade({ packageDir });
const updater = new Updater({ apsHome, logger: { info() {}, error() {}, warn() {} } });
const install = async () => {
fs.writeFileSync(path.join(installDir, '工业智核 APS.exe'), 'new-exe-v0.2.0');
fs.writeFileSync(path.join(installDir, 'resources', 'index.html'), 'new');
};
await assert.rejects(
updater.applyUpdate({
manifestPath, packagePath, installDir, backupDir,
currentVersion: '0.1.0', healthBase: base, install,
}),
(err) => err.code === 'HEALTH_HTTP_STATUS',
);
assert.equal(fs.readFileSync(path.join(installDir, '工业智核 APS.exe'), 'utf8'), 'old-exe-v0.1.0');
assert.equal(fs.readFileSync(path.join(installDir, 'resources', 'index.html'), 'utf8'), '<!doctype html>old');
const audit = fs.readFileSync(path.join(apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8').trim().split('\n');
const events = audit.map((line) => JSON.parse(line).event);
assert.ok(events.includes('rollback.done'));
} finally {
server.close();
fs.rmSync(root, { recursive: true, force: true });
}
});