diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx index eba056b0..56f73518 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx @@ -10,6 +10,8 @@ import * as crowdsecApi from '../../api/crowdsec' import * as backupsApi from '../../api/backups' import * as settingsApi from '../../api/settings' import * as presetsApi from '../../api/presets' +import * as featureFlagsApi from '../../api/featureFlags' +import * as consoleApi from '../../api/consoleEnrollment' import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets' vi.mock('../../api/security') @@ -17,6 +19,8 @@ vi.mock('../../api/crowdsec') vi.mock('../../api/backups') vi.mock('../../api/settings') vi.mock('../../api/presets') +vi.mock('../../api/featureFlags') +vi.mock('../../api/consoleEnrollment') const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) const renderWithProviders = (ui: React.ReactNode) => { @@ -63,6 +67,11 @@ describe('CrowdSecConfig', () => { }) vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' }) vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] }) + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ + 'feature.crowdsec.console_enrollment': false, + }) + vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'not_enrolled', key_present: false }) + vi.mocked(consoleApi.enrollConsole).mockResolvedValue({ status: 'enrolling', key_present: true }) }) it('exports config when clicking Export', async () => { @@ -94,6 +103,103 @@ describe('CrowdSecConfig', () => { await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled()) }) + it('hides console enrollment when feature flag is off', async () => { + vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()) + expect(screen.queryByTestId('console-enrollment-card')).not.toBeInTheDocument() + }) + + it('shows console enrollment form when feature flag is on', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true }) + vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('console-enrollment-card')).toBeInTheDocument()) + expect(screen.getByTestId('console-enrollment-token')).toBeInTheDocument() + }) + + it('validates required console enrollment fields and acknowledgement', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true }) + vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + + renderWithProviders() + + const enrollBtn = await screen.findByTestId('console-enroll-btn') + await userEvent.click(enrollBtn) + + const errors = await screen.findAllByTestId('console-enroll-error') + expect(errors.length).toBeGreaterThan(0) + expect(consoleApi.enrollConsole).not.toHaveBeenCalled() + }) + + it('submits console enrollment payload with snake_case fields', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true }) + vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + vi.mocked(consoleApi.enrollConsole).mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'agent-one', tenant: 'tenant-inc' }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('console-enrollment-card')).toBeInTheDocument()) + await userEvent.type(screen.getByTestId('console-enrollment-token'), 'secret-1234567890') + await userEvent.clear(screen.getByTestId('console-agent-name')) + await userEvent.type(screen.getByTestId('console-agent-name'), 'agent-one') + await userEvent.type(screen.getByTestId('console-tenant'), 'tenant-inc') + await userEvent.click(screen.getByTestId('console-ack-checkbox')) + await userEvent.click(screen.getByTestId('console-enroll-btn')) + + await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith({ + enrollment_key: 'secret-1234567890', + agent_name: 'agent-one', + tenant: 'tenant-inc', + force: false, + })) + + expect((screen.getByTestId('console-enrollment-token') as HTMLInputElement).value).toBe('') + }) + + it('renders masked key state in console status', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true }) + vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'a1', tenant: 't1', last_heartbeat_at: '2024-01-01T00:00:00Z' }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('console-token-state')).toHaveTextContent('Stored (masked)')) + }) + + it('retries degraded enrollment and rotates key when enrolled', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true }) + vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + vi.mocked(consoleApi.getConsoleStatus).mockResolvedValueOnce({ status: 'failed', key_present: true, last_error: 'network' }) + vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'enrolled', key_present: true }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('console-ack-checkbox')).toBeInTheDocument()) + await userEvent.type(screen.getByTestId('console-enrollment-token'), 'another-secret-123456') + await userEvent.click(screen.getByTestId('console-ack-checkbox')) + await userEvent.click(screen.getByTestId('console-retry-btn')) + await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith(expect.objectContaining({ force: true }))) + + await waitFor(() => expect(screen.getByTestId('console-rotate-btn')).not.toBeDisabled()) + await userEvent.type(screen.getByTestId('console-enrollment-token'), 'rotate-token-987654321') + await userEvent.click(screen.getByTestId('console-rotate-btn')) + await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith(expect.objectContaining({ + enrollment_key: 'rotate-token-987654321', + force: true, + }))) + }) + it('lists files, reads file content and can save edits (backup before save)', async () => { const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } } vi.mocked(api.getSecurityStatus).mockResolvedValue(status) diff --git a/frontend/src/pages/__tests__/SystemSettings.test.tsx b/frontend/src/pages/__tests__/SystemSettings.test.tsx index 9f557864..4f9888fc 100644 --- a/frontend/src/pages/__tests__/SystemSettings.test.tsx +++ b/frontend/src/pages/__tests__/SystemSettings.test.tsx @@ -65,6 +65,7 @@ describe('SystemSettings', () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, 'feature.uptime.enabled': false, }) @@ -397,6 +398,7 @@ describe('SystemSettings', () => { it('displays Cerberus Security Suite toggle', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.cerberus.enabled': true, + 'feature.crowdsec.console_enrollment': false, 'feature.uptime.enabled': false, }) @@ -411,10 +413,32 @@ describe('SystemSettings', () => { expect(tooltipParent?.getAttribute('title')).toContain('Advanced security features') }) + it('displays CrowdSec Console Enrollment toggle', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ + 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': true, + 'feature.uptime.enabled': false, + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('CrowdSec Console Enrollment')).toBeTruthy() + }) + + const crowdsecLabel = screen.getByText('CrowdSec Console Enrollment') + const tooltipParent = crowdsecLabel.closest('[title]') as HTMLElement + expect(tooltipParent?.getAttribute('title')).toContain('CrowdSec Console') + + const switchInput = tooltipParent?.querySelector('input[type="checkbox"]') as HTMLInputElement + expect(switchInput?.checked).toBe(true) + }) + it('displays Uptime Monitoring toggle', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.uptime.enabled': true, 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, }) renderWithProviders() @@ -431,6 +455,7 @@ describe('SystemSettings', () => { it('shows Cerberus toggle as checked when enabled', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.cerberus.enabled': true, + 'feature.crowdsec.console_enrollment': false, 'feature.uptime.enabled': false, }) @@ -451,6 +476,7 @@ describe('SystemSettings', () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.uptime.enabled': true, 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, }) renderWithProviders() @@ -468,6 +494,7 @@ describe('SystemSettings', () => { it('shows Cerberus toggle as unchecked when disabled', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, 'feature.uptime.enabled': false, }) @@ -486,6 +513,7 @@ describe('SystemSettings', () => { it('toggles Cerberus feature flag when switch is clicked', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, 'feature.uptime.enabled': false, }) vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined) @@ -510,10 +538,39 @@ describe('SystemSettings', () => { }) }) + it('toggles CrowdSec Console Enrollment feature flag when switch is clicked', async () => { + vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ + 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, + 'feature.uptime.enabled': false, + }) + vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('CrowdSec Console Enrollment')).toBeTruthy() + }) + + const user = userEvent.setup() + const crowdsecLabel = screen.getByText('CrowdSec Console Enrollment') + const parentDiv = crowdsecLabel.closest('.flex') + const switchInput = parentDiv?.querySelector('input[type="checkbox"]') as HTMLInputElement + + await user.click(switchInput) + + await waitFor(() => { + expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({ + 'feature.crowdsec.console_enrollment': true, + }) + }) + }) + it('toggles Uptime feature flag when switch is clicked', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.uptime.enabled': true, 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, }) vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined) @@ -552,6 +609,7 @@ describe('SystemSettings', () => { it('shows loading overlay while toggling a feature flag', async () => { vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.cerberus.enabled': false, + 'feature.crowdsec.console_enrollment': false, 'feature.uptime.enabled': false, }) vi.mocked(featureFlagsApi.updateFeatureFlags).mockImplementation(