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:
Anso
2026-05-31 20:28:18 -04:00
committed by GitHub
parent 6fc7f200a6
commit d03d97d964
16 changed files with 441 additions and 37 deletions
+5 -1
View File
@@ -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();
+24 -17
View File
@@ -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);
});
});
+1
View File
@@ -22,6 +22,7 @@ export const CAPABILITIES = [
'users',
'registries',
'self-update',
'vulnerability-scanning',
] as const;
export type Capability = (typeof CAPABILITIES)[number];