mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
8980910153
* 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.
159 lines
6.9 KiB
TypeScript
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();
|
|
});
|
|
});
|