aps-agent/apps/desktop/test/updater-smoke.mjs

161 lines
6.9 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* 桌面端离线升级/回滚 冒烟脚本(纯逻辑,临时目录模拟,不启动 Electron、不真装 NSIS)。
* 运行:node test/updater-smoke.mjs
* 覆盖:校验和失败拒绝安装 / 版本回退拒绝 / 备份-安装-健康检查-失败自动回滚 / 审计留痕。
*/
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const {
Updater,
UpgradeError,
verifyPackage,
} = await import(pathToFileURL(path.join(here, '..', 'updater.cjs')).href);
let passed = 0;
let failed = 0;
function check(name, fn) {
return Promise.resolve()
.then(fn)
.then(() => { passed += 1; console.log('PASS ' + name); })
.catch((error) => { failed += 1; console.log('FAIL ' + name + ' :: ' + error.message); });
}
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-updater-smoke-'));
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 };
}
function makeUpgrade(packageDir, { version = '0.2.0', payload = 'installer-v0.2.0', tamperHash = false } = {}) {
const fileName = 'aps-agent-desktop-' + version + '.exe';
const packagePath = path.join(packageDir, fileName);
fs.writeFileSync(packagePath, payload);
const manifest = {
schemaVersion: 1,
product: 'aps-agent-desktop',
version,
platform: process.platform,
package: {
fileName,
sha256: createHash('sha256').update(payload).digest('hex'),
size: Buffer.byteLength(payload),
},
releasedAt: new Date().toISOString(),
};
if (tamperHash) manifest.package.sha256 = '0'.repeat(64);
const manifestPath = path.join(packageDir, 'upgrade-manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
return { manifestPath, packagePath, manifest };
}
function startHealthServer(version = '0.2.0') {
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 }));
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, '127.0.0.1', () => resolve({ server, base: 'http://127.0.0.1:' + server.address().port }));
});
}
const quiet = { info() {}, error() {}, warn() {} };
// ① 校验和失败 → 拒绝安装(安装目录不被触碰)
await check('checksum mismatch rejects install', async () => {
const fx = fixture();
try {
const { manifestPath, packagePath, manifest } = makeUpgrade(fx.packageDir, { tamperHash: true });
let rejected = false;
try {
await verifyPackage({ manifest, packagePath });
} catch (error) {
rejected = error instanceof UpgradeError && error.code === 'CHECKSUM_MISMATCH';
}
assert.equal(rejected, true, 'expected CHECKSUM_MISMATCH');
assert.equal(fs.readFileSync(path.join(fx.installDir, '工业智核 APS.exe'), 'utf8'), 'old-exe-v0.1.0');
} finally { fs.rmSync(fx.root, { recursive: true, force: true }); }
});
// ② 版本回退 → 拒绝安装
await check('version downgrade rejects install', async () => {
const fx = fixture();
try {
const { manifestPath, packagePath } = makeUpgrade(fx.packageDir, { version: '0.0.9' });
const updater = new Updater({ apsHome: fx.apsHome, logger: quiet });
await assert.rejects(
updater.applyUpdate({ manifestPath, packagePath, installDir: fx.installDir, backupDir: fx.backupDir, currentVersion: '0.1.0', install: async () => { throw new Error('must not install'); } }),
(error) => error.code === 'VERSION_NOT_NEWER',
);
} finally { fs.rmSync(fx.root, { recursive: true, force: true }); }
});
// ③ 成功升级:校验 → 备份 → 安装 → 健康检查 → 审计
await check('upgrade success with health check + audit', async () => {
const fx = fixture();
const { server, base } = await startHealthServer('0.2.0');
try {
const { manifestPath, packagePath } = makeUpgrade(fx.packageDir);
const updater = new Updater({ apsHome: fx.apsHome, logger: quiet });
const report = await updater.applyUpdate({
manifestPath, packagePath,
installDir: fx.installDir, backupDir: fx.backupDir,
currentVersion: '0.1.0', healthBase: base, expectedVersion: '0.2.0',
install: async () => fs.writeFileSync(path.join(fx.installDir, '工业智核 APS.exe'), 'new-exe-v0.2.0'),
});
assert.equal(report.ok, true);
assert.equal(fs.readFileSync(path.join(fx.installDir, '工业智核 APS.exe'), 'utf8'), 'new-exe-v0.2.0');
const audit = fs.readFileSync(path.join(fx.apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8');
assert.ok(audit.includes('upgrade.succeeded'));
} finally { server.close(); fs.rmSync(fx.root, { recursive: true, force: true }); }
});
// ④ 失败自动回滚:健康检查失败 → 文件恢复 + 审计记录原因
await check('failure auto-rollback restores files + audits reason', async () => {
const fx = fixture();
const { server, base } = await startHealthServer('0.2.0');
try {
const { manifestPath, packagePath } = makeUpgrade(fx.packageDir);
const updater = new Updater({ apsHome: fx.apsHome, logger: quiet });
await assert.rejects(
updater.applyUpdate({
manifestPath, packagePath,
installDir: fx.installDir, backupDir: fx.backupDir,
currentVersion: '0.1.0', healthBase: base, expectedVersion: '0.3.0',
install: async () => {
fs.writeFileSync(path.join(fx.installDir, '工业智核 APS.exe'), 'broken');
fs.writeFileSync(path.join(fx.installDir, 'resources', 'index.html'), 'broken');
},
}),
(error) => error.code === 'HEALTH_VERSION_MISMATCH',
);
assert.equal(fs.readFileSync(path.join(fx.installDir, '工业智核 APS.exe'), 'utf8'), 'old-exe-v0.1.0');
assert.equal(fs.readFileSync(path.join(fx.installDir, 'resources', 'index.html'), 'utf8'), '<!doctype html>old');
const audit = fs.readFileSync(path.join(fx.apsHome, 'logs', 'upgrade-audit.jsonl'), 'utf8');
assert.ok(audit.includes('upgrade.failed'));
assert.ok(audit.includes('rollback.done'));
} finally { server.close(); fs.rmSync(fx.root, { recursive: true, force: true }); }
});
console.log('\nupdater-smoke: ' + passed + ' passed, ' + failed + ' failed');
process.exit(failed === 0 ? 0 : 1);