647 lines
26 KiB
JavaScript
647 lines
26 KiB
JavaScript
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 });
|
||
}
|
||
});
|