161 lines
6.9 KiB
JavaScript
161 lines
6.9 KiB
JavaScript
|
|
#!/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);
|