From 588dd3999b9fedb528d9f260f1d14e614d15fcc9 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:54:17 +0200 Subject: [PATCH] Display rendered configuration for environment --- .../environments/EnvConfigPage.test.tsx | 209 ++++++++++++++++++ .../features/environments/EnvConfigPage.tsx | 20 +- 2 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 frontend/src/features/environments/EnvConfigPage.test.tsx diff --git a/frontend/src/features/environments/EnvConfigPage.test.tsx b/frontend/src/features/environments/EnvConfigPage.test.tsx new file mode 100644 index 00000000..fa34e308 --- /dev/null +++ b/frontend/src/features/environments/EnvConfigPage.test.tsx @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRouter, + createRoute, + createRootRoute, + RouterProvider, + Outlet, +} from '@tanstack/react-router'; +import { EnvConfigPage } from './EnvConfigPage'; +import type { TLSEnvironment, EnvConfigResponse } from '$/api/environments'; + +const { + mockGetEnvironment, + mockGetConfig, + mockGetAssembledConfig, + mockPatchConfig, + mockPatchIntervals, + mockPatchExpiration, +} = vi.hoisted(() => ({ + mockGetEnvironment: vi.fn<() => Promise>(), + mockGetConfig: vi.fn<() => Promise>(), + mockGetAssembledConfig: vi.fn<() => Promise<{ data: string }>>(), + mockPatchConfig: vi.fn(), + mockPatchIntervals: vi.fn(), + mockPatchExpiration: vi.fn(), +})); + +vi.mock('$/api/environments', () => ({ + getEnvironment: mockGetEnvironment, + getEnvironmentConfig: mockGetConfig, + getEnvironmentAssembledConfig: mockGetAssembledConfig, + patchEnvironmentConfig: (...args: unknown[]) => mockPatchConfig(...args), + patchEnvironmentIntervals: (...args: unknown[]) => mockPatchIntervals(...args), + patchEnvironmentExpiration: (...args: unknown[]) => mockPatchExpiration(...args), +})); + +vi.mock('$/api/client', () => ({ + AuthError: class AuthError extends Error { + readonly status = 401; + constructor() { + super('Unauthorized'); + } + }, + ApiError: class ApiError extends Error { + constructor(msg: string, public status: number, public code?: string) { + super(msg); + } + }, +})); + +vi.mock('$/components/forms/CodeEditor', () => ({ + CodeEditor: ({ value, 'aria-label': ariaLabel }: { value: string; 'aria-label'?: string }) => ( +
+ {value} +
+ ), +})); + +function makeEnv(overrides: Partial = {}): TLSEnvironment { + return { + id: 1, + created_at: new Date(Date.now() - 600_000).toISOString(), + updated_at: new Date().toISOString(), + uuid: '00000000-0000-0000-0000-000000000001', + name: 'dev', + hostname: 'osctrl.example.com', + secret: '', + enroll_secret_path: '', + enroll_expire: '', + remove_secret_path: '', + remove_expire: '', + type: 'osquery', + deb_package: '', + rpm_package: '', + msi_package: '', + pkg_package: '', + debug_http: false, + icon: 'fas fa-wrench', + options: '{}', + schedule: '{}', + packs: '{}', + decorators: '{}', + atc: '{}', + configuration: '', + flags: '', + certificate: '', + config_tls: true, + config_interval: 300, + logging_tls: true, + log_interval: 600, + query_tls: true, + query_interval: 60, + carves_tls: true, + enroll_path: 'enroll', + log_path: 'log', + config_path: 'config', + query_read_path: 'read', + query_write_path: 'write', + carver_init_path: 'init', + carver_block_path: 'block', + accept_enrolls: true, + user_id: 1, + ...overrides, + }; +} + +function makeRouter(initialPath = '/_app/env/dev/config') { + const rootRoute = createRootRoute({ component: Outlet }); + const appRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/_app', + component: Outlet, + }); + const envRoute = createRoute({ + getParentRoute: () => appRoute, + path: 'env/$env', + component: Outlet, + }); + const configRoute = createRoute({ + getParentRoute: () => envRoute, + path: 'config', + component: EnvConfigPage, + }); + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: () =>
Login
, + }); + + const routeTree = rootRoute.addChildren([ + appRoute.addChildren([envRoute.addChildren([configRoute])]), + loginRoute, + ]); + const history = createMemoryHistory({ initialEntries: [initialPath] }); + return createRouter({ routeTree, history }); +} + +function renderWithProviders() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const router = makeRouter(); + return render( + + + , + ); +} + +describe('EnvConfigPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetEnvironment.mockResolvedValue(makeEnv()); + mockGetConfig.mockResolvedValue({ + options: '{"logger_plugin":"tls"}', + schedule: '{}', + packs: '{}', + decorators: '{}', + atc: '{}', + flags: '--tls_hostname=osctrl.example.com', + }); + mockGetAssembledConfig.mockResolvedValue({ + data: '{"options":{"logger_plugin":"tls"}}', + }); + }); + + it('loads the fully rendered tab from the assembled config endpoint', async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Settings' })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('tab', { name: 'Full Configuration' })); + + await waitFor(() => { + expect(mockGetAssembledConfig).toHaveBeenCalledWith('dev'); + }); + + expect(screen.getByText('Assembled configuration')).toBeInTheDocument(); + expect(screen.getByText('{"options":{"logger_plugin":"tls"}}')).toBeInTheDocument(); + }); + + it('refetches the full configuration when clicking the tab again', async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + const tab = await screen.findByRole('tab', { name: 'Full Configuration' }); + + await user.click(tab); + + await waitFor(() => { + expect(mockGetAssembledConfig).toHaveBeenCalledTimes(1); + }); + + await user.click(tab); + + await waitFor(() => { + expect(mockGetAssembledConfig).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/src/features/environments/EnvConfigPage.tsx b/frontend/src/features/environments/EnvConfigPage.tsx index e4b8fe83..b3ffcc2f 100644 --- a/frontend/src/features/environments/EnvConfigPage.tsx +++ b/frontend/src/features/environments/EnvConfigPage.tsx @@ -16,6 +16,7 @@ import { cn } from '$/lib/cn'; import { CodeEditor } from '$/components/forms/CodeEditor'; import { DiffView } from '$/components/forms/DiffView'; import { DocsLink } from '$/components/atoms/DocsLink'; +import { AssembledConfigCard } from '$/features/enrollment/AssembledConfigCard'; type SectionKey = 'options' | 'schedule' | 'packs' | 'decorators' | 'atc' | 'flags'; @@ -114,11 +115,11 @@ export function EnvConfigPage() { flags: false, }); - // Page is tabbed: 'settings' (intervals + expiration) plus one tab per + // Page is tabbed: 'settings' (intervals + expiration), one tab per // config SECTION. The "settings" default keeps the slider-based forms // up-front so an operator who lands here to tune pull intervals doesn't // scroll past six 280px Monaco editors first. - type TabKey = 'settings' | SectionKey; + type TabKey = 'settings' | SectionKey | 'assembled'; const [activeTab, setActiveTab] = useState('settings'); useEffect(() => { @@ -293,6 +294,17 @@ export function EnvConfigPage() { onClick={() => setActiveTab(key)} /> ))} + { + if (activeTab === 'assembled') { + void qc.invalidateQueries({ queryKey: ['env', env, 'assembled-config'] }); + } + setActiveTab('assembled'); + }} + /> {saveErr && ( @@ -318,6 +330,10 @@ export function EnvConfigPage() { )} + {activeTab === 'assembled' && ( + + )} + {SECTIONS.map(({ key, label, language, help, docsUrl }) => { if (activeTab !== key) return null; const isDirty = dirty.has(key);