diff --git a/backend/src/__tests__/mesh-route-gating.test.ts b/backend/src/__tests__/mesh-route-gating.test.ts new file mode 100644 index 00000000..3a363a16 --- /dev/null +++ b/backend/src/__tests__/mesh-route-gating.test.ts @@ -0,0 +1,155 @@ +/** + * Gate coverage for the mesh router. + * + * Every /api/mesh route is tier-gated (requireAdmiral). The five operator + * mutations are additionally role-gated (requireAdmin): node enable/disable, + * stack opt-in/opt-out, and the override regen. The operator read routes + * (status, aliases, activity, diagnostics) stay reachable for any Admiral-tier + * user regardless of role, which is what lets a non-admin see a read-only + * Routing tab. The node-to-node routes that central calls over the proxy on the + * operator's behalf (local-override PUT/DELETE, alias test) are Admiral-gated + * but intentionally not admin-gated. These tests lock that split so the backend + * can never silently diverge from the matching frontend render gate (a button + * that 403s, or a feature an owner cannot see). + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let defaultNodeId: number; + +function userToken(username: string): string { + const user = DatabaseService.getInstance().getUserByUsername(username); + if (!user) throw new Error(`missing test user ${username}`); + return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); +} + +function setTier(tier: 'community' | 'paid', variant: 'skipper' | 'admiral' | null): void { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + + const viewerHash = await bcrypt.hash('password123', 1); + DatabaseService.getInstance().addUser({ username: 'mesh-viewer', password_hash: viewerHash, role: 'viewer' }); + defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1; + + ({ app } = await import('../index')); +}); + +beforeEach(() => { + // Default every test to a fully entitled Admiral instance; tier-rejection + // tests override this locally. + setTier('paid', 'admiral'); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('mesh tier gate (requireAdmiral)', () => { + it('rejects Community tier with PAID_REQUIRED', async () => { + setTier('community', null); + const res = await request(app) + .get('/api/mesh/aliases') + .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('rejects a paid non-Admiral variant with ADMIRAL_REQUIRED', async () => { + setTier('paid', 'skipper'); + const res = await request(app) + .get('/api/mesh/aliases') + .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIRAL_REQUIRED'); + }); + + it('rejects Community tier on a mutation before the role gate runs', async () => { + setTier('community', null); + const res = await request(app) + .post('/api/mesh/regen-overrides') + .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); +}); + +describe('mesh read routes are visible to a non-admin Admiral user', () => { + it('returns aliases to a viewer', async () => { + const res = await request(app) + .get('/api/mesh/aliases') + .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.aliases)).toBe(true); + }); + + it('returns activity to a viewer', async () => { + const res = await request(app) + .get('/api/mesh/activity') + .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.events)).toBe(true); + }); + + it('returns status to a viewer', async () => { + const res = await request(app) + .get('/api/mesh/status') + .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.nodes)).toBe(true); + }); +}); + +describe('mesh mutation routes require the admin role (requireAdmin)', () => { + const mutationRoutes: { name: string; path: () => string }[] = [ + { name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' }, + { name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` }, + { name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` }, + { name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` }, + { name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` }, + ]; + + for (const route of mutationRoutes) { + it(`${route.name} rejects a non-admin Admiral user with ADMIN_REQUIRED`, async () => { + const res = await request(app) + .post(route.path()) + .set('Authorization', `Bearer ${userToken('mesh-viewer')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); + } + + it('lets an Admiral admin pass both gates on regen-overrides', async () => { + const res = await request(app) + .post('/api/mesh/regen-overrides') + .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('regenerated'); + }); + + it('lets an Admiral admin past both gates on a node mutation (not gate-rejected)', async () => { + // Locks the guard order (tier before role) for a mutation other than + // regen-overrides: an admin must never be rejected by either gate. The + // handler may still 4xx/5xx for other reasons in the test environment; + // only the gate codes are asserted absent. + const res = await request(app) + .post(`/api/mesh/nodes/${defaultNodeId}/enable`) + .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`); + expect(res.body.code).not.toBe('PAID_REQUIRED'); + expect(res.body.code).not.toBe('ADMIRAL_REQUIRED'); + expect(res.body.code).not.toBe('ADMIN_REQUIRED'); + }); +}); diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 8c8d42b4..21e92b87 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -210,7 +210,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {isAdmiral && ( - + )} diff --git a/frontend/src/components/fleet/MeshOptInSheet.test.tsx b/frontend/src/components/fleet/MeshOptInSheet.test.tsx new file mode 100644 index 00000000..2b856171 --- /dev/null +++ b/frontend/src/components/fleet/MeshOptInSheet.test.tsx @@ -0,0 +1,73 @@ +/** + * Render-gate coverage for MeshOptInSheet's opt-in/out controls. + * + * Opting a stack in or out is admin-only on the backend + * (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This + * test locks the matching UI gate: a manager sees Add/Remove buttons, a + * non-manager sees the membership read-only with a hint and still gets the + * read-only topology affordance. The read-only branch must never issue the + * admin-only mutation. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { MeshStackEntry } from '@/types/mesh'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +import { apiFetch } from '@/lib/api'; +import { MeshOptInSheet } from './MeshOptInSheet'; + +const STACKS: MeshStackEntry[] = [ + { name: 'web', optedIn: true }, + { name: 'db', optedIn: false }, +]; + +beforeEach(() => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ stacks: STACKS }), + } as unknown as Response); +}); + +function renderSheet(canManage: boolean) { + return render( + {}} + nodeId={1} + nodeName="node-alpha" + onChanged={() => {}} + onViewTopology={() => {}} + canManage={canManage} + />, + ); +} + +describe('MeshOptInSheet canManage gate', () => { + it('shows opt-in/out controls for a manager', async () => { + renderSheet(true); + expect(await screen.findByText('web')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Add to mesh/i })).toBeInTheDocument(); + expect(screen.queryByText(/Changing mesh membership requires an administrator/i)).not.toBeInTheDocument(); + }); + + it('renders the membership read-only for a non-manager', async () => { + renderSheet(false); + expect(await screen.findByText('web')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument(); + expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument(); + // Topology is read-only and stays available for opted-in stacks. + expect(screen.getByRole('button', { name: /View topology for web/i })).toBeInTheDocument(); + // The read-only branch must never issue an opt-in/opt-out request. + expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith( + expect.stringContaining('/opt-'), + expect.anything(), + ); + }); +}); diff --git a/frontend/src/components/fleet/MeshOptInSheet.tsx b/frontend/src/components/fleet/MeshOptInSheet.tsx index babf01f6..ba3a6d8c 100644 --- a/frontend/src/components/fleet/MeshOptInSheet.tsx +++ b/frontend/src/components/fleet/MeshOptInSheet.tsx @@ -14,9 +14,11 @@ interface Props { nodeName: string; onChanged: () => void; onViewTopology?: (stack: string) => void; + /** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */ + canManage: boolean; } -export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology }: Props) { +export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology, canManage }: Props) { const [stacks, setStacks] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -93,6 +95,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged

Adding a stack lets its services be reached from other meshed stacks by hostname. Toggling a stack triggers a redeploy on its node so the routing override applies. + {!canManage && ' Changing mesh membership requires an administrator.'}

{loading && ( @@ -132,13 +135,15 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged Topology )} - + {canManage && ( + + )} )} diff --git a/frontend/src/components/fleet/RoutingNodeCard.tsx b/frontend/src/components/fleet/RoutingNodeCard.tsx index 59785636..9e4bdda6 100644 --- a/frontend/src/components/fleet/RoutingNodeCard.tsx +++ b/frontend/src/components/fleet/RoutingNodeCard.tsx @@ -18,6 +18,7 @@ interface Props { onShowAlias: (alias: string) => void; onTestUpstream: (alias: string) => Promise; onChanged: () => void; + canManage: boolean; } const REVERSE_BRIDGE: Record = { @@ -62,7 +63,7 @@ function buildFooterContext( } export function RoutingNodeCard({ - status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, + status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage, }: Props) { const [toggling, setToggling] = useState(false); const [testingAlias, setTestingAlias] = useState(null); @@ -175,6 +176,7 @@ export function RoutingNodeCard({ onRetry={onChanged} footerContext={footerContext} offlineReason={status.reachableReason} + canManage={canManage} /> ); } diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx index 117890fc..f04a5660 100644 --- a/frontend/src/components/fleet/RoutingTab.tsx +++ b/frontend/src/components/fleet/RoutingTab.tsx @@ -45,7 +45,7 @@ function readStoredEdgeMode(): MeshGraphEdgeMode { } } -export function RoutingTab() { +export function RoutingTab({ canManage }: { canManage: boolean }) { const [status, setStatus] = useState([]); const [localDataPlane, setLocalDataPlane] = useState(null); const [aliases, setAliases] = useState([]); @@ -178,11 +178,13 @@ export function RoutingTab() { onShowAlias={(alias) => setRouteDetailAlias(alias)} onTestUpstream={testUpstream} onChanged={() => { void refresh(); }} + canManage={canManage} /> ))} setRouteDetailAlias(alias)} onTestUpstream={testUpstream} onChanged={() => { void refresh(); }} + canManage={canManage} /> ))} @@ -247,6 +250,7 @@ export function RoutingTab() { /> )} void; diagnosticsNode: { id: number; name: string } | null; @@ -311,6 +316,7 @@ function SheetsRoot(props: { onOpenChange={(open) => { if (!open) props.setOptInNode(null); }} nodeId={optInNode.id} nodeName={optInNode.name} + canManage={props.canManage} onChanged={props.onChanged} onViewTopology={(stack) => { props.setTopologyStack({ nodeId: optInNode.id, nodeName: optInNode.name, stack }); diff --git a/frontend/src/components/ui/routing-node-card.test.tsx b/frontend/src/components/ui/routing-node-card.test.tsx new file mode 100644 index 00000000..86dfbfff --- /dev/null +++ b/frontend/src/components/ui/routing-node-card.test.tsx @@ -0,0 +1,105 @@ +/** + * Render-gate coverage for the routing-node-card `canManage` prop. + * + * Enabling/disabling mesh on a node and opting a stack in are admin-only on the + * backend. This locks the matching UI gate: a manager sees the enable/disable + * toggle and the enable/add CTAs, a non-manager sees neither (just a hint) while + * the read-only affordances, diagnostics in particular, stay available. Without + * this the card can drift back to rendering a control the API answers with 403. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { RoutingNodeCard, type RoutingNodeCardProps } from '@/components/ui/routing-node-card'; + +function renderCard(overrides: Partial = {}) { + const props: RoutingNodeCardProps = { + crumb: ['Routing', 'Node', 'node-alpha'], + name: 'node-alpha', + nodeState: 'idle', + meta: { pilotConnected: true, reverseBridge: 'na', stacks: 0, aliases: 0 }, + aliases: [], + onToggleEnabled: vi.fn(), + onShowDiagnostics: vi.fn(), + onAddStack: vi.fn(), + onRetry: vi.fn(), + footerContext: 'Mesh off', + // canManage is required on the primitive; tests override it per case. + canManage: true, + ...overrides, + }; + return render(); +} + +describe('routing-node-card canManage gate', () => { + it('shows the enable toggle and the enable CTA for a manager', () => { + renderCard({ nodeState: 'idle', canManage: true }); + expect(screen.getByRole('switch')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Enable mesh on node-alpha/i })).toBeInTheDocument(); + expect(screen.queryByText(/Managing the mesh requires an administrator/i)).not.toBeInTheDocument(); + }); + + it('hides the toggle and enable CTA for a non-manager but keeps diagnostics', () => { + renderCard({ nodeState: 'idle', canManage: false }); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Enable mesh on/i })).not.toBeInTheDocument(); + expect(screen.getByText(/Managing the mesh requires an administrator/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Diagnostics/i })).toBeInTheDocument(); + }); + + it('hides the add-stack CTA for a non-manager on a meshed node', () => { + renderCard({ + nodeState: 'meshed', + meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 }, + canManage: false, + }); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.queryByText(/Add stack to mesh/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Managing the mesh requires an administrator/i)).toBeInTheDocument(); + }); + + it('keeps the retry CTA for a non-manager on a degraded node', () => { + // Retry is a read-only refresh, not a management action, so it must + // survive the gate. A regression dropping the management-state check + // would hide it for non-admins. + renderCard({ nodeState: 'degraded', canManage: false }); + expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument(); + expect(screen.queryByText(/Managing the mesh requires an administrator/i)).not.toBeInTheDocument(); + }); + + it('keeps the retry CTA for a non-manager on an offline node', () => { + renderCard({ nodeState: 'offline', canManage: false }); + expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument(); + }); +}); + +describe('routing-node-card canManage gate (compact density)', () => { + beforeEach(() => { + window.localStorage.setItem('sencho.appearance.density', 'compact'); + }); + afterEach(() => { + window.localStorage.removeItem('sencho.appearance.density'); + }); + + it('hides the toggle for a non-manager on a meshed node', () => { + renderCard({ + nodeState: 'meshed', + meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 }, + canManage: false, + }); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + }); + + it('shows the toggle for a manager on a meshed node', () => { + renderCard({ + nodeState: 'meshed', + meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 }, + canManage: true, + }); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('keeps the retry CTA for a non-manager on an offline node', () => { + renderCard({ nodeState: 'offline', canManage: false }); + expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/routing-node-card.tsx b/frontend/src/components/ui/routing-node-card.tsx index 2a18dac5..3b799fe0 100644 --- a/frontend/src/components/ui/routing-node-card.tsx +++ b/frontend/src/components/ui/routing-node-card.tsx @@ -40,6 +40,15 @@ export interface RoutingNodeCardProps { footerContext: string; /** Offline-state reason copy; falls back to a generic line. */ offlineReason?: string | null; + /** + * When false, the management affordances (enable/disable toggle and the + * enable-mesh / add-stack empty-state CTAs) are hidden so a viewer without + * the admin role gets a read-only card. Read-only affordances (diagnostics, + * alias probe, retry) stay available. Required, with no default: a caller + * must state the gate explicitly so a new call site cannot fall open to a + * management view it did not intend. + */ + canManage: boolean; } const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]'; @@ -76,6 +85,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) { crumb, name, isLocal, nodeState, meta, aliases, onToggleEnabled, onShowDiagnostics, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason, + canManage, } = props; const [density] = useDensity(); const compact = density === 'compact'; @@ -107,6 +117,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) { diagnosticsDisabled={diagnosticsDisabled} onToggleEnabled={onToggleEnabled} onShowDiagnostics={onShowDiagnostics} + canManage={canManage} footerContext={footerContext} aliasesEmpty={aliases.length === 0} onAddStack={onAddStack} @@ -124,6 +135,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) { diagnosticsDisabled={diagnosticsDisabled} onToggleEnabled={onToggleEnabled} onShowDiagnostics={onShowDiagnostics} + canManage={canManage} aliases={aliases} onShowAlias={onShowAlias} onTestAlias={onTestAlias} @@ -147,6 +159,7 @@ interface BodyChrome { diagnosticsDisabled: boolean; onToggleEnabled: (next: boolean) => void; onShowDiagnostics: () => void; + canManage: boolean; footerContext: string; onAddStack?: () => void; onRetry?: () => void; @@ -169,6 +182,7 @@ function ComfortableBody(props: ComfortableProps) { crumb, name, isLocal, chip, meta, nodeState, isEnabled, toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics, aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason, + canManage, } = props; const published = aliases.filter((a) => a.kind === 'alias').length; const showAliases = aliases.length > 0; @@ -207,6 +221,7 @@ function ComfortableBody(props: ComfortableProps) { diagnosticsDisabled={diagnosticsDisabled} onToggleEnabled={onToggleEnabled} onShowDiagnostics={onShowDiagnostics} + canManage={canManage} />
@@ -224,6 +239,7 @@ function ComfortableBody(props: ComfortableProps) { onAddStack={onAddStack} onRetry={onRetry} onToggleEnabled={onToggleEnabled} + canManage={canManage} />}
@@ -236,7 +252,7 @@ function CompactBody(props: CompactProps) { const { name, isLocal, chip, meta, nodeState, isEnabled, toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics, - footerContext, aliasesEmpty, onAddStack, onRetry, + footerContext, aliasesEmpty, onAddStack, onRetry, canManage, } = props; return ( @@ -254,11 +270,13 @@ function CompactBody(props: CompactProps) { {chip.label}
- + {canManage && ( + + )} + {!canManage && isManagementState ? ( +
+ Managing the mesh requires an administrator. +
+ ) : ( + + )}
); } @@ -546,10 +579,14 @@ interface CompactFooterProps { onAddStack?: () => void; onRetry?: () => void; onToggleEnabled: (next: boolean) => void; + canManage: boolean; } -function CompactFooter({ context, nodeState, name, aliasesEmpty, onAddStack, onRetry, onToggleEnabled }: CompactFooterProps) { - const showCta = nodeState !== 'meshed' || aliasesEmpty; +function CompactFooter({ context, nodeState, name, aliasesEmpty, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) { + // Hide the management CTAs (enable mesh / add stack) for non-admins; the + // read-only retry on a degraded/offline node stays available. + const isManagementState = nodeState === 'idle' || nodeState === 'meshed'; + const showCta = (nodeState !== 'meshed' || aliasesEmpty) && (canManage || !isManagementState); const { cta } = emptyStateCopy(nodeState, name); const handleClick = () => { if (nodeState === 'idle') onToggleEnabled(true);