mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
0953025036
* fix(fleet): gate node update actions to admins and harden update tracking Node update affordances now render only for admins, matching the admin-only routes behind them. Previously a non-admin could open the Fleet view and see the per-node Update button, Update all, retry, dismiss, and Recheck controls, then get a 403 on click. Those controls are now hidden for non-admins, who still see read-only update status. Both update-status clear routes (per-node and bulk) now require admin, and the bulk recheck throttles its forced "latest published version" lookup so a caller cannot loop it to hammer the upstream registries; the response reports whether the refresh actually ran so the UI can surface a "checked recently" note. Completion detection no longer reports a node as Updated when it merely blips offline and returns on the same version with an unchanged process start time. That case stays in progress and is decided by the existing early-fail and timeout heuristics, so a momentary network glitch is not mistaken for a successful update. Failed and timed-out updates now emit an operator-visible warning, and a periodic safety-net sweep bounds in-flight trackers when no client is polling for status. * fix(fleet): harden update completion and recheck failure handling Refinements from review of the node self-update hardening: - Completion signal 1 now requires a valid version, not merely a different one. A node whose /api/meta momentarily omits or mangles its version (online, same process) reported version=null, which compared unequal to the previous version and falsely marked the update completed. It now stays in progress and is decided by the early-fail/timeout heuristics. - Terminal resolution is atomic: it re-reads the live tracker and transitions only if it is still in flight with the same start time, so two concurrent status polls cannot both warn or clobber each other's transition. - The operator warning for a failed or timed-out update now redacts secret-shaped text (bearer/basic/token/password, credentialed URLs) from the underlying error before logging, in addition to stripping control characters. - The Recheck button now surfaces an error toast when the request throws (network or auth failure), matching the existing non-ok-response path instead of only logging to the console.
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
/**
|
|
* Hardening coverage for the fleet node self-update flow:
|
|
* - GET /api/fleet/update-status terminal resolution: hard timeout, early-fail,
|
|
* the version-change and process-restart completion signals, and the tightened
|
|
* offline-then-online rule (a bounce on the same version with an unchanged,
|
|
* known process start time must NOT be reported as completed).
|
|
* - The failure-class terminal transitions emit an operator-visible WARN.
|
|
* - POST /api/fleet/nodes/:id/update concurrency guard (409).
|
|
* - Authorization: both DELETE clear routes require admin.
|
|
* - The forced-recheck throttle on DELETE /update-status?recheck=true.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import type { RemoteMeta } from '../services/CapabilityRegistry';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
import { CacheService } from '../services/CacheService';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let adminAuth: string;
|
|
let viewerAuth: string;
|
|
let proxyNodeId: number;
|
|
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
|
let FleetUpdateTrackerService: typeof import('../services/FleetUpdateTrackerService').FleetUpdateTrackerService;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
|
|
let UPDATE_TIMEOUT_MS: number;
|
|
let localNodeId: number;
|
|
|
|
// Recent enough to clear neither the early-fail (3 min) nor the timeout (5 min).
|
|
const RECENT_MS = 30_000;
|
|
// Past the early-fail heuristic but inside the hard timeout window.
|
|
const EARLY_FAIL_ELAPSED_MS = 240_000;
|
|
|
|
const ONLINE = (over: Partial<RemoteMeta> = {}): RemoteMeta => ({
|
|
version: '0.83.0',
|
|
capabilities: ['stacks', 'self-update'],
|
|
startedAt: 1,
|
|
updateError: null,
|
|
online: true,
|
|
...over,
|
|
});
|
|
|
|
function mockMeta(meta: RemoteMeta) {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta);
|
|
}
|
|
|
|
function mockTarget() {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) =>
|
|
id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' } : null,
|
|
);
|
|
}
|
|
|
|
// getCompareTarget hits GitHub; pin it to a version above the node's so the
|
|
// node reads as outdated and never trips signal 4 unintentionally.
|
|
function mockCompareTargetFetch() {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
|
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
}
|
|
|
|
function setTracker(over: Partial<import('../services/FleetUpdateTrackerService').UpdateTracker>, nodeId = proxyNodeId) {
|
|
FleetUpdateTrackerService.getInstance().set(nodeId, {
|
|
status: 'updating',
|
|
startedAt: Date.now() - RECENT_MS,
|
|
previousVersion: '0.83.0',
|
|
previousProcessStart: 1,
|
|
wasOffline: false,
|
|
...over,
|
|
});
|
|
}
|
|
|
|
async function getStatus(nodeId = proxyNodeId): Promise<number | null | undefined> {
|
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
|
|
expect(res.status).toBe(200);
|
|
return res.body.nodes.find((n: { nodeId: number }) => n.nodeId === nodeId)?.updateStatus;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
|
const trackerMod = await import('../services/FleetUpdateTrackerService');
|
|
FleetUpdateTrackerService = trackerMod.FleetUpdateTrackerService;
|
|
UPDATE_TIMEOUT_MS = trackerMod.UPDATE_TIMEOUT_MS;
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
localNodeId = db.getNodes().find(n => n.type === 'local')!.id;
|
|
proxyNodeId = db.addNode({
|
|
name: 'proxy-hardening-test',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: 'http://192.168.1.99:1852',
|
|
api_token: 'proxy-token',
|
|
});
|
|
db.addUser({ username: 'viewer-hardening-test', password_hash: 'unused', role: 'viewer' });
|
|
|
|
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
|
viewerAuth = `Bearer ${jwt.sign({ username: 'viewer-hardening-test' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
const tracker = FleetUpdateTrackerService.getInstance();
|
|
for (const [id] of tracker.entries()) tracker.delete(id);
|
|
});
|
|
|
|
describe('GET /api/fleet/update-status terminal resolution', () => {
|
|
it('times out an in-flight tracker past the hard ceiling and warns', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
mockMeta(ONLINE());
|
|
const warnSpy = vi.spyOn(console, 'warn');
|
|
setTracker({ startedAt: Date.now() - (UPDATE_TIMEOUT_MS + 1_000) });
|
|
|
|
expect(await getStatus()).toBe('timeout');
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Node update timeout'));
|
|
});
|
|
|
|
it('fails via the early-fail heuristic when the node stays online and unchanged, and warns', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
mockMeta(ONLINE()); // version unchanged, startedAt unchanged
|
|
const warnSpy = vi.spyOn(console, 'warn');
|
|
setTracker({ startedAt: Date.now() - EARLY_FAIL_ELAPSED_MS });
|
|
|
|
expect(await getStatus()).toBe('failed');
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Node update failed'));
|
|
});
|
|
|
|
it('completes via signal 1 when the remote version changed, without warning', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
mockMeta(ONLINE({ version: '0.99.0', startedAt: 1 }));
|
|
const warnSpy = vi.spyOn(console, 'warn');
|
|
setTracker({ previousVersion: '0.83.0' });
|
|
|
|
expect(await getStatus()).toBe('completed');
|
|
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Node update'));
|
|
});
|
|
|
|
it('completes via signal 2 when the remote process restarted (startedAt changed)', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
mockMeta(ONLINE({ version: '0.83.0', startedAt: 2 })); // same version, new process start
|
|
setTracker({ previousProcessStart: 1 });
|
|
|
|
expect(await getStatus()).toBe('completed');
|
|
});
|
|
|
|
it('does NOT complete a same-version bounce when the process start time is unchanged', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
// Bounced offline then back, but same version AND same (known) process start:
|
|
// the process never actually restarted, so this is a blip, not a completed update.
|
|
mockMeta(ONLINE({ version: '0.83.0', startedAt: 1 }));
|
|
setTracker({ wasOffline: true, previousProcessStart: 1 });
|
|
|
|
expect(await getStatus()).toBe('updating');
|
|
});
|
|
|
|
it('does NOT complete when the remote version is momentarily unavailable (null) on an unchanged process', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
// /api/meta briefly omits or mangles the version (online, but version null)
|
|
// with no process restart: this is not a version change and must not complete.
|
|
mockMeta(ONLINE({ version: null, startedAt: 1 }));
|
|
setTracker({ previousVersion: '0.83.0', previousProcessStart: 1, wasOffline: false });
|
|
|
|
expect(await getStatus()).toBe('updating');
|
|
});
|
|
|
|
it('completes an offline->online bounce when the process start time is unavailable', async () => {
|
|
mockTarget();
|
|
mockCompareTargetFetch();
|
|
// No startedAt reported by the remote: offline->online is the only restart
|
|
// evidence available, so signal 3 remains a valid completion fallback.
|
|
mockMeta(ONLINE({ version: '0.83.0', startedAt: null }));
|
|
setTracker({ wasOffline: true, previousProcessStart: null });
|
|
|
|
expect(await getStatus()).toBe('completed');
|
|
});
|
|
|
|
it('fails a local-node update via the early-fail heuristic and warns', async () => {
|
|
// The local node reads its own version, never fetchMetaForNode; with no
|
|
// recorded self-update error it resolves through the early-fail heuristic.
|
|
mockCompareTargetFetch();
|
|
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
|
const warnSpy = vi.spyOn(console, 'warn');
|
|
setTracker({ startedAt: Date.now() - EARLY_FAIL_ELAPSED_MS }, localNodeId);
|
|
|
|
expect(await getStatus(localNodeId)).toBe('failed');
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Node update failed'));
|
|
});
|
|
});
|
|
|
|
describe('POST /api/fleet/nodes/:id/update concurrency', () => {
|
|
it('returns 409 when an update is already in progress for the node', async () => {
|
|
setTracker({ startedAt: Date.now() - RECENT_MS });
|
|
|
|
const res = await request(app)
|
|
.post(`/api/fleet/nodes/${proxyNodeId}/update`)
|
|
.set('Authorization', adminAuth);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body?.error).toMatch(/already in progress/i);
|
|
});
|
|
});
|
|
|
|
describe('clear-route authorization', () => {
|
|
it('rejects DELETE /nodes/:id/update-status for a non-admin', async () => {
|
|
const res = await request(app)
|
|
.delete(`/api/fleet/nodes/${proxyNodeId}/update-status`)
|
|
.set('Authorization', viewerAuth);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('rejects DELETE /update-status for a non-admin', async () => {
|
|
const res = await request(app)
|
|
.delete('/api/fleet/update-status?recheck=true')
|
|
.set('Authorization', viewerAuth);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('allows DELETE /nodes/:id/update-status for an admin and clears the tracker', async () => {
|
|
setTracker({ status: 'failed', error: 'boom', startedAt: Date.now() - RECENT_MS });
|
|
const res = await request(app)
|
|
.delete(`/api/fleet/nodes/${proxyNodeId}/update-status`)
|
|
.set('Authorization', adminAuth);
|
|
expect(res.status).toBe(204);
|
|
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('forced-recheck throttle', () => {
|
|
it('forces the latest-version refresh once, then throttles within the cooldown', async () => {
|
|
// Reset the module-scope throttle clock so this assertion does not depend on
|
|
// whether an earlier test happened to force a recheck first.
|
|
const { _resetForcedRecheckThrottleForTests } = await import('../routes/fleet');
|
|
_resetForcedRecheckThrottleForTests();
|
|
mockCompareTargetFetch();
|
|
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
|
|
|
const first = await request(app)
|
|
.delete('/api/fleet/update-status?recheck=true')
|
|
.set('Authorization', adminAuth);
|
|
expect(first.status).toBe(200);
|
|
expect(first.body.rechecked).toBe(true);
|
|
expect(invalidateSpy).toHaveBeenCalledWith('latest-version');
|
|
|
|
invalidateSpy.mockClear();
|
|
|
|
// A terminal tracker set before the throttled call must still be cleared:
|
|
// the cooldown gates only the upstream version refresh, not tracker cleanup.
|
|
FleetUpdateTrackerService.getInstance().set(proxyNodeId, {
|
|
status: 'failed', startedAt: Date.now(), previousVersion: null,
|
|
previousProcessStart: null, wasOffline: false, resolvedAt: Date.now(), error: 'boom',
|
|
});
|
|
|
|
const second = await request(app)
|
|
.delete('/api/fleet/update-status?recheck=true')
|
|
.set('Authorization', adminAuth);
|
|
expect(second.status).toBe(200);
|
|
expect(second.body.rechecked).toBe(false);
|
|
expect(invalidateSpy).not.toHaveBeenCalledWith('latest-version');
|
|
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)).toBeUndefined();
|
|
});
|
|
});
|