-
Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.
-
+
Upload a tar.gz or zip package. A backup is created before importing so you can roll back if needed. Export the current package from the Cerberus dashboard or CrowdSec config page.
+
diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx
index e48130c5..9c74c89e 100644
--- a/frontend/src/pages/Security.tsx
+++ b/frontend/src/pages/Security.tsx
@@ -11,6 +11,7 @@ import { toast } from '../utils/toast'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { ConfigReloadOverlay } from '../components/LoadingStates'
+import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
export default function Security() {
const navigate = useNavigate()
@@ -65,36 +66,9 @@ export default function Security() {
},
})
- const toggleCerberusMutation = useMutation({
- mutationFn: async (enabled: boolean) => {
- await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
- },
- onMutate: async (enabled: boolean) => {
- await queryClient.cancelQueries({ queryKey: ['security-status'] })
- const previous = queryClient.getQueryData(['security-status'])
- if (previous) {
- queryClient.setQueryData(['security-status'], (old: unknown) => {
- const copy = JSON.parse(JSON.stringify(old)) as SecurityStatus
- if (!copy.cerberus) copy.cerberus = { enabled: false }
- copy.cerberus.enabled = enabled
- return copy
- })
- }
- return { previous }
- },
- onError: (_err, _vars, context: unknown) => {
- if (context && typeof context === 'object' && 'previous' in context) {
- queryClient.setQueryData(['security-status'], context.previous)
- }
- },
- // onSuccess: already set below
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['settings'] })
- queryClient.invalidateQueries({ queryKey: ['security-status'] })
- },
- })
const fetchCrowdsecStatus = async () => {
+
try {
const s = await statusCrowdsec()
setCrowdsecStatus(s)
@@ -105,31 +79,76 @@ export default function Security() {
useEffect(() => { fetchCrowdsecStatus() }, [])
- const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
- const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
+ const handleCrowdsecExport = async () => {
+ const defaultName = buildCrowdsecExportFilename()
+ const filename = promptCrowdsecFilename(defaultName)
+ if (!filename) return
+
+ try {
+ const resp = await exportCrowdsecConfig()
+ downloadCrowdsecExport(resp, filename)
+ toast.success('CrowdSec configuration exported')
+ } catch {
+ toast.error('Failed to export CrowdSec configuration')
+ }
+ }
+
+ const crowdsecPowerMutation = useMutation({
+ mutationFn: async (enabled: boolean) => {
+ await updateSetting('security.crowdsec.enabled', enabled ? 'true' : 'false', 'security', 'bool')
+ if (enabled) {
+ await startCrowdsec()
+ } else {
+ await stopCrowdsec()
+ }
+ return enabled
+ },
+ onMutate: async (enabled: boolean) => {
+ await queryClient.cancelQueries({ queryKey: ['security-status'] })
+ const previous = queryClient.getQueryData(['security-status'])
+ queryClient.setQueryData(['security-status'], (old: unknown) => {
+ if (!old || typeof old !== 'object') return old
+ const copy = { ...(old as SecurityStatus) }
+ if (copy.crowdsec && typeof copy.crowdsec === 'object') {
+ copy.crowdsec = { ...copy.crowdsec, enabled } as never
+ }
+ return copy
+ })
+ setCrowdsecStatus(prev => prev ? { ...prev, running: enabled } : prev)
+ return { previous }
+ },
+ onError: (err: unknown, enabled: boolean, context: unknown) => {
+ if (context && typeof context === 'object' && 'previous' in context) {
+ queryClient.setQueryData(['security-status'], context.previous)
+ }
+ const msg = err instanceof Error ? err.message : String(err)
+ toast.error(enabled ? `Failed to start CrowdSec: ${msg}` : `Failed to stop CrowdSec: ${msg}`)
+ fetchCrowdsecStatus()
+ },
+ onSuccess: async (enabled: boolean) => {
+ await fetchCrowdsecStatus()
+ queryClient.invalidateQueries({ queryKey: ['security-status'] })
+ queryClient.invalidateQueries({ queryKey: ['settings'] })
+ toast.success(enabled ? 'CrowdSec started' : 'CrowdSec stopped')
+ },
+ })
// Determine if any security operation is in progress
const isApplyingConfig =
- toggleCerberusMutation.isPending ||
toggleServiceMutation.isPending ||
updateSecurityConfigMutation.isPending ||
generateBreakGlassMutation.isPending ||
- startMutation.isPending ||
- stopMutation.isPending
+ crowdsecPowerMutation.isPending
// Determine contextual message
const getMessage = () => {
- if (toggleCerberusMutation.isPending) {
- return { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
- }
if (toggleServiceMutation.isPending) {
- return { message: 'Three heads turn...', submessage: 'Security configuration updating' }
+ return { message: 'Three heads turn...', submessage: 'Cerberus configuration updating' }
}
- if (startMutation.isPending) {
- return { message: 'Summoning the guardian...', submessage: 'Intrusion prevention rising' }
- }
- if (stopMutation.isPending) {
- return { message: 'Guardian rests...', submessage: 'Intrusion prevention pausing' }
+ if (crowdsecPowerMutation.isPending) {
+ return crowdsecPowerMutation.variables
+ ? { message: 'Summoning the guardian...', submessage: 'CrowdSec is starting' }
+ : { message: 'Guardian rests...', submessage: 'CrowdSec is stopping' }
}
return { message: 'Strengthening the guard...', submessage: 'Protective wards activating' }
}
@@ -144,6 +163,10 @@ export default function Security() {
return
Failed to load security status
}
+ const cerberusDisabled = !status.cerberus?.enabled
+ const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
+ const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
+
// const suiteDisabled = !(status?.cerberus?.enabled ?? false)
// Replace the previous early-return that instructed enabling via env vars.
@@ -152,10 +175,10 @@ export default function Security() {
-
Security Suite Disabled
+ Cerberus Disabled
- Charon supports advanced security features (CrowdSec, WAF, ACLs, Rate Limiting). Enable the global Cerberus toggle in System Settings and activate individual services below.
+ Cerberus powers CrowdSec, WAF, ACLs, and Rate Limiting. Enable the Cerberus toggle in System Settings to awaken the guardian, then configure each head below.
diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx
index b7a846a9..d41c0eb2 100644
--- a/frontend/src/pages/SystemSettings.tsx
+++ b/frontend/src/pages/SystemSettings.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
@@ -10,6 +10,7 @@ import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
import client from '../api/client'
// CrowdSec runtime control is now in the Security page
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
+import { ConfigReloadOverlay } from '../components/LoadingStates'
interface HealthResponse {
status: string
@@ -96,6 +97,22 @@ export default function SystemSettings() {
queryFn: getFeatureFlags,
})
+ const featureToggles = useMemo(
+ () => [
+ {
+ key: 'feature.cerberus.enabled',
+ label: 'Cerberus Security Suite',
+ tooltip: 'Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.',
+ },
+ {
+ key: 'feature.uptime.enabled',
+ label: 'Uptime Monitoring',
+ tooltip: 'Monitor the availability of your proxy hosts and remote servers.',
+ },
+ ],
+ []
+ )
+
const updateFlagMutation = useMutation({
mutationFn: async (payload: Record
) => updateFeatureFlags(payload),
onSuccess: () => {
@@ -110,16 +127,54 @@ export default function SystemSettings() {
// CrowdSec control
+ // Determine loading message
+ const { message, submessage } = updateFlagMutation.isPending
+ ? { message: 'Updating features...', submessage: 'Applying configuration changes' }
+ : { message: 'Loading...', submessage: 'Please wait' }
return (
-
-
-
- System Settings
-
+ <>
+ {updateFlagMutation.isPending && (
+
+ )}
+
+
+
+ System Settings
+
- {/* General Configuration */}
-
+ {/* Features */}
+
+ Features
+
+ {featureFlags ? (
+ featureToggles.map(({ key, label, tooltip }) => (
+
+
{label}
+
updateFlagMutation.mutate({ [key]: e.target.checked })}
+ />
+
+ ))
+ ) : (
+
Loading features...
+ )}
+
+
+
+ {/* General Configuration */}
+
General Configuration
- {/* Optional Features */}
-
- Optional Features
-
- {featureFlags ? (
- <>
- {/* Cerberus */}
-
-
-
Cerberus Security Suite
-
- Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.
-
-
-
updateFlagMutation.mutate({ 'feature.cerberus.enabled': e.target.checked })}
- />
-
+ {/* Optional Features - Removed (Moved to top) */}
- {/* Uptime */}
-
-
-
Uptime Monitoring
-
- Monitor the availability of your proxy hosts and remote servers.
-
-
-
updateFlagMutation.mutate({ 'feature.uptime.enabled': e.target.checked })}
- />
-
- >
- ) : (
-
Loading features...
- )}
-
-
{/* System Status */}
@@ -325,5 +343,6 @@ export default function SystemSettings() {
+ >
)
}
diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx
index bfef1290..373b22ab 100644
--- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx
+++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { AxiosError } from 'axios'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -8,11 +9,14 @@ import * as api from '../../api/security'
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 { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
+vi.mock('../../api/presets')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
@@ -27,13 +31,46 @@ const renderWithProviders = (ui: React.ReactNode) => {
}
describe('CrowdSecConfig', () => {
- beforeEach(() => vi.clearAllMocks())
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
+ presets: CROWDSEC_PRESETS.map((preset) => ({
+ slug: preset.slug,
+ title: preset.title,
+ summary: preset.description,
+ source: 'charon',
+ requires_hub: false,
+ available: true,
+ cached: false,
+ })),
+ })
+ vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
+ status: 'pulled',
+ slug: 'bot-mitigation-essentials',
+ preview: CROWDSEC_PRESETS[0].content,
+ cache_key: 'cache-123',
+ etag: 'etag-123',
+ retrieved_at: '2024-01-01T00:00:00Z',
+ source: 'hub',
+ })
+ vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
+ status: 'applied',
+ backup: '/tmp/backup.tar.gz',
+ reload_hint: 'CrowdSec reloaded',
+ used_cscli: true,
+ cache_key: 'cache-123',
+ slug: 'bot-mitigation-essentials',
+ })
+ vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' })
+ vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
+ })
it('exports config when clicking Export', async () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
+ vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
renderWithProviders()
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
@@ -69,8 +106,7 @@ describe('CrowdSecConfig', () => {
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
// wait for file list
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
- const selects = screen.getAllByRole('combobox')
- const select = selects[1]
+ const select = screen.getByTestId('crowdsec-file-select')
await userEvent.selectOptions(select, 'conf.d/a.conf')
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
// ensure textarea populated
@@ -93,9 +129,123 @@ describe('CrowdSecConfig', () => {
renderWithProviders()
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
- const selects = screen.getAllByRole('combobox')
- const modeSelect = selects[0]
- await userEvent.selectOptions(modeSelect, 'local')
+ const modeToggle = screen.getByTestId('crowdsec-mode-toggle')
+ await userEvent.click(modeToggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
})
+
+ it('renders preset preview and applies with backup when backend apply is unavailable', 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 } }
+ const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || ''
+ vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
+ vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
+ vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
+ vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
+ vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
+ const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, {
+ status: 501,
+ statusText: 'Not Implemented',
+ headers: {},
+ config: {},
+ data: {},
+ } as any)
+ vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError)
+
+ renderWithProviders()
+ await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:'))
+ const fileSelect = screen.getByTestId('crowdsec-file-select')
+ await userEvent.selectOptions(fileSelect, 'acquis.yaml')
+ const applyBtn = screen.getByTestId('apply-preset-btn')
+ await userEvent.click(applyBtn)
+
+ await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' }))
+ await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
+ await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent))
+ })
+
+ it('surfaces validation error when slug is invalid', 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)
+ vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
+ const validationError = new AxiosError('invalid', undefined, undefined, undefined, {
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ config: {},
+ data: { error: 'slug invalid' },
+ } as any)
+ vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError)
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid'))
+ })
+
+ it('disables apply and offers cached preview when hub is unavailable', 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)
+ vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
+ vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
+ presets: [
+ {
+ slug: 'hub-only',
+ title: 'Hub Only',
+ summary: 'Needs hub',
+ source: 'hub',
+ requires_hub: true,
+ available: true,
+ cached: true,
+ cache_key: 'cache-hub',
+ etag: 'etag-hub',
+ },
+ ],
+ })
+ const hubError = new AxiosError('unavailable', undefined, undefined, undefined, {
+ status: 503,
+ statusText: 'Service Unavailable',
+ headers: {},
+ config: {},
+ data: { error: 'hub service unavailable' },
+ } as any)
+ vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError)
+ vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' })
+
+ renderWithProviders()
+
+ const select = await screen.findByTestId('preset-select')
+ await waitFor(() => expect(screen.getByText('Hub Only')).toBeInTheDocument())
+ await userEvent.selectOptions(select, 'hub-only')
+
+ await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
+
+ const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement
+ expect(applyBtn.disabled).toBe(true)
+
+ await userEvent.click(screen.getByText('Use cached preview'))
+ await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
+ })
+
+ it('shows apply response metadata including backup path', 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)
+ vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
+ vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
+ vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({
+ status: 'applied',
+ backup: '/tmp/crowdsec-backup',
+ reload_hint: 'crowdsec reloaded',
+ used_cscli: true,
+ cache_key: 'cache-123',
+ slug: 'bot-mitigation-essentials',
+ })
+
+ renderWithProviders()
+
+ const applyBtn = await screen.findByTestId('apply-preset-btn')
+ await userEvent.click(applyBtn)
+
+ await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup'))
+ expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec reloaded')
+ })
})
diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
new file mode 100644
index 00000000..a3bbb7de
--- /dev/null
+++ b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
@@ -0,0 +1,118 @@
+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 { MemoryRouter } from 'react-router-dom'
+import CrowdSecConfig from '../CrowdSecConfig'
+import * as securityApi from '../../api/security'
+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 { toast } from '../../utils/toast'
+
+vi.mock('../../api/security')
+vi.mock('../../api/crowdsec')
+vi.mock('../../api/backups')
+vi.mock('../../api/settings')
+vi.mock('../../api/presets')
+vi.mock('../../utils/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}))
+
+describe('CrowdSecConfig', () => {
+ const createClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+ const renderWithProviders = () => {
+ const queryClient = createClient()
+ return render(
+
+
+
+
+
+ )
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ cerberus: { enabled: true },
+ crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
+ waf: { mode: 'enabled', enabled: true },
+ rate_limit: { enabled: true },
+ acl: { enabled: true },
+ })
+ vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
+ vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
+ vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
+ vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
+ vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
+ vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
+ vi.mocked(settingsApi.updateSetting).mockResolvedValue()
+ vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
+ vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
+ vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
+ status: 'pulled',
+ slug: 'bot-mitigation-essentials',
+ preview: 'configs: {}',
+ cache_key: 'cache-123',
+ })
+ vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' })
+ vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' })
+ vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
+ window.URL.createObjectURL = vi.fn(() => 'blob:url')
+ window.URL.revokeObjectURL = vi.fn()
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ })
+
+ it('toggles mode between local and disabled', async () => {
+ renderWithProviders()
+
+ await waitFor(() => screen.getByTestId('crowdsec-mode-toggle'))
+ const toggle = screen.getByTestId('crowdsec-mode-toggle')
+
+ await userEvent.click(toggle)
+
+ await waitFor(() => {
+ expect(settingsApi.updateSetting).toHaveBeenCalledWith(
+ 'security.crowdsec.mode',
+ 'disabled',
+ 'security',
+ 'string'
+ )
+ expect(toast.success).toHaveBeenCalledWith('CrowdSec disabled')
+ })
+ })
+
+ it('exports configuration packages with prompted filename', async () => {
+ renderWithProviders()
+
+ await waitFor(() => screen.getByRole('button', { name: /Export/i }))
+ const exportButton = screen.getByRole('button', { name: /Export/i })
+
+ await userEvent.click(exportButton)
+
+ await waitFor(() => {
+ expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
+ expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
+ })
+ })
+
+ it('shows Configuration Packages heading', async () => {
+ renderWithProviders()
+
+ await waitFor(() => screen.getByText('Configuration Packages'))
+
+ expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
new file mode 100644
index 00000000..db4308c3
--- /dev/null
+++ b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
@@ -0,0 +1,64 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import ImportCrowdSec from '../ImportCrowdSec'
+import * as crowdsecApi from '../../api/crowdsec'
+import * as backupsApi from '../../api/backups'
+import { toast } from 'react-hot-toast'
+
+vi.mock('../../api/crowdsec')
+vi.mock('../../api/backups')
+vi.mock('react-hot-toast', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
+ },
+}))
+
+describe('ImportCrowdSec', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
+ vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
+ })
+
+ const renderPage = () => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
+ return render(
+
+
+
+
+
+ )
+ }
+
+ it('renders configuration packages heading', async () => {
+ renderPage()
+
+ await waitFor(() => screen.getByText('CrowdSec Configuration Packages'))
+ expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument()
+ })
+
+ it('creates a backup before importing selected package', async () => {
+ renderPage()
+
+ const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
+ const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
+
+ await userEvent.upload(fileInput, file)
+
+ const importButton = screen.getByRole('button', { name: /Import/i })
+ await userEvent.click(importButton)
+
+ await waitFor(() => {
+ expect(backupsApi.createBackup).toHaveBeenCalled()
+ expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file)
+ expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported')
+ })
+ })
+})
diff --git a/frontend/src/pages/__tests__/Security.audit.test.tsx b/frontend/src/pages/__tests__/Security.audit.test.tsx
index d816f75f..5e1719df 100644
--- a/frontend/src/pages/__tests__/Security.audit.test.tsx
+++ b/frontend/src/pages/__tests__/Security.audit.test.tsx
@@ -2,10 +2,10 @@
* Security Page - QA Security Audit Tests
*
* Tests edge cases, input validation, error states, and security concerns
- * for the Security Dashboard implementation.
+ * for the Cerberus Dashboard implementation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, waitFor } from '@testing-library/react'
+import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
@@ -15,6 +15,14 @@ import * as crowdsecApi from '../../api/crowdsec'
import * as settingsApi from '../../api/settings'
import { toast } from '../../utils/toast'
+const mockSecurityStatus = {
+ cerberus: { enabled: true },
+ crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
+ waf: { mode: 'enabled' as const, enabled: true },
+ rate_limit: { enabled: true },
+ acl: { enabled: true },
+}
+
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/settings')
@@ -46,6 +54,12 @@ describe('Security Page - QA Security Audit', () => {
},
})
vi.clearAllMocks()
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
+ vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
+ vi.mocked(settingsApi.updateSetting).mockResolvedValue()
+ vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -54,12 +68,10 @@ describe('Security Page - QA Security Audit', () => {
)
- const mockSecurityStatus = {
- cerberus: { enabled: true },
- crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
- waf: { mode: 'enabled' as const, enabled: true },
- rate_limit: { enabled: true },
- acl: { enabled: true }
+ const renderSecurityPage = async () => {
+ await act(async () => {
+ render(, { wrapper })
+ })
}
describe('Input Validation', () => {
@@ -68,9 +80,9 @@ describe('Security Page - QA Security Audit', () => {
// won't execute. This test verifies that property.
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// DOM should not contain any actual script elements from user input
expect(document.querySelectorAll('script[src*="alert"]').length).toBe(0)
@@ -82,9 +94,9 @@ describe('Security Page - QA Security Audit', () => {
it('handles empty admin whitelist gracefully', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Empty whitelist input should exist and be empty
const whitelistInput = screen.getByDisplayValue('')
@@ -98,28 +110,31 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network error'))
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await user.click(toggle)
await waitFor(() => {
- expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
+ expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
})
})
it('handles CrowdSec start failure gracefully', async () => {
const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ ...mockSecurityStatus,
+ crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
+ })
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start'))
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-start'))
- const startButton = screen.getByTestId('crowdsec-start')
- await user.click(startButton)
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await user.click(toggle)
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
@@ -132,11 +147,11 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Failed to stop'))
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-stop'))
- const stopButton = screen.getByTestId('crowdsec-stop')
- await user.click(stopButton)
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await user.click(toggle)
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
@@ -148,7 +163,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockRejectedValue(new Error('Export failed'))
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
@@ -163,10 +178,10 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockRejectedValue(new Error('Status check failed'))
- render(, { wrapper })
+ await renderSecurityPage()
// Page should still render even if status check fails
- await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
})
@@ -177,19 +192,22 @@ describe('Security Page - QA Security Audit', () => {
// Never resolving promise to simulate pending state
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('toggle-cerberus'))
- const toggle = screen.getByTestId('toggle-cerberus')
+ await waitFor(() => screen.getByTestId('toggle-waf'))
+ const toggle = screen.getByTestId('toggle-waf')
await user.click(toggle)
// Overlay should appear indicating operation in progress
- await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
})
- it('prevents double-click on CrowdSec start button', async () => {
+ it('prevents double toggle when starting CrowdSec', async () => {
const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ ...mockSecurityStatus,
+ crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
+ })
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
let callCount = 0
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(async () => {
@@ -198,17 +216,19 @@ describe('Security Page - QA Security Audit', () => {
return { success: true }
})
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-start'))
- const startButton = screen.getByTestId('crowdsec-start')
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
// Double click
- await user.click(startButton)
- await user.click(startButton)
+ await user.click(toggle)
+ await user.click(toggle)
// Wait for potential multiple calls
- await new Promise(resolve => setTimeout(resolve, 150))
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 150))
+ })
// Should only be called once due to disabled state
expect(callCount).toBe(1)
@@ -221,9 +241,9 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Get initial card order
const initialCards = screen.getAllByRole('heading', { level: 3 })
@@ -246,9 +266,9 @@ describe('Security Page - QA Security Audit', () => {
it('shows correct layer indicator icons', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Each layer should have correct emoji
expect(screen.getByText(/🛡️ Layer 1/)).toBeInTheDocument()
@@ -267,9 +287,9 @@ describe('Security Page - QA Security Audit', () => {
}
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// All 4 cards should be present
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
@@ -283,11 +303,10 @@ describe('Security Page - QA Security Audit', () => {
it('all toggles have proper test IDs for automation', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
- expect(screen.getByTestId('toggle-cerberus')).toBeInTheDocument()
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
@@ -297,24 +316,27 @@ describe('Security Page - QA Security Audit', () => {
it('WAF controls have proper test IDs when enabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
expect(screen.getByTestId('waf-mode-select')).toBeInTheDocument()
expect(screen.getByTestId('waf-ruleset-select')).toBeInTheDocument()
})
- it('CrowdSec buttons have proper test IDs when enabled', async () => {
+ it('CrowdSec controls surface primary actions when enabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
- expect(screen.getByTestId('crowdsec-start')).toBeInTheDocument()
- expect(screen.getByTestId('crowdsec-stop')).toBeInTheDocument()
+ expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Logs/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
+ const configButtons = screen.getAllByRole('button', { name: /Config/i })
+ expect(configButtons.some(btn => btn.textContent === 'Config')).toBe(true)
})
})
@@ -322,9 +344,9 @@ describe('Security Page - QA Security Audit', () => {
it('pipeline order matches spec: CrowdSec → ACL → WAF → Rate Limiting', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
const cards = screen.getAllByRole('heading', { level: 3 })
const cardNames = cards.map(card => card.textContent)
@@ -336,9 +358,9 @@ describe('Security Page - QA Security Audit', () => {
it('layer indicators match spec descriptions', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// From spec: Layer 1: IP Reputation, Layer 2: Access Control, Layer 3: Request Inspection, Layer 4: Volume Control
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
@@ -350,9 +372,9 @@ describe('Security Page - QA Security Audit', () => {
it('threat summaries match spec when services enabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// From spec:
// CrowdSec: "Known attackers, botnets, brute-force attempts"
@@ -374,7 +396,7 @@ describe('Security Page - QA Security Audit', () => {
() => new Promise(resolve => setTimeout(resolve, 50))
)
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-waf'))
@@ -386,17 +408,17 @@ describe('Security Page - QA Security Audit', () => {
}
// Page should still be functional
- await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
it('handles undefined crowdsec status gracefully', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue(null as never)
- render(, { wrapper })
+ await renderSecurityPage()
// Should not crash
- await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
})
})
diff --git a/frontend/src/pages/__tests__/Security.spec.tsx b/frontend/src/pages/__tests__/Security.spec.tsx
index aba8b842..30c6acdb 100644
--- a/frontend/src/pages/__tests__/Security.spec.tsx
+++ b/frontend/src/pages/__tests__/Security.spec.tsx
@@ -63,7 +63,7 @@ describe('Security page', () => {
} as SecurityStatus)
renderWithProviders()
- expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
+ expect(await screen.findByText('Cerberus Disabled')).toBeInTheDocument()
const docBtns = screen.getAllByText('Documentation')
expect(docBtns.length).toBeGreaterThan(0)
})
@@ -80,14 +80,9 @@ describe('Security page', () => {
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
- const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
- // debug: ensure element state
- console.log('crowdsecToggle disabled:', (crowdsecToggle as HTMLInputElement).disabled)
- expect(crowdsecToggle).toBeTruthy()
- // Ensure the toggle exists and is not disabled
- expect(crowdsecToggle).toBeTruthy()
- expect((crowdsecToggle as HTMLInputElement).disabled).toBe(false)
+ await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
+ const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement
+ expect(crowdsecToggle.disabled).toBe(false)
// Ensure enable-all controls were removed
expect(screen.queryByTestId('enable-all-btn')).toBeNull()
})
@@ -103,7 +98,7 @@ describe('Security page', () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
const updateSpy = vi.mocked(settingsApi.updateSetting)
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const aclToggle = screen.getByTestId('toggle-acl')
await userEvent.click(aclToggle)
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
@@ -120,42 +115,47 @@ describe('Security page', () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
+ vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
+
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
- it('calls start/stop endpoints for CrowdSec', async () => {
- const status: SecurityStatus = {
+ it('calls start/stop endpoints for CrowdSec via toggle', async () => {
+ const user = userEvent.setup()
+ const baseStatus: SecurityStatus = {
cerberus: { enabled: true },
- crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
+ crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
waf: { enabled: false, mode: 'disabled' as const },
rate_limit: { enabled: false },
acl: { enabled: false },
}
- vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
- // Test start
- vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
+
+ vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
+ vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
+ vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
+
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
- const startBtn = screen.getByText('Start')
- await userEvent.click(startBtn)
+ await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await user.click(toggle)
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
- // Cleanup before re-render to avoid multiple DOM instances
+
cleanup()
- // Test stop: render with running state and click stop
- vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
+ const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } }
+ vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123 })
+ vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
+
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
- await waitFor(() => expect(screen.getByText('Stop')).toBeInTheDocument())
- const stopBtn = screen.getAllByText('Stop').find(b => !b.hasAttribute('disabled'))
- if (!stopBtn) throw new Error('No enabled Stop button found')
- await userEvent.click(stopBtn)
+ await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
+ const stopToggle = screen.getByTestId('toggle-crowdsec')
+ await user.click(stopToggle)
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
})
@@ -169,7 +169,7 @@ describe('Security page', () => {
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('Cerberus Disabled')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
expect(crowdsecToggle).toBeDisabled()
})
@@ -325,7 +325,7 @@ describe('Security page', () => {
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
// Mode selector and ruleset selector should not be visible
expect(screen.queryByTestId('waf-mode-select')).not.toBeInTheDocument()
diff --git a/frontend/src/pages/__tests__/Security.test.tsx b/frontend/src/pages/__tests__/Security.test.tsx
index 24c8b8b6..4e2baddf 100644
--- a/frontend/src/pages/__tests__/Security.test.tsx
+++ b/frontend/src/pages/__tests__/Security.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, waitFor } from '@testing-library/react'
+import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
@@ -46,6 +46,13 @@ describe('Security', () => {
},
})
vi.clearAllMocks()
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
+ vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
+ vi.mocked(settingsApi.updateSetting).mockResolvedValue()
+ vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
+ vi.spyOn(window, 'open').mockImplementation(() => null)
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -54,6 +61,12 @@ describe('Security', () => {
)
+ const renderSecurityPage = async () => {
+ await act(async () => {
+ render(, { wrapper })
+ })
+ }
+
const mockSecurityStatus = {
cerberus: { enabled: true },
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
@@ -63,58 +76,30 @@ describe('Security', () => {
}
describe('Rendering', () => {
- it('should show loading state initially', () => {
+ it('should show loading state initially', async () => {
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
- render(, { wrapper })
+
+ await renderSecurityPage()
+
expect(screen.getByText(/Loading security status/i)).toBeInTheDocument()
})
it('should show error if security status fails to load', async () => {
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument())
})
- it('should render Security Dashboard when status loads', async () => {
+ it('should render Cerberus Dashboard when status loads', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
- await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
+ await renderSecurityPage()
+ await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
it('should show banner when Cerberus is disabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
- render(, { wrapper })
- await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
- })
- })
-
- describe('Cerberus Toggle', () => {
- it('should toggle Cerberus on', async () => {
- const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
- vi.mocked(settingsApi.updateSetting).mockResolvedValue()
-
- render(, { wrapper })
-
- await waitFor(() => screen.getByTestId('toggle-cerberus'))
- const toggle = screen.getByTestId('toggle-cerberus')
- await user.click(toggle)
-
- await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'true', 'security', 'bool'))
- })
-
- it('should toggle Cerberus off', async () => {
- const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- vi.mocked(settingsApi.updateSetting).mockResolvedValue()
-
- render(, { wrapper })
-
- await waitFor(() => screen.getByTestId('toggle-cerberus'))
- const toggle = screen.getByTestId('toggle-cerberus')
- await user.click(toggle)
-
- await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'false', 'security', 'bool'))
+ await renderSecurityPage()
+ await waitFor(() => expect(screen.getByText(/Cerberus Disabled/i)).toBeInTheDocument())
})
})
@@ -124,7 +109,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
@@ -138,11 +123,13 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-waf'))
const toggle = screen.getByTestId('toggle-waf')
- await user.click(toggle)
+ await act(async () => {
+ await user.click(toggle)
+ })
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
})
@@ -152,7 +139,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-acl'))
const toggle = screen.getByTestId('toggle-acl')
@@ -166,7 +153,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
const toggle = screen.getByTestId('toggle-rate-limit')
@@ -179,8 +166,8 @@ describe('Security', () => {
describe('Admin Whitelist', () => {
it('should load admin whitelist from config', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
})
@@ -192,7 +179,7 @@ describe('Security', () => {
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
@@ -206,34 +193,47 @@ describe('Security', () => {
})
describe('CrowdSec Controls', () => {
- it('should start CrowdSec', async () => {
+ it('should start CrowdSec when toggling on', async () => {
const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ ...mockSecurityStatus,
+ crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
+ })
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ success: true })
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-start'))
- const startButton = screen.getByTestId('crowdsec-start')
- await user.click(startButton)
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await act(async () => {
+ await user.click(toggle)
+ })
- await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
+ await waitFor(() => {
+ expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool')
+ expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
+ })
})
- it('should stop CrowdSec', async () => {
+ it('should stop CrowdSec when toggling off', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-stop'))
- const stopButton = screen.getByTestId('crowdsec-stop')
- await user.click(stopButton)
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await act(async () => {
+ await user.click(toggle)
+ })
- await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
+ await waitFor(() => {
+ expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool')
+ expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
+ })
})
it('should export CrowdSec config', async () => {
@@ -243,7 +243,7 @@ describe('Security', () => {
window.URL.createObjectURL = vi.fn(() => 'blob:url')
window.URL.revokeObjectURL = vi.fn()
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
@@ -264,7 +264,7 @@ describe('Security', () => {
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('waf-mode-select'))
const select = screen.getByTestId('waf-mode-select')
@@ -280,7 +280,7 @@ describe('Security', () => {
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('waf-ruleset-select'))
const select = screen.getByTestId('waf-ruleset-select')
@@ -293,9 +293,9 @@ describe('Security', () => {
describe('Card Order (Pipeline Sequence)', () => {
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await renderSecurityPage()
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Get all card headings
const cards = screen.getAllByRole('heading', { level: 3 })
@@ -307,9 +307,9 @@ describe('Security', () => {
it('should display layer indicators on each card', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await renderSecurityPage()
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Verify each layer indicator is present
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
@@ -320,9 +320,9 @@ describe('Security', () => {
it('should display threat protection summaries', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- render(, { wrapper })
- await waitFor(() => screen.getByText(/Security Dashboard/i))
+ await renderSecurityPage()
+ await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Verify threat protection descriptions
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
@@ -333,26 +333,12 @@ describe('Security', () => {
})
describe('Loading Overlay', () => {
- it('should show Cerberus overlay when Cerberus is toggling', async () => {
- const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
- vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
-
- render(, { wrapper })
-
- await waitFor(() => screen.getByTestId('toggle-cerberus'))
- const toggle = screen.getByTestId('toggle-cerberus')
- await user.click(toggle)
-
- await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
- })
-
it('should show overlay when service is toggling', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
- render(, { wrapper })
+ await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-waf'))
const toggle = screen.getByTestId('toggle-waf')
@@ -363,15 +349,18 @@ describe('Security', () => {
it('should show overlay when starting CrowdSec', async () => {
const user = userEvent.setup()
- vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ ...mockSecurityStatus,
+ crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
+ })
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-start'))
- const startButton = screen.getByTestId('crowdsec-start')
- await user.click(startButton)
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await user.click(toggle)
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
})
@@ -382,11 +371,11 @@ describe('Security', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
- render(, { wrapper })
+ await renderSecurityPage()
- await waitFor(() => screen.getByTestId('crowdsec-stop'))
- const stopButton = screen.getByTestId('crowdsec-stop')
- await user.click(stopButton)
+ await waitFor(() => screen.getByTestId('toggle-crowdsec'))
+ const toggle = screen.getByTestId('toggle-crowdsec')
+ await user.click(toggle)
await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument())
})
diff --git a/frontend/src/pages/__tests__/SystemSettings.test.tsx b/frontend/src/pages/__tests__/SystemSettings.test.tsx
index d0790d9c..9f557864 100644
--- a/frontend/src/pages/__tests__/SystemSettings.test.tsx
+++ b/frontend/src/pages/__tests__/SystemSettings.test.tsx
@@ -63,7 +63,10 @@ describe('SystemSettings', () => {
'security.cerberus.enabled': 'false',
})
- vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({})
+ vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
+ 'feature.cerberus.enabled': false,
+ 'feature.uptime.enabled': false,
+ })
vi.mocked(client.get).mockResolvedValue({
data: {
@@ -382,44 +385,53 @@ describe('SystemSettings', () => {
})
})
- describe('Optional Features', () => {
- it('renders the Optional Features section', async () => {
+ describe('Features', () => {
+ it('renders the Features section', async () => {
renderWithProviders()
await waitFor(() => {
- expect(screen.getByText('Optional Features')).toBeTruthy()
+ expect(screen.getByText('Features')).toBeTruthy()
})
})
it('displays Cerberus Security Suite toggle', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
+ 'feature.uptime.enabled': false,
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
- expect(screen.getByText('Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.')).toBeTruthy()
})
+
+ const cerberusLabel = screen.getByText('Cerberus Security Suite')
+ const tooltipParent = cerberusLabel.closest('[title]') as HTMLElement
+ expect(tooltipParent?.getAttribute('title')).toContain('Advanced security features')
})
it('displays Uptime Monitoring toggle', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.uptime.enabled': true,
+ 'feature.cerberus.enabled': false,
})
renderWithProviders()
await waitFor(() => {
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
- expect(screen.getByText('Monitor the availability of your proxy hosts and remote servers.')).toBeTruthy()
})
+
+ const uptimeLabel = screen.getByText('Uptime Monitoring')
+ const tooltipParent = uptimeLabel.closest('[title]') as HTMLElement
+ expect(tooltipParent?.getAttribute('title')).toContain('Monitor the availability')
})
it('shows Cerberus toggle as checked when enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
+ 'feature.uptime.enabled': false,
})
renderWithProviders()
@@ -438,6 +450,7 @@ describe('SystemSettings', () => {
it('shows Uptime toggle as checked when enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.uptime.enabled': true,
+ 'feature.cerberus.enabled': false,
})
renderWithProviders()
@@ -455,6 +468,7 @@ describe('SystemSettings', () => {
it('shows Cerberus toggle as unchecked when disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
+ 'feature.uptime.enabled': false,
})
renderWithProviders()
@@ -472,6 +486,7 @@ describe('SystemSettings', () => {
it('toggles Cerberus feature flag when switch is clicked', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
+ 'feature.uptime.enabled': false,
})
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
@@ -498,6 +513,7 @@ describe('SystemSettings', () => {
it('toggles Uptime feature flag when switch is clicked', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.uptime.enabled': true,
+ 'feature.cerberus.enabled': false,
})
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
@@ -527,10 +543,37 @@ describe('SystemSettings', () => {
renderWithProviders()
await waitFor(() => {
- expect(screen.getByText('Optional Features')).toBeTruthy()
+ expect(screen.getByText('Features')).toBeTruthy()
})
expect(screen.getByText('Loading features...')).toBeTruthy()
})
+
+ it('shows loading overlay while toggling a feature flag', async () => {
+ vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
+ 'feature.cerberus.enabled': false,
+ 'feature.uptime.enabled': false,
+ })
+ vi.mocked(featureFlagsApi.updateFeatureFlags).mockImplementation(
+ () => new Promise(() => {})
+ )
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
+ })
+
+ const user = userEvent.setup()
+ const cerberusText = screen.getByText('Cerberus Security Suite')
+ const parentDiv = cerberusText.closest('.flex')
+ const switchInput = parentDiv?.querySelector('input[type="checkbox"]') as HTMLInputElement
+
+ await user.click(switchInput)
+
+ await waitFor(() => {
+ expect(screen.getByText('Updating features...')).toBeInTheDocument()
+ })
+ })
})
})
diff --git a/frontend/src/utils/crowdsecExport.ts b/frontend/src/utils/crowdsecExport.ts
new file mode 100644
index 00000000..b07fcfdf
--- /dev/null
+++ b/frontend/src/utils/crowdsecExport.ts
@@ -0,0 +1,24 @@
+export const buildCrowdsecExportFilename = (): string => {
+ const timestamp = new Date().toISOString().replace(/:/g, '-')
+ return `crowdsec-export-${timestamp}.tar.gz`
+}
+
+export const promptCrowdsecFilename = (defaultName = buildCrowdsecExportFilename()): string | null => {
+ const input = window.prompt('Name your CrowdSec export archive', defaultName)
+ if (input === null || typeof input === 'undefined') return null
+ const trimmed = typeof input === 'string' ? input.trim() : ''
+ const candidate = trimmed || defaultName
+ const sanitized = candidate.replace(/[\\/]+/g, '-').replace(/\s+/g, '-')
+ return sanitized.toLowerCase().endsWith('.tar.gz') ? sanitized : `${sanitized}.tar.gz`
+}
+
+export const downloadCrowdsecExport = (blob: Blob, filename: string) => {
+ const url = window.URL.createObjectURL(new Blob([blob]))
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ window.URL.revokeObjectURL(url)
+}