mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(nodes): close capability-gating gaps in node compatibility (#1261)
* fix(nodes): close capability-gating gaps in node compatibility Vulnerability scanning is now gated correctly on whether the active node advertises support for it: - A node without the Trivy binary stops advertising the scanning capability. Previously the capability was toggled only on a state change, so a node that booted without Trivy kept advertising scanning it could not perform. - The control node's own capability list now reflects features disabled at runtime, matching what it advertises to peers. - The scan history surface shows a clear "not available on this node" card, with its header actions hidden, instead of attempting a request that fails. A node's version and capability metadata now refreshes immediately after a connection test or a completed update, rather than waiting out the cache. Capability gates fail closed to the unavailable card when a node's metadata request errors, instead of staying open until the next fetch. Adds a test that fails if the frontend and backend capability lists drift, plus coverage for the metadata error path, the runtime-disabled local meta, the scanning capability sync, and the metadata cache invalidation paths. * fix(nodes): refresh node metadata client-side after a connection test A connection test dropped the server-side metadata cache, but the dashboard kept its own cached copy until the client TTL expired, so version and capability gates could stay stale in the browser. The test now forces a client-side metadata refresh for that node, so the version pill and gates reflect the node's current state immediately. Also strips any URL userinfo before logging the metadata fetch target, and makes the scanning-capability detection test deterministically exercise the no-binary disable path rather than depending on whether the runner has Trivy.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Guards against frontend/backend capability-registry drift.
|
||||
*
|
||||
* The capability list is maintained as two hand-written constants:
|
||||
* backend/src/services/CapabilityRegistry.ts and frontend/src/lib/capabilities.ts.
|
||||
* They MUST be identical, because the frontend gates features on the flags the
|
||||
* backend advertises. A capability present on only one side either makes a gate
|
||||
* impossible to express (frontend missing it) or advertises a feature the
|
||||
* frontend never gates (backend missing it). This test fails the moment the two
|
||||
* lists diverge so the drift is caught at CI time, not in production.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { CAPABILITIES } from '../services/CapabilityRegistry';
|
||||
|
||||
function extractCapabilities(source: string): string[] {
|
||||
const start = source.indexOf('CAPABILITIES = [');
|
||||
if (start === -1) throw new Error('Could not locate CAPABILITIES array in frontend source');
|
||||
const end = source.indexOf(']', start);
|
||||
if (end === -1) throw new Error('Could not locate end of CAPABILITIES array in frontend source');
|
||||
const block = source.slice(start, end);
|
||||
return [...block.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
describe('capability list parity (frontend <-> backend)', () => {
|
||||
it('frontend lib/capabilities.ts matches backend CapabilityRegistry exactly', () => {
|
||||
const frontendPath = path.resolve(__dirname, '../../../frontend/src/lib/capabilities.ts');
|
||||
const frontendSource = fs.readFileSync(frontendPath, 'utf8');
|
||||
const frontendCaps = extractCapabilities(frontendSource);
|
||||
|
||||
expect([...frontendCaps].sort()).toEqual([...CAPABILITIES].sort());
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
|
||||
const LOOPBACK = 'http://127.0.0.1:54322';
|
||||
|
||||
@@ -233,3 +234,51 @@ describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => {
|
||||
expect(systemUpdateCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () => {
|
||||
// Satisfy getCompareTarget's GitHub lookup with a benign response so the
|
||||
// route does not make a real network call during the test.
|
||||
function mockCompareTargetFetch() {
|
||||
mockFetch(() =>
|
||||
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('drops the cached meta when a node transitions to completed', async () => {
|
||||
mockTargetForPilot();
|
||||
mockCompareTargetFetch();
|
||||
// Remote now reports a different version than before the update (signal 1).
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
||||
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
|
||||
|
||||
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('completed');
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(`remote-meta:${proxyNodeId}`);
|
||||
});
|
||||
|
||||
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
|
||||
mockTargetForPilot();
|
||||
mockCompareTargetFetch();
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
||||
|
||||
// Already completed before this poll: no transition, so no invalidation.
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
tracker.set(proxyNodeId, tracker.create('completed', '0.83.0', null));
|
||||
|
||||
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(invalidateSpy).not.toHaveBeenCalledWith(`remote-meta:${proxyNodeId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Tests for node management API - focusing on api_url validation (SSRF fix C2).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { disableCapability, enableCapability } from '../services/CapabilityRegistry';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -79,6 +82,44 @@ describe('POST /api/nodes - api_url SSRF validation (C2 fix)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/nodes/:id/meta - local meta honors runtime-disabled capabilities', () => {
|
||||
it('omits a capability that has been disabled at runtime', async () => {
|
||||
const list = await request(app).get('/api/nodes').set('Authorization', authHeader);
|
||||
const local = (list.body as Array<{ id: number; type: string }>).find((n) => n.type === 'local');
|
||||
expect(local).toBeTruthy();
|
||||
|
||||
disableCapability('vulnerability-scanning');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get(`/api/nodes/${local!.id}/meta`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.capabilities).toContain('stacks');
|
||||
expect(res.body.capabilities).not.toContain('vulnerability-scanning');
|
||||
} finally {
|
||||
enableCapability('vulnerability-scanning');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/nodes/:id/test - invalidates remote-meta cache', () => {
|
||||
it('drops the cached meta so the next read rebuilds version and capabilities live', async () => {
|
||||
const testSpy = vi
|
||||
.spyOn(NodeRegistry.getInstance(), 'testConnection')
|
||||
.mockResolvedValue({ success: true });
|
||||
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
||||
try {
|
||||
const res = await request(app).post('/api/nodes/7/test').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(testSpy).toHaveBeenCalledWith(7);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith('remote-meta:7');
|
||||
} finally {
|
||||
testSpy.mockRestore();
|
||||
invalidateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack name validation on GET routes (H3 fix)', () => {
|
||||
it('rejects path traversal in GET /api/stacks/:stackName', async () => {
|
||||
const res = await request(app)
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* highest-severity rollup, duplicate scan prevention, and graceful handling
|
||||
* when the binary is not available.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import TrivyService, { parseTrivyOutput } from '../services/TrivyService';
|
||||
import TrivyInstaller from '../services/TrivyInstaller';
|
||||
import { getActiveCapabilities, enableCapability } from '../services/CapabilityRegistry';
|
||||
|
||||
describe('TrivyService', () => {
|
||||
let svc: TrivyService;
|
||||
@@ -15,6 +17,13 @@ describe('TrivyService', () => {
|
||||
svc = TrivyService.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// detectTrivy() toggles a process-global capability flag. Restore the
|
||||
// default (enabled) so this suite cannot leak a disabled state into another
|
||||
// suite sharing the worker.
|
||||
enableCapability('vulnerability-scanning');
|
||||
});
|
||||
|
||||
describe('isTrivyAvailable', () => {
|
||||
it('returns false when binary has not been detected', () => {
|
||||
// Service default state: available=false until initialize() runs
|
||||
@@ -37,6 +46,45 @@ describe('TrivyService', () => {
|
||||
await svc.detectTrivy();
|
||||
expect(svc.getDetectionTimestamp()).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it('keeps the vulnerability-scanning capability in lockstep with detected availability', async () => {
|
||||
// Regression guard: detection used to toggle the capability only on a
|
||||
// state transition, so a process that boots without Trivy (source starts
|
||||
// at 'none', wasAvailable === false) never disabled it and kept
|
||||
// advertising scanning on a node that cannot scan. Start from the enabled
|
||||
// state (the buggy starting point) so that on a Trivy-less runner this
|
||||
// proves the disable branch fired; the advertised capability must equal
|
||||
// the detected availability after every detection.
|
||||
enableCapability('vulnerability-scanning');
|
||||
const result = await svc.detectTrivy();
|
||||
const advertised = getActiveCapabilities().includes('vulnerability-scanning');
|
||||
expect(advertised).toBe(result.available);
|
||||
});
|
||||
|
||||
it('disables the capability from the enabled state when no binary is found (deterministic)', async () => {
|
||||
// Force every detection candidate to miss regardless of the runner: bogus
|
||||
// managed path, bogus TRIVY_BIN, and an emptied PATH so a bare `trivy`
|
||||
// cannot resolve. This reproduces the boot-without-Trivy case the fix
|
||||
// targets and proves the disable branch fires even starting from enabled.
|
||||
const installerSpy = vi
|
||||
.spyOn(TrivyInstaller.getInstance(), 'binaryPath')
|
||||
.mockReturnValue('/nonexistent/managed/trivy');
|
||||
const prevTrivyBin = process.env.TRIVY_BIN;
|
||||
const prevPath = process.env.PATH;
|
||||
process.env.TRIVY_BIN = '/nonexistent/env/trivy';
|
||||
process.env.PATH = '';
|
||||
enableCapability('vulnerability-scanning');
|
||||
try {
|
||||
const result = await svc.detectTrivy();
|
||||
expect(result.available).toBe(false);
|
||||
expect(getActiveCapabilities()).not.toContain('vulnerability-scanning');
|
||||
} finally {
|
||||
installerSpy.mockRestore();
|
||||
if (prevTrivyBin === undefined) delete process.env.TRIVY_BIN;
|
||||
else process.env.TRIVY_BIN = prevTrivyBin;
|
||||
process.env.PATH = prevPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanImage', () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { formatNoTargetError } from '../utils/remoteTarget';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
|
||||
import { containerActionForStack } from './stacks';
|
||||
import { activeBulkActions } from './labels';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
@@ -702,6 +702,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node) => {
|
||||
const tracker = updateTracker.get(node.id);
|
||||
const statusBeforeResolve = tracker?.status;
|
||||
|
||||
let version: string | null = null;
|
||||
let remoteStartedAt: number | null = null;
|
||||
@@ -799,6 +800,17 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
}
|
||||
|
||||
const currentTracker = updateTracker.get(node.id);
|
||||
// A node that just finished updating runs new code with a possibly
|
||||
// different version and capability set. Drop its cached /api/meta on
|
||||
// the completion transition so the dashboard reflects the new state
|
||||
// immediately instead of waiting out the remote-meta TTL.
|
||||
if (
|
||||
node.type === 'remote' &&
|
||||
statusBeforeResolve !== 'completed' &&
|
||||
currentTracker?.status === 'completed'
|
||||
) {
|
||||
invalidateRemoteMetaCache(node.id);
|
||||
}
|
||||
return {
|
||||
nodeId: node.id,
|
||||
name: node.name,
|
||||
|
||||
@@ -9,8 +9,8 @@ import { enrollmentLimiter } from '../middleware/rateLimiters';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import { REMOTE_META_NAMESPACE } from '../helpers/cacheInvalidation';
|
||||
import { CAPABILITIES, getSenchoVersion, type RemoteMeta } from '../services/CapabilityRegistry';
|
||||
import { REMOTE_META_NAMESPACE, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
|
||||
import { getActiveCapabilities, getSenchoVersion, type RemoteMeta } from '../services/CapabilityRegistry';
|
||||
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
||||
import { PilotCloseCode } from '../pilot/protocol';
|
||||
import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer';
|
||||
@@ -476,6 +476,10 @@ nodesRouter.post('/:id/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const result = await NodeRegistry.getInstance().testConnection(id);
|
||||
// An explicit test is the operator asking for fresh truth: drop the cached
|
||||
// /api/meta so the next read rebuilds version and capabilities live rather
|
||||
// than serving a value up to the remote-meta TTL old.
|
||||
invalidateRemoteMetaCache(id);
|
||||
res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Connection test failed';
|
||||
@@ -493,7 +497,7 @@ nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response)
|
||||
}
|
||||
|
||||
if (node.type === 'local') {
|
||||
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
|
||||
res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities() });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import semver from 'semver';
|
||||
import { SENCHO_VERSION } from '../generated/version';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
/**
|
||||
* Static registry of capabilities supported by THIS Sencho instance.
|
||||
@@ -125,23 +126,38 @@ export const OFFLINE_META: RemoteMeta = {
|
||||
online: false,
|
||||
};
|
||||
|
||||
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
|
||||
function redactUrlCredentials(url: string): string {
|
||||
return url.replace(/(\/\/)[^/@]*@/, '$1');
|
||||
}
|
||||
|
||||
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
|
||||
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
|
||||
const safeUrl = redactUrlCredentials(baseUrl);
|
||||
try {
|
||||
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
|
||||
headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
|
||||
timeout: 5000,
|
||||
});
|
||||
const rawVersion: string | undefined = res.data.version;
|
||||
return {
|
||||
const meta: RemoteMeta = {
|
||||
version: isValidVersion(rawVersion) ? rawVersion : null,
|
||||
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
|
||||
startedAt: typeof res.data.startedAt === 'number' ? res.data.startedAt : null,
|
||||
updateError: typeof res.data.updateError === 'string' ? res.data.updateError : null,
|
||||
online: true,
|
||||
};
|
||||
if (isDebugEnabled()) {
|
||||
// Diagnostic aid for "why is this feature gated?": log the resolved version
|
||||
// and capability count (not the full list) at the one boundary that decides
|
||||
// gating. The URL is logged with any userinfo credentials stripped.
|
||||
console.log(
|
||||
`[CapabilityRegistry:diag] meta ok from ${safeUrl}: version=${meta.version ?? 'null'} capabilities=${meta.capabilities.length}`,
|
||||
);
|
||||
}
|
||||
return meta;
|
||||
} catch (err) {
|
||||
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message);
|
||||
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${safeUrl}:`, (err as Error).message);
|
||||
return { ...OFFLINE_META };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,13 +409,20 @@ class TrivyService {
|
||||
this.detectionTimestamp - started
|
||||
}`,
|
||||
);
|
||||
if (isAvailable && !wasAvailable) {
|
||||
// Sync the capability unconditionally so a node that boots without Trivy
|
||||
// (the common case) stops advertising vulnerability-scanning. A transition-only
|
||||
// toggle missed this: source starts at 'none', so wasAvailable is false on the
|
||||
// first detection and the disable branch never fired. Set add/delete is idempotent.
|
||||
if (isAvailable) {
|
||||
enableCapability('vulnerability-scanning');
|
||||
} else {
|
||||
disableCapability('vulnerability-scanning');
|
||||
}
|
||||
if (isAvailable && !wasAvailable) {
|
||||
console.log(
|
||||
`[Trivy] Binary detected (source=${this.source}); vulnerability scanning enabled (version ${this.version})`,
|
||||
);
|
||||
} else if (!isAvailable && wasAvailable) {
|
||||
disableCapability('vulnerability-scanning');
|
||||
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled');
|
||||
}
|
||||
return { available: isAvailable, version: this.version, source: this.source };
|
||||
|
||||
@@ -7,7 +7,7 @@ When you manage [multiple nodes](/features/multi-node), each running its own Sen
|
||||
|
||||
## How it works
|
||||
|
||||
Every Sencho instance exposes a public `GET /api/meta` endpoint that advertises its version and a list of **capabilities**, one flag per feature it implements. When you switch to a node, your control instance fetches that node's metadata and caches it for 5 minutes on success or 30 seconds on failure, so the version pill and the per-feature gates stay in sync without re-hitting the remote on every navigation.
|
||||
Every Sencho instance exposes a public `GET /api/meta` endpoint that advertises its version and a list of **capabilities**, one flag per feature it implements. When you switch to a node, your control instance fetches that node's metadata and caches it (briefly on the server, and for 5 minutes on success or 30 seconds on failure in the dashboard), so the version pill and the per-feature gates stay in sync without re-hitting the remote on every navigation. Testing a node from **Settings · Nodes**, or a node finishing an update, refreshes its metadata right away rather than waiting for the cache to expire.
|
||||
|
||||
Features whose capability is missing from the active node's list are replaced by a lock card naming the missing feature and prompting you to upgrade the node. Core features (stack management, containers, resources, logs) work on every Sencho version and are never gated this way.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function NodeManager() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const canEditLabels = isPaid && isAdmin;
|
||||
const { nodes } = useNodes();
|
||||
const { nodes, refreshNodeMeta } = useNodes();
|
||||
useMastheadStats([
|
||||
{ label: 'NODES', value: `${nodes.length}` },
|
||||
{
|
||||
@@ -128,6 +128,10 @@ export function NodeManager() {
|
||||
if (result.success) {
|
||||
toast.success(`Connected to "${node.name}" successfully`);
|
||||
setTestResult({ nodeId: node.id, info: result.info });
|
||||
// The test dropped the server-side metadata cache; force a client refresh
|
||||
// so the version pill and capability gates reflect the node's current state
|
||||
// now, instead of waiting out the dashboard's metadata TTL.
|
||||
void refreshNodeMeta(node.id, true);
|
||||
} else {
|
||||
toast.error(`Failed to connect: ${result.error}`);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ScanComparisonSheet } from './ScanComparisonSheet';
|
||||
import { SeverityChip } from './VulnerabilityScanSheet';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -62,7 +63,8 @@ interface SecurityHistoryViewProps {
|
||||
export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const { activeNode, hasCapability } = useNodes();
|
||||
const scanningAvailable = hasCapability('vulnerability-scanning');
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [capInfo, setCapInfo] = useState<{ perImageLimit: number; refs: Set<string> } | null>(null);
|
||||
@@ -112,11 +114,11 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
}, [activeNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || !scanningAvailable) return;
|
||||
load(page, search);
|
||||
// reloadToken bumps when the active node changes even if page/search
|
||||
// happen to match the previous values, so the fetch re-runs exactly once.
|
||||
}, [open, load, page, search, reloadToken]);
|
||||
}, [open, scanningAvailable, load, page, search, reloadToken]);
|
||||
|
||||
// Skip the initial mount: the effect fires once with the original
|
||||
// searchDraft, and unconditionally resetting page to 0 after 300ms races
|
||||
@@ -167,21 +169,22 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
crumb={['Security', 'Scan history']}
|
||||
name="Scan history"
|
||||
meta={meta}
|
||||
primaryAction={{
|
||||
primaryAction={scanningAvailable ? {
|
||||
label: `Compare (${selected.length}/2)`,
|
||||
icon: GitCompare,
|
||||
onClick: compareSelected,
|
||||
disabled: compareDisabled,
|
||||
}}
|
||||
secondaryActions={[{
|
||||
} : undefined}
|
||||
secondaryActions={scanningAvailable ? [{
|
||||
label: 'Refresh',
|
||||
icon: RefreshCw,
|
||||
onClick: () => load(safePage, search),
|
||||
disabled: loading,
|
||||
}]}
|
||||
}] : []}
|
||||
footerContext={footerContext}
|
||||
size="xl"
|
||||
>
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<SheetSection title="Scans" hideHeader>
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
@@ -324,6 +327,7 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetSection>
|
||||
</CapabilityGate>
|
||||
|
||||
<ScanComparisonSheet
|
||||
baselineScanId={compareIds?.[0] ?? null}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
|
||||
const nodes = {
|
||||
hasCapability: (c: string) => c !== 'vulnerability-scanning',
|
||||
activeNode: { id: 1, name: 'node-x' },
|
||||
activeNodeMeta: { version: '0.80.0', capabilities: [] as string[], fetchedAt: 0 },
|
||||
};
|
||||
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => nodes,
|
||||
}));
|
||||
|
||||
describe('CapabilityGate', () => {
|
||||
it('renders a lock card (not the children) when the node lacks the capability', () => {
|
||||
render(
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<div>gated-content</div>
|
||||
</CapabilityGate>,
|
||||
);
|
||||
expect(screen.queryByText('gated-content')).toBeNull();
|
||||
expect(
|
||||
screen.getByText('Vulnerability scanning is not available on this node'),
|
||||
).toBeInTheDocument();
|
||||
// The version hint names the running version so the operator knows what to upgrade.
|
||||
expect(screen.getByText(/node-x is running v0\.80\.0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when the node advertises the capability', () => {
|
||||
render(
|
||||
<CapabilityGate capability="stacks" featureName="Stacks">
|
||||
<div>gated-content</div>
|
||||
</CapabilityGate>,
|
||||
);
|
||||
expect(screen.getByText('gated-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,15 @@ vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
|
||||
const nodesState: {
|
||||
activeNode: { id: number; name?: string } | null;
|
||||
hasCapability: (cap: string) => boolean;
|
||||
activeNodeMeta: { version: string | null; capabilities: string[]; fetchedAt: number } | null;
|
||||
} = {
|
||||
activeNode: { id: 1 },
|
||||
hasCapability: () => true,
|
||||
activeNodeMeta: null,
|
||||
};
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => nodesState,
|
||||
}));
|
||||
@@ -107,6 +115,8 @@ beforeEach(() => {
|
||||
compareProps.length = 0;
|
||||
licenseState.isPaid = true;
|
||||
nodesState.activeNode = { id: 1 };
|
||||
nodesState.hasCapability = () => true;
|
||||
nodesState.activeNodeMeta = null;
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -123,6 +133,23 @@ describe('SecurityHistoryView', () => {
|
||||
expect(url).toMatch(/limit=\d+/);
|
||||
});
|
||||
|
||||
it('shows a lock card and does not fetch when the node lacks vulnerability-scanning', async () => {
|
||||
nodesState.hasCapability = (cap: string) => cap !== 'vulnerability-scanning';
|
||||
nodesState.activeNodeMeta = { version: '0.80.0', capabilities: [], fetchedAt: 0 };
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
|
||||
render(<SecurityHistoryView open onClose={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Vulnerability scanning is not available on this node'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
// The header actions are gone too, so there is no Refresh button that could
|
||||
// fire the gated fetch from behind the lock card.
|
||||
expect(screen.queryByRole('button', { name: /refresh/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /compare/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('advances offset when the user pages forward', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()], { total: 250 }));
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -34,7 +34,7 @@ interface NodeContextType {
|
||||
activeNodeMeta: NodeMeta | null;
|
||||
hasCapability: (cap: Capability) => boolean;
|
||||
nodeMeta: Map<number, NodeMeta>;
|
||||
refreshNodeMeta: (nodeId: number) => Promise<void>;
|
||||
refreshNodeMeta: (nodeId: number, force?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const META_CACHE_TTL = 5 * 60 * 1000;
|
||||
@@ -54,34 +54,41 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
||||
const nodeMetaRef = useRef<Map<number, NodeMeta>>(nodeMeta);
|
||||
nodeMetaRef.current = nodeMeta;
|
||||
|
||||
const fetchNodeMeta = useCallback(async (nodeId: number) => {
|
||||
const fetchNodeMeta = useCallback(async (nodeId: number, force = false) => {
|
||||
const cached = nodeMetaRef.current.get(nodeId);
|
||||
if (cached) {
|
||||
// Use shorter TTL for failed fetches so we retry quickly after transient errors
|
||||
if (cached && !force) {
|
||||
// Use shorter TTL for failed fetches so we retry quickly after transient errors.
|
||||
// `force` bypasses the TTL after an explicit user action (e.g. a connection test)
|
||||
// that just dropped the server-side cache, so the dashboard reflects the new
|
||||
// version and capabilities without waiting out the TTL.
|
||||
const ttl = cached.capabilities.length > 0 ? META_CACHE_TTL : META_FAILURE_TTL;
|
||||
if (Date.now() - cached.fetchedAt < ttl) return;
|
||||
}
|
||||
|
||||
const setMeta = (meta: NodeMeta) =>
|
||||
setNodeMeta(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(nodeId, meta);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/nodes/${nodeId}/meta`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodeMeta(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(nodeId, {
|
||||
version: data.version ?? null,
|
||||
capabilities: Array.isArray(data.capabilities) ? data.capabilities : [],
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
setMeta({
|
||||
version: data.version ?? null,
|
||||
capabilities: Array.isArray(data.capabilities) ? data.capabilities : [],
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// A non-OK response (proxy error, auth, 5xx) is a resolved failure: record an
|
||||
// offline meta so gates fail closed to the lock card and the short failure TTL
|
||||
// throttles retries, instead of leaving hasCapability optimistically open.
|
||||
setMeta({ version: null, capabilities: [], fetchedAt: Date.now() });
|
||||
}
|
||||
} catch {
|
||||
setNodeMeta(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(nodeId, { version: null, capabilities: [], fetchedAt: Date.now() });
|
||||
return next;
|
||||
});
|
||||
setMeta({ version: null, capabilities: [], fetchedAt: Date.now() });
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { NodeProvider, useNodes } from '../NodeContext';
|
||||
|
||||
const apiFetch = vi.fn();
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts),
|
||||
}));
|
||||
|
||||
const LOCAL_NODE = {
|
||||
id: 1,
|
||||
name: 'local',
|
||||
type: 'local' as const,
|
||||
compose_dir: '/compose',
|
||||
is_default: true,
|
||||
status: 'online' as const,
|
||||
created_at: 0,
|
||||
};
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <NodeProvider>{children}</NodeProvider>;
|
||||
}
|
||||
|
||||
describe('NodeContext meta fetch error handling', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
apiFetch.mockReset();
|
||||
});
|
||||
|
||||
it('records an offline meta on a non-OK meta response so gates fail closed', async () => {
|
||||
// The meta endpoint resolves with a non-OK status (proxy error / 5xx).
|
||||
// Previously this wrote no record and hasCapability stayed optimistically
|
||||
// true forever; now it must write an offline record so gates lock.
|
||||
apiFetch.mockImplementation((path: string) => {
|
||||
if (path === '/nodes') {
|
||||
return Promise.resolve({ ok: true, json: async () => [LOCAL_NODE] });
|
||||
}
|
||||
if (path.startsWith('/nodes/1/meta')) {
|
||||
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodes(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.activeNode?.id).toBe(1));
|
||||
// After the failed meta resolves, the offline record drives hasCapability to false.
|
||||
await waitFor(() => expect(result.current.hasCapability('stacks')).toBe(false));
|
||||
expect(result.current.activeNodeMeta).toEqual({
|
||||
version: null,
|
||||
capabilities: [],
|
||||
fetchedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('records advertised capabilities on an OK meta response', async () => {
|
||||
apiFetch.mockImplementation((path: string) => {
|
||||
if (path === '/nodes') {
|
||||
return Promise.resolve({ ok: true, json: async () => [LOCAL_NODE] });
|
||||
}
|
||||
if (path.startsWith('/nodes/1/meta')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.86.6', capabilities: ['stacks', 'fleet'] }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodes(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.activeNode?.id).toBe(1));
|
||||
await waitFor(() => expect(result.current.hasCapability('fleet')).toBe(true));
|
||||
expect(result.current.hasCapability('vulnerability-scanning')).toBe(false);
|
||||
});
|
||||
|
||||
it('force-refresh bypasses the meta TTL so a connection test reflects immediately', async () => {
|
||||
apiFetch.mockImplementation((path: string) => {
|
||||
if (path === '/nodes') {
|
||||
return Promise.resolve({ ok: true, json: async () => [LOCAL_NODE] });
|
||||
}
|
||||
if (path.startsWith('/nodes/1/meta')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.86.6', capabilities: ['stacks'] }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodes(), { wrapper });
|
||||
await waitFor(() => expect(result.current.hasCapability('stacks')).toBe(true));
|
||||
|
||||
const metaCalls = () =>
|
||||
apiFetch.mock.calls.filter((c) => String(c[0]).startsWith('/nodes/1/meta')).length;
|
||||
const before = metaCalls();
|
||||
|
||||
// A normal refresh within the TTL is a no-op (cached).
|
||||
await act(async () => {
|
||||
await result.current.refreshNodeMeta(1);
|
||||
});
|
||||
expect(metaCalls()).toBe(before);
|
||||
|
||||
// A forced refresh (post connection test) re-fetches despite the TTL.
|
||||
await act(async () => {
|
||||
await result.current.refreshNodeMeta(1, true);
|
||||
});
|
||||
expect(metaCalls()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export const CAPABILITIES = [
|
||||
'users',
|
||||
'registries',
|
||||
'self-update',
|
||||
'vulnerability-scanning',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
Reference in New Issue
Block a user