aps-agent/apps/desktop/test/upgrade-nonce.test.cjs

306 lines
13 KiB
JavaScript
Raw 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 os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createUpgradeNonceCoordinator,
normalizeUpgradeError,
UPGRADE_NONCE_EVENT_VERIFIED,
UPGRADE_NONCE_EVENT_FAILED,
} = require('../upgrade-nonce.cjs');
/** 固定的时钟:startedAt=999000ms,当前=1000000ms(间隔 1s,不触发默认 10min 过期)。 */
const T0 = 999_000;
const T1 = 1_000_000;
function makePending(overrides = {}) {
return {
id: 'upgrade-1',
currentVersion: '0.1.0',
targetVersion: '0.2.0',
expectedVersion: '0.2.0',
healthBase: 'http://127.0.0.1:8000',
audit: { auditApiUrl: 'http://127.0.0.1:8000', auditToken: 'audit-token', sidecarNonce: 'old-nonce' },
startedAt: new Date(T0).toISOString(),
...overrides,
};
}
function makeCoordinator(overrides = {}) {
return createUpgradeNonceCoordinator({
verifyHealth: async ({ origin, nonce, expectedVersion }) => ({
ok: true,
version: '0.2.0',
nonce,
expectedVersion,
}),
now: () => T1,
...overrides,
});
}
test('restarted 事件触发重验证:用新 nonce 探测(版本比对),成功标记完成并清空', async () => {
const seen = [];
const completed = [];
const coordinator = makeCoordinator({
verifyHealth: async ({ origin, nonce, expectedVersion }) => {
seen.push({ origin, nonce, expectedVersion });
return { ok: true, version: '0.2.0', nonce };
},
onComplete: async ({ pending, health, nonce }) => {
completed.push({ id: pending.id, health, nonce });
},
});
const pending = coordinator.setPending(makePending());
assert.equal(coordinator.getPending().status, 'installing');
await coordinator.markInstalled({ id: pending.id, report: { ok: true, targetVersion: '0.2.0', nonce: 'old-nonce' } });
assert.equal(coordinator.getPending().status, 'verifying');
const result = await coordinator.onSidecarRestarted({
origin: 'http://127.0.0.1:9000',
nonce: 'new-nonce-xyz',
});
assert.equal(result.handled, true);
assert.equal(result.ok, true);
assert.equal(result.nonce, 'new-nonce-xyz');
// 重验证必须使用新 nonce + 目标版本比对
assert.equal(seen.length, 1);
assert.deepEqual(seen[0], {
origin: 'http://127.0.0.1:9000',
nonce: 'new-nonce-xyz',
expectedVersion: '0.2.0',
});
assert.equal(completed.length, 1);
assert.equal(completed[0].nonce, 'new-nonce-xyz');
assert.equal(coordinator.getPending(), null); // 已清空
const last = coordinator.getLastResult();
assert.equal(last.status, 'verified');
assert.equal(last.nonce, 'new-nonce-xyz');
assert.equal(last.verifiedVia, 'sidecar-restart');
});
test('版本不匹配失败留痕:onFailure 收到错误、pending 标记 failed 并清空', async () => {
const failures = [];
const coordinator = makeCoordinator({
verifyHealth: async () => {
const error = new Error('健康检查版本不匹配: 期望=0.2.0 实际=0.1.0');
error.code = 'HEALTH_VERSION_MISMATCH';
error.details = { expectedVersion: '0.2.0', actual: '0.1.0' };
throw error;
},
onFailure: async ({ pending, error }) => {
failures.push({ id: pending.id, error });
},
});
const pending = coordinator.setPending(makePending());
await coordinator.markInstalled({ id: pending.id, report: { ok: true, nonce: 'old-nonce' } });
const result = await coordinator.onSidecarRestarted({ origin: 'http://127.0.0.1:9000', nonce: 'new-nonce' });
assert.equal(result.handled, true);
assert.equal(result.ok, false);
assert.equal(result.error.code, 'HEALTH_VERSION_MISMATCH');
assert.equal(result.error.details.actual, '0.1.0');
assert.equal(failures.length, 1);
assert.equal(failures[0].error.code, 'HEALTH_VERSION_MISMATCH');
assert.equal(coordinator.getPending(), null);
const last = coordinator.getLastResult();
assert.equal(last.status, 'failed');
assert.equal(last.error.code, 'HEALTH_VERSION_MISMATCH');
assert.equal(last.nonce, 'new-nonce');
});
test('无 pending 时 restarted 事件 no-op(不调用 verifyHealth)', async () => {
let verifyCalls = 0;
const coordinator = makeCoordinator({
verifyHealth: async () => { verifyCalls += 1; return { ok: true }; },
});
const result = await coordinator.onSidecarRestarted({ origin: 'http://127.0.0.1:9000', nonce: 'new-nonce' });
assert.deepEqual(result, { handled: false, reason: 'no-pending' });
assert.equal(verifyCalls, 0);
assert.equal(coordinator.getPending(), null);
assert.equal(coordinator.getLastResult(), null);
});
test('安装期间 restarted 事件被延迟记录,markInstalled 后立即用新 nonce 补做重验证', async () => {
const seen = [];
const coordinator = makeCoordinator({
verifyHealth: async ({ nonce }) => { seen.push(nonce); return { ok: true, version: '0.2.0' }; },
});
const pending = coordinator.setPending(makePending());
// applyUpdate 尚未返回(installing)时 sidecar 已重启
const deferred = await coordinator.onSidecarRestarted({ origin: 'http://127.0.0.1:9000', nonce: 'new-nonce' });
assert.equal(deferred.reason, 'installing-deferred');
assert.equal(seen.length, 0);
// applyUpdate 返回后补做最终校验
await coordinator.markInstalled({ id: pending.id, report: { ok: true, nonce: 'old-nonce' } });
assert.equal(seen.length, 1);
assert.equal(seen[0], 'new-nonce');
assert.equal(coordinator.getPending(), null);
assert.equal(coordinator.getLastResult().status, 'verified');
});
test('已终结(failed)的 pending 不再重复验证', async () => {
let verifyCalls = 0;
const coordinator = makeCoordinator({
verifyHealth: async () => { verifyCalls += 1; return { ok: true }; },
});
const pending = coordinator.setPending(makePending());
coordinator.markFailed({ id: pending.id, error: new Error('install failed') });
const result = await coordinator.onSidecarRestarted({ origin: 'http://x', nonce: 'n' });
assert.equal(result.reason, 'already-settled');
assert.equal(result.status, 'failed');
assert.equal(verifyCalls, 0);
});
test('过期 pending(超过 maxPendingMs)不触发重验证并清理', async () => {
let verifyCalls = 0;
const coordinator = createUpgradeNonceCoordinator({
verifyHealth: async () => { verifyCalls += 1; return { ok: true }; },
maxPendingMs: 60_000,
now: () => 2_000_000, // startedAt=999000 → 间隔约 1001s > 60s
});
const pending = coordinator.setPending(makePending());
await coordinator.markInstalled({ id: pending.id, report: { ok: true } });
const result = await coordinator.onSidecarRestarted({ origin: 'http://x', nonce: 'n' });
assert.equal(result.reason, 'stale');
assert.equal(verifyCalls, 0);
assert.equal(coordinator.getPending(), null);
assert.equal(coordinator.getLastResult().status, 'stale');
});
test('setPending:进行中 pending 拒绝新登记;已终结可替换', async () => {
const coordinator = makeCoordinator();
const first = coordinator.setPending(makePending({ id: 'u1' }));
assert.equal(first.id, 'u1');
assert.equal(coordinator.setPending(makePending({ id: 'u2' })), null); // 进行中拒绝
coordinator.markFailed({ id: 'u1', error: new Error('x') });
const second = coordinator.setPending(makePending({ id: 'u3' }));
assert.equal(second.id, 'u3'); // 已终结可替换
assert.equal(coordinator.getPending().status, 'installing');
});
test('deferFinalVerify=false(无 sidecar/dev 环境):applyUpdate 后直接完成并清空', async () => {
let verifyCalls = 0;
const coordinator = makeCoordinator({
verifyHealth: async () => { verifyCalls += 1; return { ok: true }; },
});
const pending = coordinator.setPending(makePending());
await coordinator.markInstalled({
id: pending.id,
report: { ok: true, targetVersion: '0.2.0', health: { ok: true, version: '0.2.0' } },
deferFinalVerify: false,
});
assert.equal(verifyCalls, 0);
assert.equal(coordinator.getPending(), null);
const last = coordinator.getLastResult();
assert.equal(last.status, 'verified');
assert.equal(last.verifiedVia, 'preliminary');
});
test('onComplete 抛错不阻断状态机(升级仍标记完成)', async () => {
const coordinator = makeCoordinator({
onComplete: async () => { throw new Error('audit upload failed'); },
logger: { warn() {}, error() {}, info() {} },
});
const pending = coordinator.setPending(makePending());
await coordinator.markInstalled({ id: pending.id, report: { ok: true } });
const result = await coordinator.onSidecarRestarted({ origin: 'http://x', nonce: 'n' });
assert.equal(result.handled, true);
assert.equal(result.ok, true);
assert.equal(coordinator.getPending(), null);
assert.equal(coordinator.getLastResult().status, 'verified');
});
test('expectedVersion 未提供时不自动回填 targetVersion(退化为 200 检查,避免旧 main 进程误报)', async () => {
const seen = [];
const coordinator = makeCoordinator({
verifyHealth: async (args) => { seen.push(args); return { ok: true }; },
});
const pending = coordinator.setPending(makePending({ expectedVersion: null, targetVersion: '0.2.0' }));
await coordinator.markInstalled({ id: pending.id, report: { ok: true, targetVersion: '0.2.0' } });
await coordinator.onSidecarRestarted({ origin: 'http://127.0.0.1:9000', nonce: 'new-nonce' });
assert.equal(seen.length, 1);
assert.equal(seen[0].expectedVersion, null);
assert.equal(seen[0].nonce, 'new-nonce');
assert.equal(coordinator.getLastResult().status, 'verified');
});
test('normalizeUpgradeError 归一化 UpgradeError / 普通错误 / 原始值', () => {
const upErr = new Error('boom');
upErr.code = 'HEALTH_TIMEOUT';
upErr.details = { a: 1 };
assert.deepEqual(normalizeUpgradeError(upErr), { code: 'HEALTH_TIMEOUT', message: 'boom', details: { a: 1 } });
assert.deepEqual(normalizeUpgradeError(new Error('plain')), { code: 'UNKNOWN', message: 'plain', details: null });
assert.deepEqual(normalizeUpgradeError('raw'), { code: 'UNKNOWN', message: 'raw', details: null });
assert.deepEqual(normalizeUpgradeError(null), { code: 'UNKNOWN', message: 'null', details: null });
});
test('审计事件名常量与 round-45 契约一致(可被 main 侧复用)', () => {
assert.equal(UPGRADE_NONCE_EVENT_VERIFIED, 'upgrade.health-verified');
assert.equal(UPGRADE_NONCE_EVENT_FAILED, 'upgrade.health-verify-failed');
});
test('createUpgradeNonceCoordinator 缺 verifyHealth 时抛 TypeError', () => {
assert.throws(() => createUpgradeNonceCoordinator({}), TypeError);
assert.throws(() => createUpgradeNonceCoordinator(), TypeError);
});
test('setPending 持久化:新协调器同 statePath 恢复 pending 为 verifying(round-48)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-upgrade-nonce-'));
const statePath = path.join(root, 'upgrade-state.json');
try {
const first = makeCoordinator({ statePath });
first.setPending(makePending());
assert.ok(fs.existsSync(statePath), 'setPending 应写 upgrade-state.json');
const second = makeCoordinator({ statePath });
const restored = second.getPending();
assert.ok(restored, '新协调器应恢复 pending');
assert.equal(restored.id, 'upgrade-1');
assert.equal(restored.status, 'verifying');
assert.equal(restored.currentVersion, '0.1.0');
// 恢复后的 pending 可被 restarted 事件续接(用新 nonce 补验成功)
const result = await second.onSidecarRestarted({ origin: 'http://127.0.0.1:8000', nonce: 'new-nonce' });
assert.equal(result.handled, true);
assert.equal(result.ok, true);
assert.equal(second.getPending(), null);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('恢复时过期 pending 被丢弃(round-48)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-upgrade-nonce-'));
const statePath = path.join(root, 'upgrade-state.json');
try {
const first = makeCoordinator({ statePath, maxPendingMs: 10_000 });
first.setPending(makePending()); // startedAt=T0(999s),now=T1(1000s)
assert.ok(fs.existsSync(statePath));
// 新协调器 maxPendingMs=100(< 1s 间隔)→ 恢复时判定过期丢弃
const second = makeCoordinator({ statePath, maxPendingMs: 100 });
assert.equal(second.getPending(), null);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('settle 后持久化状态清空:新协调器无 pending(round-48)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-upgrade-nonce-'));
const statePath = path.join(root, 'upgrade-state.json');
try {
const first = makeCoordinator({ statePath });
first.setPending(makePending());
await first.markInstalled({ report: { ok: true, targetVersion: '0.2.0' }, deferFinalVerify: false });
assert.equal(first.getPending(), null);
const saved = JSON.parse(fs.readFileSync(statePath, 'utf8'));
assert.equal(saved.pending, null);
const second = makeCoordinator({ statePath });
assert.equal(second.getPending(), null);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});