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

647 lines
26 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.

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 });
}
});