Files
sencho/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx
T
Anso 8980910153 feat: node-scoped Networking operator page (#1603)
* feat: add node-scoped Networking operator page

Adds a Networking view with overview, topology, inventory, and findings.

Shared aggregate reads back the page; Resources keeps prune and redirects here.

Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing.

* fix: rename unused variable n to _n to satisfy no-unused-vars lint

* fix: keep top bar search clickable when nav grows

* feat: complete Compose-first Networking Phase 2 operator assistant

* fix: move networking action visibility helper out of component module

* feat(networking): complete Compose-first Networking operator page

Finish the node-scoped Networking page (Overview, Networks, Topology,
Findings) with design-system parity and correct finding semantics.

- Rebuild detail sheets on SystemSheet/SheetSection; align the tab band,
  masthead, and mobile tone with Fleet and Security.
- Encode the host-mode and exposure severity matrix; fix collision counts
  so intentional shared externals are not flagged; add one typed drift
  predicate shared by inventory, topology, badges, and overview counts.
- Preserve per-container attachments and IPs on topology node clicks;
  drawer-only click with an explicit logs action; ownership and boolean
  filters; bound large graphs before layout.
- Aggregate cached Compose Doctor findings into the Findings tab with
  honest source labels, structural merge and dedupe, staleness
  reconciliation, and a shared exposure-context helper both engines use.
- Networks tab: privacy-safe service search, precise ownership counts,
  schema v3 with version-2 adapters on every endpoint, pre-confirm delete
  reasons, and the shared sortable table with an internal scroll region.
- Interop: Fleet node-card networking signal with pending-intent
  navigation, stack-to-node backlink, and Dossier/Drift deep links.
- Enrich sanitized inspect with an allowlisted connected-container list;
  fetch topology once and filter client-side.
- Docs and tests across every new finding kind, adapter, and flow.

* fix(networking): correct drift count, exposure fail-soft, and inspect crash paths

Address code-review findings on the Networking page implementation:
- Fix the Overview drift count to use the shared drift-kind predicate instead
  of a hardcoded list that omitted external-network-missing.
- Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented
  rules on exposure-context availability, so a DB read failure no longer
  fabricates findings (mirrors the live engine's existing fail-soft behavior).
- Guard the per-stack exposure-intent read in topology aggregation so a
  transient DB failure degrades to unknown intent instead of failing the
  whole response.
- Harden the network detail drawer against a partial inspect payload from an
  older remote node, and log the real error instead of a bare catch.
- Remove now-duplicated severity-rank and drift-kind helpers in favor of the
  shared modules; drop dead backend-only exports; widen the frontend schema
  version type to a plain number instead of casting past a literal type.
- Add coverage for the delete-guard precedence, the full host-mode severity
  matrix, the schema-2 compatibility adapter, and the sanitized connected-
  container allowlist; tighten two tests that were not exercising the
  behavior they claimed to.

* fix: add missing onOpenNodeNetworking prop to FleetView experimental test

The added required prop on FleetViewProps broke the merge-build when the
test file (on main but not on this branch) was compiled against the
updated FleetView interface.
2026-07-14 20:18:10 -04:00

159 lines
6.9 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const useLicenseMock = vi.fn();
const useAuthMock = vi.fn();
const useNodesMock = vi.fn();
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/lib/nodesApi', () => ({ cordonNode: vi.fn(), uncordonNode: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { NodeCard } from '../NodeCard';
import type { FleetNode } from '../types';
function onlineNode(): FleetNode {
return {
id: 2, name: 'Edge', type: 'remote', status: 'online',
stats: { active: 3, managed: 3, unmanaged: 0, exited: 1, total: 4 },
systemStats: { cpu: { usage: '20.0', cores: 4 }, memory: { total: 100, used: 40, free: 60, usagePercent: '40.0' }, disk: { total: 100, used: 30, free: 70, usagePercent: '30.0' } },
stacks: ['web'], cordoned: false, cordoned_at: null, cordoned_reason: null,
};
}
function offlineNode(): FleetNode {
return { ...onlineNode(), status: 'offline', stats: null, systemStats: null, stacks: null };
}
function baseProps(node: FleetNode) {
return { node, onNavigate: vi.fn() };
}
beforeEach(() => {
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
useLicenseMock.mockReturnValue({ isPaid: false });
});
afterEach(() => vi.clearAllMocks());
describe('NodeCard', () => {
it('renders stats and the online badge for an online node', () => {
render(<NodeCard {...baseProps(onlineNode())} />);
expect(screen.getByText('Online')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.queryByText('Node unreachable')).not.toBeInTheDocument();
});
it('shows the unreachable placeholder and hides stats for an offline node', () => {
render(<NodeCard {...baseProps(offlineNode())} />);
expect(screen.getByText('Offline')).toBeInTheDocument();
expect(screen.getByText('Node unreachable')).toBeInTheDocument();
expect(screen.queryByText('Running')).not.toBeInTheDocument();
});
it('hides the actions menu for a free-tier user', () => {
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps(onlineNode())} />);
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('hides the actions menu for a Community admin with node:manage when cordon requires Admiral', () => {
useLicenseMock.mockReturnValue({ isPaid: false });
render(<NodeCard {...baseProps(onlineNode())} />);
// Cordon is Admiral-only; without edit/delete props, no menu items are available to Community.
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('exposes the actions menu (cordon entry point) for a paid admin', () => {
useLicenseMock.mockReturnValue({ isPaid: true });
render(<NodeCard {...baseProps(onlineNode())} />);
// With no edit/delete affordances wired, the menu renders iff cordon is
// allowed: isPaid && can('node:manage'). The admin's can() returns true.
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
});
it('exposes the cordon control for an Admiral node-admin via the node:manage permission', async () => {
const can = vi.fn((action: string) => action === 'node:manage');
useAuthMock.mockReturnValue({ isAdmin: false, can });
useLicenseMock.mockReturnValue({ isPaid: true });
render(<NodeCard {...baseProps(onlineNode())} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
expect(await screen.findByText('Cordon node')).toBeInTheDocument();
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
});
it('hides the cordon control from a paid user lacking node:manage', () => {
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
useLicenseMock.mockReturnValue({ isPaid: true });
render(<NodeCard {...baseProps(onlineNode())} />);
// The paid tier alone must not surface cordon to a deployer/viewer/auditor.
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('shows Uncordon when the node is already cordoned', async () => {
const can = vi.fn((action: string) => action === 'node:manage');
useAuthMock.mockReturnValue({ isAdmin: false, can });
useLicenseMock.mockReturnValue({ isPaid: true });
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
expect(await screen.findByText('Uncordon node')).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 and shows Pinned when updateBlocked', () => {
useAuthMock.mockReturnValue({ isAdmin: true });
render(
<NodeCard
{...baseProps(onlineNode())}
updateStatus={{ ...updateAvailableStatus, updateBlocked: true, updateBlockedReason: 'Digest pin.' }}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText('Pinned')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
});
it('shows the networking signal badge and switches to the node on click', async () => {
const onOpenNetworking = vi.fn();
const user = userEvent.setup();
render(
<NodeCard
{...baseProps(onlineNode())}
onOpenNetworking={onOpenNetworking}
networkingSignal={{ exposed: false, unknown: false, drift: true }}
/>,
);
const badge = screen.getByText(/Networking/);
expect(badge).toBeInTheDocument();
await user.click(badge);
expect(onOpenNetworking).toHaveBeenCalledWith(2);
});
it('hides the networking signal badge when there is nothing to flag', () => {
render(
<NodeCard
{...baseProps(onlineNode())}
onOpenNetworking={vi.fn()}
networkingSignal={{ exposed: false, unknown: false, drift: false }}
/>,
);
expect(screen.queryByText(/Networking/)).not.toBeInTheDocument();
});
});