diff --git a/backend/src/__tests__/hub-only-guard.test.ts b/backend/src/__tests__/hub-only-guard.test.ts new file mode 100644 index 00000000..e236b051 --- /dev/null +++ b/backend/src/__tests__/hub-only-guard.test.ts @@ -0,0 +1,101 @@ +/** + * Regression guard for `hubOnlyGuard` middleware. + * + * Hub-only paths (e.g. /api/scheduled-tasks, /api/audit-log, + * /api/notification-routes) manage state owned by the local hub. When a + * request carries `x-node-id` for a remote node, the guard must reject + * with 409 before the remote proxy forwards it. Without this guard, a + * scripted client could trick the proxy into running hub-level operations + * on a remote instance, crossing a node-authority boundary that the UI + * promises will not happen. + * + * The guard sits between `enforceApiTokenScope` (step 12) and + * `createRemoteProxyMiddleware` (step 14). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +describe('hubOnlyGuard', () => { + let tmpDir: string; + let app: import('express').Express; + let authHeader: string; + let remoteNodeId: number; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + + const { DatabaseService } = await import('../services/DatabaseService'); + remoteNodeId = DatabaseService.getInstance().addNode({ + name: 'hub-only-remote', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://127.0.0.1:1', + api_token: 'hub-only-token', + }); + + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; + }); + + afterAll(() => { + cleanupTestDb(tmpDir); + }); + + it('rejects /api/scheduled-tasks with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/scheduled-tasks/') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + expect(res.body?.error).toMatch(/hub-only/i); + }); + + it('rejects /api/audit-log with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/audit-log/') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/notification-routes with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/notification-routes/') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('lets /api/scheduled-tasks through to the local handler when no nodeId is set', async () => { + const res = await request(app) + .get('/api/scheduled-tasks/') + .set('Authorization', authHeader); + + // Anything other than 403 with HUB_ONLY_ENDPOINT proves the guard fell + // through. Local handler may return 200 or a tier-gate 403; both are + // acceptable here, so assert on the body code rather than the status. + expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT'); + }); + + it('does not interfere with non-hub paths even when nodeId targets a remote node', async () => { + // /api/stacks is not hub-only and should be forwarded by the proxy. + // The exact upstream-error status is not the contract here; what + // matters is that the guard did not reject with HUB_ONLY_ENDPOINT. + const res = await request(app) + .get('/api/stacks') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT'); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 47cc90ba..808354a9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,7 @@ import './types/express'; /** * Build an Express app with the full middleware pipeline installed. * - * Canonical middleware order (16 steps). Do not reorder without re-running the + * Canonical middleware order (17 steps). Do not reorder without re-running the * regression checklist in `docs/internal/architecture/middleware-order.md`. * * 1. trust proxy @@ -26,12 +26,13 @@ import './types/express'; * 10. authGate (at /api) -- registered in index.ts * 11. auditLog (at /api) -- registered in index.ts * 12. enforceApiTokenScope (at /api) -- registered in index.ts - * 13. createRemoteProxyMiddleware -- proxy/remoteNodeProxy.ts, registered in index.ts - * 14. routes -- registered in index.ts from routes/* - * 15. static serving + SPA fallback -- registered in index.ts - * 16. errorHandler -- registered in index.ts + * 13. hubOnlyGuard (at /api) -- middleware/hubOnlyGuard.ts, registered in index.ts + * 14. createRemoteProxyMiddleware -- proxy/remoteNodeProxy.ts, registered in index.ts + * 15. routes -- registered in index.ts from routes/* + * 16. static serving + SPA fallback -- registered in index.ts + * 17. errorHandler -- registered in index.ts * - * Steps 10 to 12 and 14 must run after the public auth routers (meta, auth, + * Steps 10 to 13 and 15 must run after the public auth routers (meta, auth, * mfa, sso) are registered so those routes stay reachable without a session * cookie. index.ts mounts those public routers before step 10 to preserve * that invariant. diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 53d9b335..4e7db70c 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -22,3 +22,28 @@ export function isProxyExemptPath(path: string): boolean { } return false; } + +// Path prefixes that are hub-only: they manage state owned by the local hub +// (centralized audit, fleet schedules, notification routing rules). Routed +// to the local hub when nodeId resolves to local, but rejected with 409 when +// nodeId resolves to a remote node so a script/curl call cannot trick the +// proxy into forwarding hub-only authority across a node boundary. +// +// Frontend nav surfaces are gated separately via `HUB_ONLY_VIEWS` in +// useViewNavigationState.ts; this list is the backend defense-in-depth. +// +// Consumed by: +// - middleware/hubOnlyGuard.ts → 409 when nodeId is remote +export const HUB_ONLY_PREFIXES: readonly string[] = [ + '/api/scheduled-tasks/', + '/api/audit-log/', + '/api/notification-routes/', +]; + +/** Returns true when the path is hub-only and must not be proxied to a remote node. */ +export function isHubOnlyPath(path: string): boolean { + for (const prefix of HUB_ONLY_PREFIXES) { + if (path.startsWith(prefix)) return true; + } + return false; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 28ffcd38..380b9de7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import './types/express'; import { authGate, auditLog } from './middleware/authGate'; import { enforceApiTokenScope } from './middleware/apiTokenScope'; +import { hubOnlyGuard } from './middleware/hubOnlyGuard'; import { errorHandler } from './middleware/errorHandler'; import { createApp } from './app'; import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy'; @@ -82,6 +83,14 @@ app.use('/api', auditLog); app.use('/api', enforceApiTokenScope); +// Hub-only guard: reject requests whose nodeId resolves to a remote node +// when the path is hub-only (e.g. /api/scheduled-tasks, /api/audit-log, +// /api/notification-routes). Without this, the proxy would forward the +// request and process it on the remote as a local call, crossing a +// node-authority boundary that the UI hides. See helpers/proxyExemptPaths.ts +// for the prefix list and middleware/hubOnlyGuard.ts for the rationale. +app.use('/api', hubOnlyGuard); + // Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted BEFORE the // per-group routers so a request targeting a remote node short-circuits into // the proxy instead of hitting a local handler that would read local state. diff --git a/backend/src/middleware/hubOnlyGuard.ts b/backend/src/middleware/hubOnlyGuard.ts new file mode 100644 index 00000000..a180c72a --- /dev/null +++ b/backend/src/middleware/hubOnlyGuard.ts @@ -0,0 +1,38 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { isHubOnlyPath } from '../helpers/proxyExemptPaths'; + +/** + * Reject hub-only API requests whose `req.nodeId` resolves to a remote node. + * + * The hub-level views in the frontend (Schedules, Audit, Notification + * Routing config, etc.) are hidden from the nav strip when the active node + * is remote, so a normal user never reaches these endpoints with a remote + * nodeId. A scripted client could still craft `x-node-id: ` against + * one of these paths; without this guard, the request would be forwarded + * by `remoteNodeProxy` and processed on the remote as if it were local, + * silently crossing a node-authority boundary that the UI promised would + * not happen. + * + * Mounted at `/api` between `nodeContextMiddleware` (which sets req.nodeId) + * and `createRemoteProxyMiddleware` (which would otherwise forward the + * request). Rejects with 403 — the endpoint exists but the request cannot + * be served as routed. + * + * Returns 403 only for hub-only paths; non-hub paths fall through. + */ +export const hubOnlyGuard: RequestHandler = (req: Request, res: Response, next: NextFunction) => { + if (!isHubOnlyPath(`/api${req.path}`)) { + next(); + return; + } + const node = NodeRegistry.getInstance().getNode(req.nodeId); + if (node?.type === 'remote') { + res.status(403).json({ + error: 'This endpoint is hub-only and cannot be proxied to a remote node. Switch the active node back to your local hub.', + code: 'HUB_ONLY_ENDPOINT', + }); + return; + } + next(); +}; diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index edec2174..66f59d68 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -7,6 +7,10 @@ description: Track all mutating actions across your Sencho instance with a searc The Audit Log requires a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. + + Audit is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active). + + Sencho Admiral records every mutating action (deploy, stop, delete, settings changes, user management) with full attribution. The audit log answers the question every team eventually asks: **"Who changed what, and when?"** ## What gets logged diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 39ecf058..76711cd2 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -7,6 +7,10 @@ description: "Review pending container updates across your fleet, with risk tags Auto-Update Readiness requires a **Skipper** or **Admiral** license. + + Auto-Update is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active). + + ## Overview Auto-Update Readiness is the launchpad for every pending update across your stacks. Instead of a list of CRUD policies, the view surfaces one card per stack with an available update and tells you, at a glance, whether it is safe to apply. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 1838713c..37144d7c 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -5,6 +5,10 @@ description: Monitor all your nodes from a single dashboard with real-time healt The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment, local and remote, on one screen. It is available to all tiers, with advanced features unlocked by Skipper and Admiral. + + Fleet is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active). + + Fleet Overview with the fleet masthead, grid/topology toggle, and pinned local node diff --git a/docs/features/global-observability.mdx b/docs/features/global-observability.mdx index 8fd245c9..9896dfae 100644 --- a/docs/features/global-observability.mdx +++ b/docs/features/global-observability.mdx @@ -5,6 +5,10 @@ description: A unified, searchable log stream from every container across all yo The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place, with contextual metrics and filtering to focus on what matters. + + Logs is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active). + + Global Observability view with live masthead, signal rail, and streaming log feed diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index f6f31df7..6dc293a8 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -66,6 +66,20 @@ Click any row to switch. All views (dashboard stats, stack list, editor, resourc Node switcher popover listing connected nodes with type, version, and active-row accent +## What top-level views show when a remote node is active + +Top-level views that manage fleet-wide state are scoped to the local hub and are hidden from the nav strip when a remote node is the active selection. They reappear automatically when you switch back to your local node. + +| View | Why it is hub-only | +|------|--------------------| +| **Fleet** | Manages your node registry and cross-node topology; only meaningful from the hub. | +| **Schedules** | Schedules are dispatched from the hub to target nodes. | +| **Audit** | Audit history is centralized on the hub. | +| **Logs** | Aggregates logs across the fleet. | +| **Auto-Update** | Compares image versions across all nodes in your fleet. | + +Node-level views (Home, Resources, App Store, Console, the editor) work as expected against whichever node you have selected. + ## Per-node scheduling and update indicators The Nodes table surfaces scheduling and update status at a glance for each node: diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 8a6ee77a..fceb81c6 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -7,6 +7,10 @@ description: Automate recurring Docker operations like stack restarts, lifecycle Scheduled Operations is available to admins on Skipper and Admiral. Skipper unlocks **Auto-update Stack**, **Vulnerability Scan**, and **Fleet Snapshot**. All other actions (Restart Stack, System Prune, Backup Stack Files, Stop / Take Down / Start Stack) remain Admiral. + + Schedules is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active). + + ## Overview Scheduled Operations lets you automate recurring maintenance tasks across your infrastructure. Define a cron schedule, choose an action, and Sencho handles the rest, including a full execution history log so you always know what ran and when. diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index eaa81fc8..97cedd9e 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -2,6 +2,7 @@ import { Suspense, lazy, type ReactNode } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { AdmiralGate } from '../AdmiralGate'; import { CapabilityGate } from '../CapabilityGate'; +import { HubOnlyGate } from '../HubOnlyGate'; import LazyBoundary from '../LazyBoundary'; import { SettingsPage } from '../settings/SettingsPage'; import type { SectionId } from '../settings/types'; @@ -10,6 +11,7 @@ import ResourcesView from '../ResourcesView'; import HomeDashboard from '../HomeDashboard'; import type { NotificationItem } from '../dashboard/types'; import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; +import type { ActiveView } from './hooks/useViewNavigationState'; // Paid-tier views and the security-history overlay are loaded on demand. // Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render @@ -59,18 +61,7 @@ function LazyView({ children }: { children: ReactNode }) { ); } -export type ActiveView = - | 'dashboard' - | 'editor' - | 'host-console' - | 'resources' - | 'templates' - | 'global-observability' - | 'fleet' - | 'audit-log' - | 'scheduled-ops' - | 'auto-updates' - | 'settings'; +export type { ActiveView }; export interface ViewRouterProps { activeView: ActiveView; @@ -148,50 +139,60 @@ export function ViewRouter({ } if (activeView === 'global-observability') { return ( - - - + + + + + ); } if (activeView === 'fleet') { return ( - - - - - + + + + + + + ); } if (activeView === 'audit-log') { return ( - - - - - + + + + + + + ); } if (activeView === 'auto-updates') { return ( - - - - - + + + + + + + ); } if (activeView === 'scheduled-ops') { return ( - - - - - + + + + + + + ); } return ( diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index f207c562..27d5dd15 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -2,11 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import * as AuthContext from '@/context/AuthContext'; import * as LicenseContext from '@/context/LicenseContext'; +import * as NodeContext from '@/context/NodeContext'; import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager'; import { useViewNavigationState } from '../hooks/useViewNavigationState'; vi.mock('@/context/AuthContext'); vi.mock('@/context/LicenseContext'); +vi.mock('@/context/NodeContext'); + +function mockActiveNode(type: 'local' | 'remote' | null) { + vi.mocked(NodeContext.useNodes).mockReturnValue({ + activeNode: type === null ? null : { type, id: 1, name: 'n' }, + } as unknown as ReturnType); +} function mockCommunityUser() { vi.mocked(AuthContext.useAuth).mockReturnValue({ @@ -44,6 +52,7 @@ function mockSkipperAdmin() { describe('useViewNavigationState', () => { beforeEach(() => { mockCommunityUser(); + mockActiveNode('local'); }); // ── initial state ────────────────────────────────────────────────────────── @@ -227,4 +236,85 @@ describe('useViewNavigationState', () => { expect(values).not.toContain('audit-log'); expect(values).not.toContain('scheduled-ops'); }); + + // ── navItems: hub-only gating on remote node ─────────────────────────────── + + it('hides hub-only views from the nav strip when active node is remote', () => { + mockAdmiralAdmin(); + mockActiveNode('remote'); + const { result } = renderHook(() => useViewNavigationState()); + const values = result.current.navItems.map(i => i.value); + expect(values).not.toContain('fleet'); + expect(values).not.toContain('scheduled-ops'); + expect(values).not.toContain('audit-log'); + expect(values).not.toContain('global-observability'); + expect(values).not.toContain('auto-updates'); + // Node-level views remain visible. + expect(values).toContain('dashboard'); + expect(values).toContain('resources'); + expect(values).toContain('templates'); + expect(values).toContain('host-console'); + }); + + it('shows hub-only views again when active node switches back to local', () => { + mockAdmiralAdmin(); + mockActiveNode('remote'); + const { result, rerender } = renderHook(() => useViewNavigationState()); + expect(result.current.navItems.map(i => i.value)).not.toContain('fleet'); + + mockActiveNode('local'); + rerender(); + const values = result.current.navItems.map(i => i.value); + expect(values).toContain('fleet'); + expect(values).toContain('scheduled-ops'); + expect(values).toContain('audit-log'); + }); + + // ── auto-redirect when on a hub-only view and node switches to remote ────── + + it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => { + const onNavigateToDashboard = vi.fn(); + mockAdmiralAdmin(); + mockActiveNode('local'); + const { result, rerender } = renderHook(() => + useViewNavigationState({ onNavigateToDashboard }), + ); + + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 7 } }), + ); + }); + expect(result.current.activeView).toBe('fleet'); + expect(result.current.filterNodeId).toBe(7); + + mockActiveNode('remote'); + rerender(); + + expect(result.current.activeView).toBe('dashboard'); + expect(result.current.filterNodeId).toBeNull(); + expect(onNavigateToDashboard).toHaveBeenCalledOnce(); + }); + + it('does not redirect when a non-hub-only view is active and node becomes remote', () => { + const onNavigateToDashboard = vi.fn(); + mockAdmiralAdmin(); + mockActiveNode('local'); + const { result, rerender } = renderHook(() => + useViewNavigationState({ onNavigateToDashboard }), + ); + + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'resources' } }), + ); + }); + expect(result.current.activeView).toBe('resources'); + + mockActiveNode('remote'); + rerender(); + + expect(result.current.activeView).toBe('resources'); + expect(onNavigateToDashboard).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 4f72e830..90c0dd48 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -6,6 +6,7 @@ import { import type { LucideIcon } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; +import { useNodes } from '@/context/NodeContext'; import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager'; import type { SenchoNavigateDetail } from '@/components/NodeManager'; import type { SectionId } from '@/components/settings/types'; @@ -24,8 +25,22 @@ export type ActiveView = | 'auto-updates' | 'settings'; +// Views that operate on hub-owned state (node registry, fleet schedules, +// centralized audit, fleet-wide log aggregation, fleet-wide update preview). +// Hidden from the nav strip and force-redirect to dashboard when the active +// node is remote, since proxying them would surface that remote's own +// disconnected state instead of the hub's. Settings sub-sections use the +// parallel `hiddenOnRemote` registry (see settings/registry.ts). +export const HUB_ONLY_VIEWS: ReadonlySet = new Set([ + 'fleet', + 'scheduled-ops', + 'audit-log', + 'global-observability', + 'auto-updates', +]); + export interface NavItem { - value: string; + value: ActiveView; label: string; icon: LucideIcon; } @@ -38,6 +53,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const { onNavigateToDashboard } = options ?? {}; const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); + const { activeNode } = useNodes(); + const isRemote = activeNode?.type === 'remote'; const [activeView, setActiveView] = useState('dashboard'); const [settingsSection, setSettingsSection] = useState('appearance'); @@ -97,8 +114,18 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } - return items; - }, [isAdmin, isPaid, license?.variant, can]); + return isRemote + ? items.filter(i => !HUB_ONLY_VIEWS.has(i.value)) + : items; + }, [isAdmin, isPaid, license?.variant, can, isRemote]); + + useEffect(() => { + if (isRemote && HUB_ONLY_VIEWS.has(activeView)) { + onNavigateToDashboard?.(); + setActiveView('dashboard'); + setFilterNodeId(null); + } + }, [isRemote, activeView, onNavigateToDashboard]); return { activeView, setActiveView, diff --git a/frontend/src/components/HubOnlyGate.tsx b/frontend/src/components/HubOnlyGate.tsx new file mode 100644 index 00000000..1e5fa66d --- /dev/null +++ b/frontend/src/components/HubOnlyGate.tsx @@ -0,0 +1,19 @@ +import { type ReactNode } from 'react'; +import { useNodes } from '@/context/NodeContext'; + +interface HubOnlyGateProps { + children: ReactNode; +} + +/** + * Short-circuits to null when the active node is remote so the wrapped + * lazy chunk is never fetched. Must wrap *outside* any `LazyView` + * (Suspense + lazy), otherwise the gated chunk would download just to + * render a preview before the redirect in `useViewNavigationState` + * fires. Same load-bearing constraint as `CapabilityGate`. + */ +export function HubOnlyGate({ children }: HubOnlyGateProps) { + const { activeNode } = useNodes(); + if (activeNode?.type === 'remote') return null; + return <>{children}; +} diff --git a/frontend/src/components/__tests__/HubOnlyGate.test.tsx b/frontend/src/components/__tests__/HubOnlyGate.test.tsx new file mode 100644 index 00000000..c38a9ee1 --- /dev/null +++ b/frontend/src/components/__tests__/HubOnlyGate.test.tsx @@ -0,0 +1,54 @@ +/** + * HubOnlyGate short-circuits to null when the active node is remote so the + * wrapped lazy chunk is never fetched. Locks the load-bearing behavior + * documented in HubOnlyGate.tsx. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as NodeContext from '@/context/NodeContext'; +import { HubOnlyGate } from '../HubOnlyGate'; + +vi.mock('@/context/NodeContext'); + +function mockActiveNode(type: 'local' | 'remote' | null) { + vi.mocked(NodeContext.useNodes).mockReturnValue({ + activeNode: type === null ? null : { type, id: 1, name: 'n' }, + } as unknown as ReturnType); +} + +describe('HubOnlyGate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders children when active node is local', () => { + mockActiveNode('local'); + render( + +
hub content
+
, + ); + expect(screen.getByTestId('payload')).toBeTruthy(); + }); + + it('renders children when active node is null (initial load)', () => { + mockActiveNode(null); + render( + +
hub content
+
, + ); + expect(screen.getByTestId('payload')).toBeTruthy(); + }); + + it('returns null when active node is remote', () => { + mockActiveNode('remote'); + const { container } = render( + +
hub content
+
, + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId('payload')).toBeNull(); + }); +});