From b33cc3ac6056ddd93abbdbaa8a721ef5a6cea4f1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Apr 2026 17:20:58 +0100 Subject: [PATCH] Hide admin operations from public demo --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/ai-runtime.md | 4 + .../v6/internal/subsystems/api-contracts.md | 6 ++ .../v6/internal/subsystems/cloud-paid.md | 4 + .../subsystems/frontend-primitives.md | 11 ++- .../internal/subsystems/storage-recovery.md | 5 + frontend-modern/src/AppLayout.tsx | 14 ++- .../src/__tests__/App.architecture.test.ts | 2 + .../operations/OperationsPageSurface.tsx | 92 +++++++++++-------- .../OperationsPageSurface.demoMode.test.tsx | 74 +++++++++++++++ .../operations/operationsPageModel.ts | 4 + .../__tests__/Operations.helpers.test.ts | 2 + internal/api/contract_test.go | 6 ++ internal/api/demo_middleware.go | 6 ++ internal/api/demo_middleware_test.go | 6 ++ internal/api/demo_mode_operations.go | 47 ++++++++++ internal/api/demo_mode_operations_test.go | 54 +++++++++++ 17 files changed, 299 insertions(+), 43 deletions(-) create mode 100644 frontend-modern/src/features/operations/__tests__/OperationsPageSurface.demoMode.test.tsx create mode 100644 internal/api/demo_mode_operations.go create mode 100644 internal/api/demo_mode_operations_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 0be504a0e..573127e3f 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -432,6 +432,11 @@ needs them, but `/api/license/commercial-posture`, public demo mode and those lifecycle flows must not depend on licensed identity, plan labels, upgrade reasons, checkout handoff state, or observed usage counts surviving the public-demo contract. +That same demo-hidden API boundary also keeps runtime-admin operations out of +public lifecycle flows: `/api/diagnostics`, +`/api/diagnostics/docker/prepare-token`, and `/api/logs/*` must return `404` +in demo mode instead of exposing runtime bundles, log streams, or diagnostics +payloads through a nominally read-only preview account. Lifecycle-owned browser shells must also defer any commercial helper reads until that presentation policy resolves so demo suppression stays fail-closed during first render instead of racing hidden commercial endpoints from shared diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a8c6f5b9a..439b77624 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -228,6 +228,10 @@ organization chrome: `frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` may hide org switchers or demo-only org labels, but they must not couple assistant visibility, session reset, or drawer-open behavior to that organization presentation state. +That same shell boundary also owns demo-only Operations suppression: +`frontend-modern/src/AppLayout.tsx` may remove the top-level Operations route +from the public demo shell, but assistant availability and reset behavior must +stay independent of that utility-tab presentation choice. Authenticated `/login` recovery belongs to that same route shell boundary: once login succeeds, `frontend-modern/src/App.tsx` must resolve `/login` through the canonical post-auth landing route instead of leaving the diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 6bf0850cb..80dbc93af 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1348,6 +1348,12 @@ That same diagnostics boundary must also backfill canonical fallback reasons when a raw snapshot reaches the API layer without one, so `buildMemorySourceDiagnostics` stays self-consistent even if a caller bypasses `GetDiagnosticSnapshots()` and hands diagnostics a legacy alias directly. +That same public-demo API boundary must also hide runtime-admin operations +surfaces instead of treating them as harmless reads. Demo sessions must receive +`404` for `/api/diagnostics`, `/api/diagnostics/docker/prepare-token`, and the +shared `/api/logs/*` endpoints, so the preview shell cannot expose runtime +diagnostics, log streams, or downloadable log bundles behind a supposedly +read-only demo account. That shared `InfrastructureOperationsController.tsx` boundary now also preserves copied shell command payload continuity: any privilege-escalation wrapper applied at the settings surface must keep the full canonical installer argument list intact diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 8658e1117..7eb44ba06 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -228,6 +228,10 @@ trial urgency, observed usage counts, or checkout handoff state. The commercial posture store and billing-entitlements store must also fail closed locally until the shared presentation policy resolves, then stay fail-closed in demo mode so hidden routes are not probed from the browser shell. +That same browser-shell boundary also owns utility-nav suppression: +`frontend-modern/src/AppLayout.tsx` must drop the top-level Operations tab in +public demo mode instead of leaving diagnostics or system-log shells +discoverable after commercial surfaces are hidden. Deep-linkable commercial panels must consume the same resolved presentation policy directly, not rely only on settings navigation to keep public-demo browsers away from commercial routes. `ProLicensePanel` may instantiate its diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 43747c9d3..c51c58fdc 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -179,6 +179,11 @@ work extends shared components instead of creating new local variants. posture: `/alerts` may continue exposing reporting tabs such as overview and history, but activation controls plus configuration routes must collapse out of the public-demo shell instead of advertising blocked management actions. + That same public-demo presentation boundary also owns top-level Operations + posture: the authenticated demo shell must not advertise the Operations + utility tab, and `/operations` deep links must hand back to the dashboard + instead of surfacing diagnostics or system-log chrome that the backend hides + for demo sessions. 3. Add feature-specific presentation only when no shared primitive should own it 4. Add guardrail tests when a new shared pattern is introduced 5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary. @@ -1183,7 +1188,11 @@ the tabbed operations surface, and `frontend-modern/src/features/operations/operationsPageModel.ts` owns the tab and path contract. The operations route must keep its navigation routed through the shared `frontend-modern/src/components/shared/Subtabs.tsx` primitive rather -than rebuilding a bespoke page-local tab bar. +than rebuilding a bespoke page-local tab bar. When the session presentation +policy marks the operator as a public demo viewer, that same route owner must +suppress the surface entirely and hand the browser back to the canonical +dashboard route instead of rendering diagnostics, reporting, or logs shells +that are unavailable in demo mode. The dashboard overview route now follows that same feature-owner pattern for its dashboard-specific summary surfaces. `frontend-modern/src/pages/Dashboard.tsx` diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8dffe94b4..e83122338 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -104,6 +104,11 @@ querying, and the operator-facing storage health presentation layer. payloads must read through `GetUnifiedReadStateOrSnapshot()` so storage and recovery consumers stay aligned with the canonical mock unified snapshot instead of slipping onto the live store graph. + That same `internal/api/` demo boundary must keep runtime-admin operations + hidden from public preview sessions: `/api/diagnostics`, + `/api/diagnostics/docker/prepare-token`, and `/api/logs/*` must not remain + readable side channels while storage or recovery demo routes are otherwise + presented as read-only product surfaces. Storage and recovery consumers must also inherit the hook's canonical `ResourceType` normalization for route/query filters, so storage subtypes such as `physical_disk` stay on the same cache-backed snapshot instead of diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index d619c1ce6..c85c8a390 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -40,7 +40,10 @@ import { getKioskModePreference, setKioskMode } from '@/utils/url'; import { updateStore } from '@/stores/updates'; import { aiChatStore } from '@/stores/aiChat'; import { isPro } from '@/stores/licenseCommercial'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; +import { + presentationPolicyHidesUpgradePrompts, + presentationPolicyIsDemoMode, +} from '@/stores/sessionPresentationPolicy'; import type { AppConnectionStatus } from '@/useAppRuntimeState'; const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); @@ -422,7 +425,10 @@ export function AppLayout(props: AppLayoutProps) { breakdown: undefined, icon: , }, - { + ]; + + if (!presentationPolicyIsDemoMode()) { + tabs.push({ id: 'operations', label: 'Operations', route: '/operations', @@ -431,8 +437,8 @@ export function AppLayout(props: AppLayoutProps) { count: undefined, breakdown: undefined, icon: , - }, - ]; + }); + } if (hasSettingsAccess) { tabs.push({ diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index b51231dbb..0012d4d1d 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -75,6 +75,8 @@ describe('App architecture', () => { expect(appLayoutSource).not.toContain('sessionPresentationPolicyResolved'); expect(appLayoutSource).not.toContain('presentationPolicyHidesCommercialSurfaces'); expect(appLayoutSource).not.toContain('presentationPolicyHidesOrganizationSurfaces'); + expect(appLayoutSource).toContain('presentationPolicyIsDemoMode'); + expect(appLayoutSource).toContain("if (!presentationPolicyIsDemoMode()) {"); expect(appLayoutSource).toContain( 'aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()', ); diff --git a/frontend-modern/src/features/operations/OperationsPageSurface.tsx b/frontend-modern/src/features/operations/OperationsPageSurface.tsx index 524e80337..b205fc544 100644 --- a/frontend-modern/src/features/operations/OperationsPageSurface.tsx +++ b/frontend-modern/src/features/operations/OperationsPageSurface.tsx @@ -1,4 +1,4 @@ -import { Suspense, createMemo, type Component, type JSX } from 'solid-js'; +import { Show, Suspense, createEffect, createMemo, type Component, type JSX } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; import ActivityIcon from 'lucide-solid/icons/activity'; import FileTextIcon from 'lucide-solid/icons/file-text'; @@ -7,9 +7,12 @@ import { DiagnosticsPanel } from '@/components/Settings/DiagnosticsPanel'; import { ReportingPanel } from '@/components/Settings/ReportingPanel'; import { SystemLogsPanel } from '@/components/Settings/SystemLogsPanel'; import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs'; +import { DASHBOARD_PATH } from '@/routing/resourceLinks'; +import { presentationPolicyIsDemoMode } from '@/stores/sessionPresentationPolicy'; import { buildOperationsPath, getOperationsTabFromPath, + operationsSurfaceHiddenInDemoMode, OPERATIONS_TABS, type OperationsTabId, } from '@/features/operations/operationsPageModel'; @@ -24,21 +27,32 @@ export function OperationsPageSurface() { const location = useLocation(); const navigate = useNavigate(); + const hiddenInDemoMode = createMemo(() => + operationsSurfaceHiddenInDemoMode(presentationPolicyIsDemoMode()), + ); const activeTab = createMemo(() => getOperationsTabFromPath(location.pathname)); + createEffect(() => { + if (hiddenInDemoMode()) { + navigate(DASHBOARD_PATH, { replace: true }); + } + }); + const tabs = createMemo(() => - OPERATIONS_TABS.map((tab) => { - const Icon = operationsTabIcons[tab.id]; - return { - value: tab.id, - label: ( - - - {tab.label} - - ) satisfies JSX.Element, - }; - }), + hiddenInDemoMode() + ? [] + : OPERATIONS_TABS.map((tab) => { + const Icon = operationsTabIcons[tab.id]; + return { + value: tab.id, + label: ( + + + {tab.label} + + ) satisfies JSX.Element, + }; + }), ); const handleTabChange = (tabId: string) => { @@ -46,33 +60,35 @@ export function OperationsPageSurface() { }; return ( -
-
- -
+ +
+
+ +
-
- -
-
- } - > - {activeTab() === 'diagnostics' && } - {activeTab() === 'reporting' && } - {activeTab() === 'logs' && } - +
+ +
+
+ } + > + {activeTab() === 'diagnostics' && } + {activeTab() === 'reporting' && } + {activeTab() === 'logs' && } + +
- + ); } diff --git a/frontend-modern/src/features/operations/__tests__/OperationsPageSurface.demoMode.test.tsx b/frontend-modern/src/features/operations/__tests__/OperationsPageSurface.demoMode.test.tsx new file mode 100644 index 000000000..878b8c8c8 --- /dev/null +++ b/frontend-modern/src/features/operations/__tests__/OperationsPageSurface.demoMode.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OperationsPageSurface } from '@/features/operations/OperationsPageSurface'; + +const navigateSpy = vi.hoisted(() => vi.fn()); +const presentationPolicyIsDemoModeMock = vi.hoisted(() => vi.fn(() => false)); +const locationState = vi.hoisted(() => ({ + pathname: '/operations', + hash: '', + search: '', + query: {}, +})); + +vi.mock('@solidjs/router', async () => { + const actual = await vi.importActual('@solidjs/router'); + return { + ...actual, + useLocation: () => locationState, + useNavigate: () => navigateSpy, + }; +}); + +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyIsDemoMode: () => presentationPolicyIsDemoModeMock(), +})); + +vi.mock('@/components/Settings/DiagnosticsPanel', () => ({ + DiagnosticsPanel: () =>
Diagnostics
, +})); + +vi.mock('@/components/Settings/ReportingPanel', () => ({ + ReportingPanel: () =>
Reporting
, +})); + +vi.mock('@/components/Settings/SystemLogsPanel', () => ({ + SystemLogsPanel: () =>
Logs
, +})); + +describe('OperationsPageSurface demo mode', () => { + beforeEach(() => { + cleanup(); + navigateSpy.mockReset(); + presentationPolicyIsDemoModeMock.mockReset(); + presentationPolicyIsDemoModeMock.mockReturnValue(false); + locationState.pathname = '/operations'; + locationState.hash = ''; + locationState.search = ''; + }); + + afterEach(() => cleanup()); + + it('redirects demo sessions back to the dashboard and hides operations chrome', async () => { + presentationPolicyIsDemoModeMock.mockReturnValue(true); + + render(() => ); + + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + expect(screen.queryByText('Diagnostics & Health')).not.toBeInTheDocument(); + expect(screen.queryByTestId('diagnostics-panel')).not.toBeInTheDocument(); + }); + + it('keeps operations tabs available outside demo mode', async () => { + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Diagnostics & Health')).toBeInTheDocument(); + }); + expect(screen.getByTestId('diagnostics-panel')).toBeInTheDocument(); + expect(navigateSpy).not.toHaveBeenCalledWith('/dashboard', { replace: true }); + }); +}); diff --git a/frontend-modern/src/features/operations/operationsPageModel.ts b/frontend-modern/src/features/operations/operationsPageModel.ts index c8c94a450..2069deaa0 100644 --- a/frontend-modern/src/features/operations/operationsPageModel.ts +++ b/frontend-modern/src/features/operations/operationsPageModel.ts @@ -24,6 +24,10 @@ export const OPERATIONS_TABS: readonly OperationsTabDefinition[] = [ }, ]; +export function operationsSurfaceHiddenInDemoMode(demoMode: boolean): boolean { + return demoMode; +} + export function getOperationsTabFromPath(pathname: string): OperationsTabId { const lastPathSegment = pathname.split('/').pop() || ''; if (lastPathSegment === 'reporting') return 'reporting'; diff --git a/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts b/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts index b7f974a2e..be2e34969 100644 --- a/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts @@ -17,8 +17,10 @@ describe('operations page route shell', () => { expect(operationsPageSurfaceSource).toContain('@/components/shared/Subtabs'); expect(operationsPageSurfaceSource).toContain('getOperationsTabFromPath'); expect(operationsPageSurfaceSource).toContain('buildOperationsPath'); + expect(operationsPageSurfaceSource).toContain('operationsSurfaceHiddenInDemoMode'); expect(operationsPageSurfaceSource).not.toContain('-webkit-overflow-scrolling'); expect(operationsPageModelSource).toContain('export const OPERATIONS_TABS'); + expect(operationsPageModelSource).toContain('export function operationsSurfaceHiddenInDemoMode'); expect(operationsPageModelSource).toContain('export function getOperationsTabFromPath'); expect(operationsPageModelSource).toContain('export function buildOperationsPath'); }); diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 5cbfe74ec..cd4ec1f36 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -6193,6 +6193,12 @@ func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { {method: http.MethodPut, path: "/api/admin/orgs/t-tenant/billing-state"}, {method: http.MethodGet, path: "/api/upgrade-metrics/stats"}, {method: http.MethodPost, path: "/api/upgrade-metrics/events"}, + {method: http.MethodGet, path: "/api/diagnostics"}, + {method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token"}, + {method: http.MethodGet, path: "/api/logs/stream"}, + {method: http.MethodGet, path: "/api/logs/download"}, + {method: http.MethodGet, path: "/api/logs/level"}, + {method: http.MethodPost, path: "/api/logs/level"}, {method: http.MethodGet, path: licensePurchaseStartPath}, {method: http.MethodGet, path: "/auth/trial-activate"}, } diff --git a/internal/api/demo_middleware.go b/internal/api/demo_middleware.go index e006d7dac..7b55a8bf7 100644 --- a/internal/api/demo_middleware.go +++ b/internal/api/demo_middleware.go @@ -26,6 +26,12 @@ func DemoModeMiddleware(cfg *config.Config, next http.Handler) http.Handler { return } } + if exposure, ok := publicDemoAdminOperationsPolicyForRequest(r); ok { + if exposure == publicDemoCommercialExposureHidden { + http.NotFound(w, r) + return + } + } // Allow GET and HEAD requests (read-only) if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { diff --git a/internal/api/demo_middleware_test.go b/internal/api/demo_middleware_test.go index 5a8ab1f69..05391c520 100644 --- a/internal/api/demo_middleware_test.go +++ b/internal/api/demo_middleware_test.go @@ -64,6 +64,12 @@ func TestDemoModeMiddleware(t *testing.T) { {"demo on hidden truenas saved preview", true, http.MethodPost, "/api/truenas/connections/conn-1/preview", "", false, http.StatusNotFound, true}, {"demo on hidden vmware draft preview", true, http.MethodPost, "/api/vmware/connections/preview", "", false, http.StatusNotFound, true}, {"demo on hidden vmware saved preview", true, http.MethodPost, "/api/vmware/connections/conn-1/preview", "", false, http.StatusNotFound, true}, + {"demo on hidden diagnostics", true, http.MethodGet, "/api/diagnostics", "", false, http.StatusNotFound, true}, + {"demo on hidden diagnostics token prepare", true, http.MethodPost, "/api/diagnostics/docker/prepare-token", "", false, http.StatusNotFound, true}, + {"demo on hidden logs stream", true, http.MethodGet, "/api/logs/stream", "", false, http.StatusNotFound, true}, + {"demo on hidden logs download", true, http.MethodGet, "/api/logs/download", "", false, http.StatusNotFound, true}, + {"demo on hidden logs level read", true, http.MethodGet, "/api/logs/level", "", false, http.StatusNotFound, true}, + {"demo on hidden logs level write", true, http.MethodPost, "/api/logs/level", "", false, http.StatusNotFound, true}, {"demo on hidden checkout start", true, http.MethodGet, "/auth/license-purchase-start", "", false, http.StatusNotFound, true}, {"demo on hidden license activate", true, http.MethodPost, "/api/license/activate", "", false, http.StatusNotFound, true}, {"demo on hidden purchase start", true, http.MethodGet, licensePurchaseStartPath, "", false, http.StatusNotFound, true}, diff --git a/internal/api/demo_mode_operations.go b/internal/api/demo_mode_operations.go new file mode 100644 index 000000000..c4f30dcd9 --- /dev/null +++ b/internal/api/demo_mode_operations.go @@ -0,0 +1,47 @@ +package api + +import "net/http" + +var publicDemoAdminOperationsPolicies = []publicDemoCommercialRoutePolicy{ + { + route: "GET /api/diagnostics", + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/diagnostics"), + }, + { + route: "POST /api/diagnostics/docker/prepare-token", + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodPost, "/api/diagnostics/docker/prepare-token"), + }, + { + route: "GET /api/logs/stream", + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/logs/stream"), + }, + { + route: "GET /api/logs/download", + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/logs/download"), + }, + { + route: "GET /api/logs/level", + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodGet, "/api/logs/level"), + }, + { + route: "POST /api/logs/level", + exposure: publicDemoCommercialExposureHidden, + matches: exactDemoCommercialMethodPath(http.MethodPost, "/api/logs/level"), + }, +} + +func publicDemoAdminOperationsPolicyForRequest( + r *http.Request, +) (publicDemoCommercialExposure, bool) { + for _, policy := range publicDemoAdminOperationsPolicies { + if policy.matches != nil && policy.matches(r) { + return policy.exposure, true + } + } + return "", false +} diff --git a/internal/api/demo_mode_operations_test.go b/internal/api/demo_mode_operations_test.go new file mode 100644 index 000000000..de1198b94 --- /dev/null +++ b/internal/api/demo_mode_operations_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPublicDemoAdminOperationsPolicyForRequest(t *testing.T) { + hiddenRoutes := []struct { + name string + method string + path string + }{ + {name: "diagnostics", method: http.MethodGet, path: "/api/diagnostics"}, + {name: "diagnostics token prepare", method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token"}, + {name: "logs stream", method: http.MethodGet, path: "/api/logs/stream"}, + {name: "logs download", method: http.MethodGet, path: "/api/logs/download"}, + {name: "logs level read", method: http.MethodGet, path: "/api/logs/level"}, + {name: "logs level write", method: http.MethodPost, path: "/api/logs/level"}, + } + + for _, tc := range hiddenRoutes { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + exposure, ok := publicDemoAdminOperationsPolicyForRequest(req) + if !ok { + t.Fatalf("%s %s did not match public demo admin operations policy", tc.method, tc.path) + } + if exposure != publicDemoCommercialExposureHidden { + t.Fatalf("%s %s exposure=%q, want %q", tc.method, tc.path, exposure, publicDemoCommercialExposureHidden) + } + }) + } + + allowedRoutes := []struct { + name string + method string + path string + }{ + {name: "health", method: http.MethodGet, path: "/api/health"}, + {name: "resources", method: http.MethodGet, path: "/api/resources"}, + {name: "runtime capabilities", method: http.MethodGet, path: "/api/license/runtime-capabilities"}, + } + + for _, tc := range allowedRoutes { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + if exposure, ok := publicDemoAdminOperationsPolicyForRequest(req); ok { + t.Fatalf("%s %s unexpectedly matched public demo admin operations policy with exposure=%q", tc.method, tc.path, exposure) + } + }) + } +}