mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
fix(fleet): gate node update actions to admins and harden update tracking (#1272)
* 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.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,22 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService';
|
||||
import {
|
||||
FleetUpdateTrackerService,
|
||||
UPDATE_TIMEOUT_MS,
|
||||
TERMINAL_TTL_MS,
|
||||
type UpdateTracker,
|
||||
} from '../services/FleetUpdateTrackerService';
|
||||
|
||||
/** Build a tracker literal with sane defaults so tests set only what matters. */
|
||||
function mk(over: Partial<UpdateTracker>): UpdateTracker {
|
||||
return {
|
||||
status: 'updating',
|
||||
startedAt: Date.now(),
|
||||
previousVersion: null,
|
||||
previousProcessStart: null,
|
||||
wasOffline: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// The service is a process-local singleton. Each test clears any entries left
|
||||
// by a prior test so assertions on size() are deterministic.
|
||||
@@ -92,4 +109,47 @@ describe('FleetUpdateTrackerService', () => {
|
||||
expect(svc.get(5)?.status).toBe('completed');
|
||||
expect(svc.get(5)?.resolvedAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
describe('sweepStale()', () => {
|
||||
it('times out an updating tracker past the ceiling and leaves a fresh one alone', () => {
|
||||
svc.set(1, mk({ status: 'updating', startedAt: Date.now() - (UPDATE_TIMEOUT_MS + 1_000) }));
|
||||
svc.set(2, mk({ status: 'updating', startedAt: Date.now() }));
|
||||
|
||||
const result = svc.sweepStale();
|
||||
|
||||
expect(result.timedOut).toBe(1);
|
||||
expect(svc.get(1)?.status).toBe('timeout');
|
||||
expect(svc.get(2)?.status).toBe('updating');
|
||||
});
|
||||
|
||||
it('reaps a completed tracker past its TTL but keeps a recent one', () => {
|
||||
svc.set(1, mk({ status: 'completed', resolvedAt: Date.now() - (TERMINAL_TTL_MS + 1_000) }));
|
||||
svc.set(2, mk({ status: 'completed', resolvedAt: Date.now() }));
|
||||
|
||||
const result = svc.sweepStale();
|
||||
|
||||
expect(result.reaped).toBe(1);
|
||||
expect(svc.get(1)).toBeUndefined();
|
||||
expect(svc.get(2)?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('never sweeps failed or timeout trackers (the operator must dismiss them)', () => {
|
||||
const old = Date.now() - (TERMINAL_TTL_MS + 60_000);
|
||||
svc.set(1, mk({ status: 'failed', resolvedAt: old }));
|
||||
svc.set(2, mk({ status: 'timeout', resolvedAt: old }));
|
||||
|
||||
const result = svc.sweepStale();
|
||||
|
||||
expect(result).toEqual({ timedOut: 0, reaped: 0 });
|
||||
expect(svc.get(1)?.status).toBe('failed');
|
||||
expect(svc.get(2)?.status).toBe('timeout');
|
||||
});
|
||||
|
||||
it('does not reap a completed tracker that has no resolvedAt', () => {
|
||||
svc.set(1, mk({ status: 'completed', resolvedAt: undefined }));
|
||||
|
||||
expect(svc.sweepStale().reaped).toBe(0);
|
||||
expect(svc.get(1)?.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+76
-18
@@ -6,7 +6,7 @@ import type Dockerode from 'dockerode';
|
||||
import { DatabaseService, type Node } from '../services/DatabaseService';
|
||||
import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService';
|
||||
import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants';
|
||||
import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService';
|
||||
import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
@@ -29,7 +29,7 @@ import { withTimeout, TimeoutError } from '../utils/withTimeout';
|
||||
// paths cap the slow `docker system df` call at the same 8s budget (F-6).
|
||||
const FLEET_DF_TIMEOUT_MS = 8_000;
|
||||
import { POLICY_SEVERITIES } from '../utils/severity';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog';
|
||||
import { formatNoTargetError } from '../utils/remoteTarget';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
@@ -41,9 +41,44 @@ import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-hea
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
|
||||
const updateTracker = FleetUpdateTrackerService.getInstance();
|
||||
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.';
|
||||
const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure
|
||||
// Throttle the forced latest-version refresh so a caller cannot loop the recheck
|
||||
// endpoint to hammer GitHub / Docker Hub. The 30-minute cache still serves reads
|
||||
// between forced refreshes; this only bounds how often we bypass it.
|
||||
const FORCED_RECHECK_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes
|
||||
let lastForcedRecheckAt = 0;
|
||||
|
||||
/** Test-only: reset the forced-recheck throttle clock so suites do not depend
|
||||
* on cross-test ordering of the module-scope timestamp. */
|
||||
export function _resetForcedRecheckThrottleForTests(): void {
|
||||
lastForcedRecheckAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically resolve an in-flight update tracker to a terminal state and store
|
||||
* it. Re-reads the live entry and transitions only if it is still 'updating'
|
||||
* with the same startedAt; because there is no await between the read and the
|
||||
* set, a concurrent /update-status poll that already resolved this node cannot
|
||||
* be clobbered or cause a duplicate WARN. For failure-class outcomes (failed /
|
||||
* timeout) it emits one operator-visible WARN so a failed fleet update is
|
||||
* observable without enabling developer mode. The error text is secret-redacted
|
||||
* and control-stripped before logging; no tokens or meta dumps.
|
||||
*/
|
||||
function resolveTerminal(
|
||||
node: { id: number; name: string },
|
||||
tracker: UpdateTracker,
|
||||
status: TerminalStatus,
|
||||
error?: string,
|
||||
): void {
|
||||
const live = updateTracker.get(node.id);
|
||||
if (!live || live.status !== 'updating' || live.startedAt !== tracker.startedAt) return;
|
||||
if (status !== 'completed') {
|
||||
const elapsedSec = Math.round((Date.now() - live.startedAt) / 1000);
|
||||
const detail = error ? `: ${sanitizeForLog(redactSensitiveText(error))}` : '';
|
||||
console.warn(`[Fleet] Node update ${status} for "${sanitizeForLog(node.name)}" (id ${node.id}) after ${elapsedSec}s${detail}`);
|
||||
}
|
||||
updateTracker.set(node.id, updateTracker.resolve(live, status, error));
|
||||
}
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
|
||||
@@ -727,17 +762,20 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
|
||||
if (elapsed > UPDATE_TIMEOUT_MS) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's');
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'timeout', UPDATE_TIMEOUT_MSG));
|
||||
resolveTerminal(node, tracker, 'timeout', UPDATE_TIMEOUT_MSG);
|
||||
} else if (node.type === 'remote') {
|
||||
if (remoteUpdateError) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError);
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'failed', remoteUpdateError));
|
||||
resolveTerminal(node, tracker, 'failed', remoteUpdateError);
|
||||
} else if (!remoteOnline) {
|
||||
if (!tracker.wasOffline) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)');
|
||||
updateTracker.set(node.id, { ...tracker, wasOffline: true });
|
||||
}
|
||||
} else if (version !== tracker.previousVersion) {
|
||||
} else if (isValidVersion(version) && version !== tracker.previousVersion) {
|
||||
// Signal 1: a valid, different version. A null/unparseable version
|
||||
// from a transient /api/meta blip is NOT a version change, so it
|
||||
// must not complete a still-running, same-process node here.
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version);
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (
|
||||
@@ -747,8 +785,18 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 2 (process restarted):', tracker.previousProcessStart, '->', remoteStartedAt);
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (tracker.wasOffline && remoteOnline) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online)');
|
||||
} else if (
|
||||
tracker.wasOffline &&
|
||||
remoteOnline &&
|
||||
(remoteStartedAt === null || tracker.previousProcessStart === null)
|
||||
) {
|
||||
// Signal 3: offline-then-online is only trustworthy as a completion
|
||||
// signal when we cannot read the remote process start time. When
|
||||
// startedAt IS known and unchanged (signal 2 above did not fire),
|
||||
// the process never restarted, so a brief unreachable blip on the
|
||||
// same version must not be reported as a completed update; it falls
|
||||
// through to the early-fail / timeout heuristics instead.
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online, startedAt unavailable)');
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (
|
||||
elapsed > 15_000 &&
|
||||
@@ -764,7 +812,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (elapsed > EARLY_FAIL_MS) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected');
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.'));
|
||||
resolveTerminal(node, tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.');
|
||||
}
|
||||
} else if (node.type === 'local') {
|
||||
// Local node has only two failure signals: an explicit pull/spawn
|
||||
@@ -776,18 +824,18 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
const localError = selfUpdate.getLastError();
|
||||
if (localError) {
|
||||
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'update failed:', localError);
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'failed', localError));
|
||||
resolveTerminal(node, tracker, 'failed', localError);
|
||||
selfUpdate.clearLastError();
|
||||
} else if (elapsed > EARLY_FAIL_MS) {
|
||||
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's');
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'));
|
||||
resolveTerminal(node, tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expire completed entries 60s after they resolved so the badge
|
||||
// is visible briefly after completion.
|
||||
if (tracker?.status === 'completed' && tracker.resolvedAt && Date.now() - tracker.resolvedAt > 60_000) {
|
||||
// Auto-expire completed entries after their visibility window so the
|
||||
// badge is visible briefly after completion.
|
||||
if (tracker?.status === 'completed' && tracker.resolvedAt && Date.now() - tracker.resolvedAt > TERMINAL_TTL_MS) {
|
||||
updateTracker.delete(node.id);
|
||||
}
|
||||
|
||||
@@ -1012,6 +1060,7 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon
|
||||
});
|
||||
|
||||
fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
|
||||
if (nodeId === null) return;
|
||||
@@ -1029,16 +1078,25 @@ fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: R
|
||||
});
|
||||
|
||||
fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
// Pre-fetch fresh latest version so the next GET has up-to-date data.
|
||||
if (!requireAdmin(req, res)) return;
|
||||
// Optionally pre-fetch a fresh latest version so the next GET compares against
|
||||
// it. Throttled so a caller cannot loop this to hammer the upstream registries;
|
||||
// `rechecked` tells the client whether the forced refresh actually ran.
|
||||
let rechecked = false;
|
||||
if (req.query.recheck === 'true') {
|
||||
await getLatestVersion(true);
|
||||
const now = Date.now();
|
||||
if (now - lastForcedRecheckAt >= FORCED_RECHECK_COOLDOWN_MS) {
|
||||
lastForcedRecheckAt = now;
|
||||
await getLatestVersion(true);
|
||||
rechecked = true;
|
||||
}
|
||||
}
|
||||
for (const [nodeId, tracker] of updateTracker.entries()) {
|
||||
if (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed') {
|
||||
updateTracker.delete(nodeId);
|
||||
}
|
||||
}
|
||||
res.status(204).send();
|
||||
res.status(200).json({ rechecked });
|
||||
});
|
||||
|
||||
// ─── Fleet Actions: gateway-orchestrated endpoints (multi-node) ───
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface UpdateTracker {
|
||||
|
||||
export type TerminalStatus = 'completed' | 'failed' | 'timeout';
|
||||
|
||||
/** Hard ceiling for an in-flight update before it is declared timed out. */
|
||||
export const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.';
|
||||
/** How long a resolved `completed` tracker lingers before it is reaped, so the
|
||||
* badge stays briefly visible after the update lands. */
|
||||
export const TERMINAL_TTL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* In-memory tracker for in-flight fleet node updates. Keyed by node id.
|
||||
*
|
||||
@@ -75,4 +82,31 @@ export class FleetUpdateTrackerService {
|
||||
public resolve(tracker: UpdateTracker, status: TerminalStatus, error?: string): UpdateTracker {
|
||||
return { ...tracker, status, resolvedAt: Date.now(), error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety-net sweep driven off the monitor tick rather than the frontend poll.
|
||||
* The `/api/fleet/update-status` poll is the primary resolver, but it only
|
||||
* runs while a client is watching; this bounds trackers when nothing polls:
|
||||
* an in-flight tracker past the ceiling is timed out, and a resolved
|
||||
* `completed` badge past its visibility window is reaped (mirroring the
|
||||
* poll's auto-expire). Failed/timeout trackers persist until the operator
|
||||
* dismisses them, matching the poll's behaviour. Returns counts for logging.
|
||||
*/
|
||||
public sweepStale(): { timedOut: number; reaped: number } {
|
||||
const now = Date.now();
|
||||
let timedOut = 0;
|
||||
let reaped = 0;
|
||||
for (const [nodeId, tracker] of this.trackers) {
|
||||
if (tracker.status === 'updating') {
|
||||
if (now - tracker.startedAt > UPDATE_TIMEOUT_MS) {
|
||||
this.trackers.set(nodeId, this.resolve(tracker, 'timeout', UPDATE_TIMEOUT_MSG));
|
||||
timedOut++;
|
||||
}
|
||||
} else if (tracker.status === 'completed' && tracker.resolvedAt && now - tracker.resolvedAt > TERMINAL_TTL_MS) {
|
||||
this.trackers.delete(nodeId);
|
||||
reaped++;
|
||||
}
|
||||
}
|
||||
return { timedOut, reaped };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import DockerController from './DockerController';
|
||||
import { DatabaseService, Node, StackAlert } from './DatabaseService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { FleetUpdateTrackerService } from './FleetUpdateTrackerService';
|
||||
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
|
||||
import { getLatestVersion } from '../utils/version-check';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -235,6 +236,7 @@ export class MonitorService {
|
||||
|
||||
await this.evaluateGlobalSettings(settings);
|
||||
await this.evaluateStackAlerts(db);
|
||||
this.sweepStaleUpdateTrackers();
|
||||
|
||||
const elapsed = Date.now() - cycleStart;
|
||||
if (elapsed > 25_000) {
|
||||
@@ -250,6 +252,23 @@ export class MonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety net for the in-memory fleet update trackers. The
|
||||
* /api/fleet/update-status poll is the primary resolver, but it only runs
|
||||
* while a client is watching; this sweep bounds in-flight trackers (to
|
||||
* timeout) and reaps stale completed badges even when nothing is polling.
|
||||
* Cheap, synchronous, in-memory.
|
||||
*/
|
||||
private sweepStaleUpdateTrackers(): void {
|
||||
const { timedOut, reaped } = FleetUpdateTrackerService.getInstance().sweepStale();
|
||||
if (timedOut > 0) {
|
||||
console.warn(`[Monitor] Timed out ${timedOut} stale fleet update tracker(s) (no status poll within the update window)`);
|
||||
}
|
||||
if (isDebugEnabled() && reaped > 0) {
|
||||
console.debug(`[Monitor:diag] Reaped ${reaped} resolved fleet update tracker(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateGlobalSettings(settings: Record<string, string>) {
|
||||
// F-11 suppression window. Default 60 min; configurable via global
|
||||
// settings. NaN / non-positive values fall back to the default so a
|
||||
|
||||
@@ -79,7 +79,7 @@ The gateway tracks each in-flight update in memory and watches the target node f
|
||||
|
||||
- The remote reports a *new* `version` value over its `/api/meta` endpoint.
|
||||
- The remote's process `startedAt` timestamp moves forward, indicating a fresh container start.
|
||||
- The remote went offline at any point during the watch window and has come back online.
|
||||
- The remote went offline during the watch window and has come back online, used as a fallback only for nodes that do not report a process start timestamp. A node that simply blips offline and returns on the *same* version with an unchanged start time is treated as still updating, not done, so a momentary network glitch is never mistaken for a successful update.
|
||||
- More than 15 seconds have elapsed and the remote's reported version is at or above the comparison target (the gateway's own version, or the published latest, whichever is appropriate).
|
||||
|
||||
When any signal fires, the row flips from **Updating** to a green **Updated** badge. The **Updated** state stays visible for 60 seconds, then auto-clears so the row settles back to **Up to date**.
|
||||
@@ -100,7 +100,7 @@ Both **Failed** and **Timed out** badges expose two inline actions:
|
||||
|
||||
Hovering either badge reveals the underlying error message reported by the remote (or by the helper container, for the gateway itself).
|
||||
|
||||
The header of the sheet exposes a **Recheck** button that does three things in one click: it flushes the cached "latest published version" lookup (resolved against the GitHub Releases API, with a Docker Hub fallback), it clears every terminal `Failed` and `Timed out` badge across the fleet, and it re-fetches the version metadata from every node.
|
||||
The header of the sheet exposes a **Recheck** button that does three things in one click: it flushes the cached "latest published version" lookup (resolved against the GitHub Releases API, with a Docker Hub fallback), it clears every terminal `Failed` and `Timed out` badge across the fleet, and it re-fetches the version metadata from every node. The published-version lookup is rate-limited to one refresh every couple of minutes, so clicking **Recheck** again right away reuses the recent result and shows a brief "checked recently" note rather than hitting the registry on every click.
|
||||
|
||||
<Note>
|
||||
Update tracking is held entirely in the gateway's memory. Restarting the gateway clears all in-flight, failed, and timed-out states automatically, so a stuck row always resolves itself on the next gateway restart even without a manual **Dismiss**.
|
||||
|
||||
@@ -237,6 +237,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
checkingUpdates={updateStatus.checkingUpdates}
|
||||
updateStatuses={updateStatus.updateStatuses}
|
||||
updatingNodeId={updateStatus.updatingNodeId}
|
||||
isAdmin={isAdmin}
|
||||
fetchUpdateStatus={updateStatus.fetchUpdateStatus}
|
||||
triggerNodeUpdate={updateStatus.triggerNodeUpdate}
|
||||
retryNodeUpdate={updateStatus.retryNodeUpdate}
|
||||
|
||||
@@ -211,8 +211,8 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
<UpdateStatusBadge
|
||||
status={updateStatus.updateStatus}
|
||||
error={updateStatus.error}
|
||||
onRetry={onRetryUpdate ? () => onRetryUpdate(node.id) : undefined}
|
||||
onDismiss={onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
||||
onRetry={isAdmin && onRetryUpdate ? () => onRetryUpdate(node.id) : undefined}
|
||||
onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
||||
/>
|
||||
)}
|
||||
{updateStatus?.updateAvailable && !updateStatus.updateStatus && (
|
||||
@@ -292,8 +292,8 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update button */}
|
||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && (
|
||||
{/* Update button (mutating action: admin only, matches the requireAdmin route guard) */}
|
||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && isAdmin && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
import type { NodeUpdateStatus } from './types';
|
||||
@@ -18,6 +19,10 @@ interface NodeUpdatesSheetProps {
|
||||
checkingUpdates: boolean;
|
||||
updateStatuses: NodeUpdateStatus[];
|
||||
updatingNodeId: number | null;
|
||||
/** Mutating affordances (update, update-all, retry, dismiss, recheck) render
|
||||
* only for admins, matching the requireAdmin guard on the fleet routes they
|
||||
* call. Non-admins still see the read-only status table. */
|
||||
isAdmin: boolean;
|
||||
fetchUpdateStatus: () => Promise<void>;
|
||||
triggerNodeUpdate: (nodeId: number) => void;
|
||||
retryNodeUpdate: (nodeId: number) => void;
|
||||
@@ -26,7 +31,7 @@ interface NodeUpdatesSheetProps {
|
||||
}
|
||||
|
||||
export function NodeUpdatesSheet({
|
||||
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId,
|
||||
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
|
||||
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
|
||||
}: NodeUpdatesSheetProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -40,10 +45,26 @@ export function NodeUpdatesSheet({
|
||||
const handleRecheck = async () => {
|
||||
setRecheckingUpdates(true);
|
||||
try {
|
||||
await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
||||
const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
||||
if (res.ok) {
|
||||
// The server throttles the upstream version lookup; `rechecked:false`
|
||||
// means a forced refresh ran too recently and the cached value stands.
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.rechecked === false) {
|
||||
toast.info('Already checked for the latest version recently.');
|
||||
}
|
||||
} else {
|
||||
// apiFetch only throws on 401/network, so HTTP errors (e.g. a 500
|
||||
// from the upstream lookup) land here, not in the catch below.
|
||||
console.warn('[Fleet] Recheck returned HTTP', res.status);
|
||||
toast.error('Could not recheck for updates. Try again shortly.');
|
||||
}
|
||||
await fetchUpdateStatus();
|
||||
} catch (err) {
|
||||
// Recheck is an explicit user click, so a thrown network/auth failure
|
||||
// gets a toast, not just a console breadcrumb.
|
||||
console.warn('[Fleet] Recheck failed:', err);
|
||||
toast.error('Could not recheck for updates. Try again shortly.');
|
||||
} finally {
|
||||
setRecheckingUpdates(false);
|
||||
}
|
||||
@@ -69,7 +90,7 @@ export function NodeUpdatesSheet({
|
||||
? undefined
|
||||
: (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`);
|
||||
|
||||
const secondaryActions = updatableRemoteCount > 0
|
||||
const secondaryActions = isAdmin && updatableRemoteCount > 0
|
||||
? [{
|
||||
label: `Update all (${updatableRemoteCount})`,
|
||||
icon: Download,
|
||||
@@ -84,12 +105,12 @@ export function NodeUpdatesSheet({
|
||||
crumb={['Fleet', 'Updates']}
|
||||
name="Node updates"
|
||||
meta={meta}
|
||||
primaryAction={{
|
||||
primaryAction={isAdmin ? {
|
||||
label: 'Recheck',
|
||||
icon: recheckingUpdates ? Loader2 : RefreshCw,
|
||||
onClick: () => { void handleRecheck(); },
|
||||
disabled: recheckingUpdates || checkingUpdates,
|
||||
}}
|
||||
} : undefined}
|
||||
secondaryActions={secondaryActions}
|
||||
footerContext={footerContext}
|
||||
size="lg"
|
||||
@@ -179,8 +200,8 @@ export function NodeUpdatesSheet({
|
||||
<UpdateStatusBadge
|
||||
status={s.updateStatus}
|
||||
error={s.error}
|
||||
onRetry={() => retryNodeUpdate(s.nodeId)}
|
||||
onDismiss={() => dismissNodeUpdate(s.nodeId)}
|
||||
onRetry={isAdmin ? () => retryNodeUpdate(s.nodeId) : undefined}
|
||||
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
|
||||
/>
|
||||
)}
|
||||
{!s.updateStatus && !s.updateAvailable && (
|
||||
@@ -188,7 +209,7 @@ export function NodeUpdatesSheet({
|
||||
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
|
||||
</Badge>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && (
|
||||
{s.updateAvailable && !s.updateStatus && isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -203,6 +224,11 @@ export function NodeUpdatesSheet({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !isAdmin && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
|
||||
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -66,4 +66,22 @@ describe('NodeCard', () => {
|
||||
// The actions menu only renders when cordon (admiral-only here) is available.
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const updateAvailableStatus = {
|
||||
nodeId: 2, name: 'Edge', type: 'remote' as const, version: '1.0.0', latestVersion: '1.1.0',
|
||||
updateAvailable: true, updateStatus: null,
|
||||
};
|
||||
|
||||
it('renders the update button for an admin when an update is available', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the update button for a non-admin but still shows the read-only badge', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Update available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: (...a: unknown[]) => apiFetchMock(...a) }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { NodeUpdatesSheet } from '../NodeUpdatesSheet';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import type { NodeUpdateStatus } from '../types';
|
||||
|
||||
const STATUSES: NodeUpdateStatus[] = [
|
||||
@@ -20,6 +24,7 @@ function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesShe
|
||||
checkingUpdates: false,
|
||||
updateStatuses: STATUSES,
|
||||
updatingNodeId: null,
|
||||
isAdmin: true,
|
||||
fetchUpdateStatus: vi.fn(async () => {}),
|
||||
triggerNodeUpdate: vi.fn(),
|
||||
retryNodeUpdate: vi.fn(),
|
||||
@@ -63,4 +68,63 @@ describe('NodeUpdatesSheet', () => {
|
||||
expect(screen.getByText('Edge')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Local')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders every mutating affordance for an admin', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps({ isAdmin: true })} />);
|
||||
expect(screen.getByRole('button', { name: 'Recheck' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Update all/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Update$/ })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Retry update')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toasts when a recheck is throttled by the server (rechecked:false)', async () => {
|
||||
apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ rechecked: false }) });
|
||||
render(<NodeUpdatesSheet {...baseProps({ isAdmin: true })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Recheck' }));
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalled());
|
||||
expect(apiFetchMock).toHaveBeenCalledWith(
|
||||
'/fleet/update-status?recheck=true',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces an error when the recheck fails (non-ok response, and by symmetry a thrown failure)', async () => {
|
||||
// apiFetch only throws on 401/network; HTTP errors land as res.ok === false.
|
||||
// Both the else branch (this case) and the catch branch raise the same
|
||||
// toast.error, so this exercises the user-facing failure toast. The thrown
|
||||
// path is not driven through the click here because this repo's test harness
|
||||
// re-surfaces a rejected Error flowing through a React event handler as a
|
||||
// test failure even when the handler catches it.
|
||||
apiFetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
render(<NodeUpdatesSheet {...baseProps({ isAdmin: true })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Recheck' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not toast when a recheck actually refreshed (rechecked:true)', async () => {
|
||||
const fetchUpdateStatus = vi.fn(async () => {});
|
||||
apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ rechecked: true }) });
|
||||
render(<NodeUpdatesSheet {...baseProps({ isAdmin: true, fetchUpdateStatus })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Recheck' }));
|
||||
await waitFor(() => expect(fetchUpdateStatus).toHaveBeenCalled());
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides every mutating affordance for a non-admin but keeps the read-only table', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps({ isAdmin: false })} />);
|
||||
// Read-only status remains visible
|
||||
expect(screen.getByText('Local')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Db')).toBeInTheDocument();
|
||||
// 'Available' appears once as the summary stat label; for a non-admin the
|
||||
// per-row read-only badge adds a second occurrence in place of the button.
|
||||
expect(screen.getAllByText('Available')).toHaveLength(2);
|
||||
// No mutate controls
|
||||
expect(screen.queryByRole('button', { name: 'Recheck' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Update all/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Retry update')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user