306 lines
13 KiB
JavaScript
306 lines
13 KiB
JavaScript
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 });
|
||
}
|
||
});
|