mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat: graduate Host Console to Community admins (#1669)
* feat: graduate Host Console to Community admins Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell. * docs: document Host Console deep links Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests. * fix: bind Host Console socket to the resolved node Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution. * fix: harden Host Console node binding, audit acting_as, and console_session tokens Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed. * test: expect acting_as in audit CSV export header Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { Suspense, lazy, type ReactNode } from 'react';
|
||||
import { Unplug } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { resolveHostConsoleCapability } from '@/lib/routing/hostConsoleCapability';
|
||||
import { LockCard } from '../ui/LockCard';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
@@ -17,7 +20,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
import { isStackEditorDeepLink, isHostConsoleStackDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
@@ -147,7 +150,8 @@ export function ViewRouter({
|
||||
quickLinkCandidates,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
const { isPaid, licenseReady } = useLicense();
|
||||
const { activeNode, activeNodeMeta } = useNodes();
|
||||
if (activeView === 'settings') {
|
||||
return (
|
||||
<SettingsPage
|
||||
@@ -184,20 +188,48 @@ export function ViewRouter({
|
||||
);
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
// Discovery + paid/RBAC: hide until experimental discovery is on,
|
||||
// then mirror backend gates (system:console admin-only + PaidGate +
|
||||
// capability). Nav is already gated the same way; this stops a
|
||||
// deep link from mounting a console the operator cannot use.
|
||||
if (!experimentalReady || !experimental) return null;
|
||||
// RBAC + mixed-version capability. Wait for a resolved active node and
|
||||
// remote meta; null activeNode must not be treated as local (wrong-node
|
||||
// or doomed WebSocket). Stack deep links hydrate selectedFile async:
|
||||
// wait so we never open a compose-root shell, then reconnect into the stack.
|
||||
if (!can('system:console')) return null;
|
||||
if (urlHydratingStack != null || (isHostConsoleStackDeepLink() && !selectedFile)) {
|
||||
return <ViewSkeleton />;
|
||||
}
|
||||
if (activeNode == null) return <ViewSkeleton />;
|
||||
const capState = resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: activeNode.type === 'remote',
|
||||
isPaid,
|
||||
licenseReady,
|
||||
activeNodeMeta,
|
||||
});
|
||||
if (capState === 'loading') return <ViewSkeleton />;
|
||||
if (capState === 'locked') {
|
||||
const nodeName = activeNode.name;
|
||||
const version = activeNodeMeta?.version;
|
||||
let versionHint = `${nodeName} does not advertise this capability.`;
|
||||
if (version && version !== 'unknown' && version !== '0.0.0-dev') {
|
||||
versionHint = `${nodeName} is running v${version}.`;
|
||||
}
|
||||
return (
|
||||
<LockCard
|
||||
icon={Unplug}
|
||||
title="Host Console is not available on this node"
|
||||
body={`${versionHint} Upgrade the node to use this feature.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const nodeId = activeNode.id;
|
||||
return (
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
<LazyView>
|
||||
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</PaidGate>
|
||||
<LazyView>
|
||||
<HostConsole
|
||||
key={`${nodeId}:${selectedFile ?? ''}`}
|
||||
nodeId={nodeId}
|
||||
stackName={selectedFile}
|
||||
onClose={onHostConsoleClose}
|
||||
/>
|
||||
</LazyView>
|
||||
);
|
||||
}
|
||||
// Stack workspace: keep a loading shell while the stack URL hydrates.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import * as AuthContext from '@/context/AuthContext';
|
||||
import * as LicenseContext from '@/context/LicenseContext';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import { ViewRouter } from '../ViewRouter';
|
||||
|
||||
vi.mock('@/context/AuthContext');
|
||||
vi.mock('@/context/LicenseContext');
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
vi.mock('../../HostConsole', () => ({
|
||||
default: ({ nodeId, stackName }: { nodeId: number; stackName?: string | null }) => (
|
||||
<div
|
||||
data-testid="host-console"
|
||||
data-node-id={String(nodeId)}
|
||||
data-stack={stackName ?? ''}
|
||||
>
|
||||
Host Console
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../LazyBoundary', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
activeView: 'host-console' as const,
|
||||
selectedFile: null as string | null,
|
||||
isLoading: false,
|
||||
settingsSection: 'appearance' as const,
|
||||
onSettingsSectionChange: vi.fn(),
|
||||
onTemplateDeploySuccess: vi.fn(),
|
||||
onHostConsoleClose: vi.fn(),
|
||||
onFleetNavigateToNode: vi.fn(),
|
||||
onOpenNodeNetworking: vi.fn(),
|
||||
filterNodeId: null,
|
||||
onClearScheduledOpsFilter: vi.fn(),
|
||||
schedulePrefill: null,
|
||||
onPrefillConsumed: vi.fn(),
|
||||
muteRulePrefill: null,
|
||||
onMutePrefillConsumed: vi.fn(),
|
||||
notifications: [] as [],
|
||||
onNavigateToStack: vi.fn(),
|
||||
onOpenSettingsSection: vi.fn(),
|
||||
onClearNotifications: vi.fn(),
|
||||
securityTab: 'overview' as const,
|
||||
onSecurityTabChange: vi.fn(),
|
||||
renderEditor: () => null,
|
||||
stackUpdates: {},
|
||||
urlHydratingStack: null as string | null,
|
||||
isFileLoading: false,
|
||||
quickLinkCandidates: [],
|
||||
};
|
||||
|
||||
describe('ViewRouter host-console', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/nodes/local/host-console');
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
can: (p: string) => p === 'system:console',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
activeNodeMeta: { version: '0.96.0', capabilities: ['host-console', 'host-console-community'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('renders Host Console for a Community admin on the local node', async () => {
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
const el = await screen.findByTestId('host-console');
|
||||
expect(el.getAttribute('data-node-id')).toBe('1');
|
||||
});
|
||||
|
||||
it('does not mount HostConsole while the active node is unresolved', () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: null,
|
||||
activeNodeMeta: null,
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount HostConsole while a stack deep link is still hydrating', () => {
|
||||
render(<ViewRouter {...baseProps} urlHydratingStack="radarr" selectedFile={null} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount a root shell while the URL targets a stack-scoped Console', () => {
|
||||
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
|
||||
render(<ViewRouter {...baseProps} selectedFile={null} urlHydratingStack={null} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts stack-scoped Console only after selectedFile matches the route', async () => {
|
||||
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
|
||||
render(<ViewRouter {...baseProps} selectedFile="radarr" />);
|
||||
const el = await screen.findByTestId('host-console');
|
||||
expect(el.getAttribute('data-stack')).toBe('radarr');
|
||||
expect(el.getAttribute('data-node-id')).toBe('1');
|
||||
});
|
||||
|
||||
it('renders nothing without system:console', () => {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
can: () => false,
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
const { container } = render(<ViewRouter {...baseProps} />);
|
||||
expect(container.querySelector('[data-testid="host-console"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a skeleton while remote metadata is loading (does not mount HostConsole)', () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: null,
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a lock card for Community + legacy remote without mounting HostConsole', () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
expect(screen.getByText(/Host Console is not available on this node/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('mounts Host Console for Admiral + legacy remote after meta resolves', async () => {
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: true,
|
||||
licenseReady: true,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
const el = await screen.findByTestId('host-console');
|
||||
expect(el.getAttribute('data-node-id')).toBe('2');
|
||||
});
|
||||
|
||||
it('shows a skeleton for legacy-only remote while license is still loading', () => {
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: false,
|
||||
licenseReady: false,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
expect(screen.queryByText(/Host Console is not available on this node/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts Host Console for Community + host-console-community remote', async () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 3, name: 'NewPeer', type: 'remote' },
|
||||
activeNodeMeta: {
|
||||
version: '0.96.0',
|
||||
capabilities: ['host-console', 'host-console-community'],
|
||||
fetchedAt: 1,
|
||||
},
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(await screen.findByTestId('host-console')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ function mockDeployer() {
|
||||
function mockPaidAdmin() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
isAdmin: true,
|
||||
can: (p: string) => p === 'system:audit' || p === 'node:read',
|
||||
can: (p: string) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
|
||||
permissionsStatus: 'ready',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
@@ -63,7 +63,7 @@ function mockPaidAdmin() {
|
||||
function mockCommunityAdmin() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
isAdmin: true,
|
||||
can: (p: string) => p === 'node:read',
|
||||
can: (p: string) => p === 'system:console' || p === 'node:read',
|
||||
permissionsStatus: 'ready',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
@@ -269,13 +269,13 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
|
||||
});
|
||||
|
||||
it('shows Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
|
||||
it('shows Update, Schedules, and Console for a community admin; Audit stays paid', () => {
|
||||
mockCommunityAdmin();
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).toContain('auto-updates');
|
||||
expect(values).toContain('scheduled-ops');
|
||||
expect(values).not.toContain('host-console');
|
||||
expect(values).toContain('host-console');
|
||||
expect(values).not.toContain('audit-log');
|
||||
// The auto-updates nav item surfaces under the short label "Update".
|
||||
expect(result.current.navItems.find(i => i.value === 'auto-updates')?.label).toBe('Update');
|
||||
@@ -429,53 +429,29 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.securityTab).toBe('overview');
|
||||
});
|
||||
|
||||
// ── experimental discovery ─────────────────────────────────────────────────
|
||||
// ── host-console discovery (no longer experimental) ────────────────────────
|
||||
|
||||
it('hides Console from nav for a paid admin when experimental discovery is off', () => {
|
||||
it('keeps Console in nav for a paid admin when experimental discovery is off', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
|
||||
});
|
||||
|
||||
it('hides Console from nav while experimental metadata is still loading', () => {
|
||||
it('keeps Console in nav while experimental metadata is still loading', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
|
||||
});
|
||||
|
||||
it('does not normalize a host-console deep link before experimental readiness', () => {
|
||||
it('keeps a host-console deep link selected when experimental is off', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
expect(result.current.activeView).toBe('host-console');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps host-console selected when delayed experimental resolves enabled', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
||||
rerender();
|
||||
expect(result.current.activeView).toBe('host-console');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes host-console once when experimental resolves disabled', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
rerender();
|
||||
expect(result.current.activeView).toBe('dashboard');
|
||||
expect(onNavigateToDashboard).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user