#!/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'), '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'), '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);