feat: Add CrowdSec configuration management and export functionality

- Implemented CrowdSec configuration page with import/export capabilities.
- Added API endpoints for exporting, importing, listing, reading, and writing CrowdSec configuration files.
- Enhanced security handler to support runtime overrides for CrowdSec mode and API URL.
- Updated frontend components to include CrowdSec settings in the UI.
- Added tests for CrowdSec configuration management and security handler behavior.
- Improved user experience with toast notifications for successful operations and error handling.
This commit is contained in:
GitHub Actions
2025-11-30 20:43:53 +00:00
parent 1244041bd7
commit d789ee85e5
29 changed files with 1721 additions and 607 deletions
+2
View File
@@ -16,6 +16,7 @@ const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
const Certificates = lazy(() => import('./pages/Certificates'))
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
const Account = lazy(() => import('./pages/Account'))
const Settings = lazy(() => import('./pages/Settings'))
const Backups = lazy(() => import('./pages/Backups'))
@@ -61,6 +62,7 @@ export default function App() {
<Route path="settings" element={<Settings />}>
<Route index element={<SystemSettings />} />
<Route path="system" element={<SystemSettings />} />
<Route path="crowdsec" element={<CrowdSecConfig />} />
<Route path="account" element={<Account />} />
</Route>
+21 -1
View File
@@ -24,4 +24,24 @@ export async function importCrowdsecConfig(file: File) {
return resp.data
}
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig }
export async function exportCrowdsecConfig() {
const resp = await client.get('/admin/crowdsec/export', { responseType: 'blob' })
return resp.data
}
export async function listCrowdsecFiles() {
const resp = await client.get<{ files: string[] }>('/admin/crowdsec/files')
return resp.data
}
export async function readCrowdsecFile(path: string) {
const resp = await client.get<{ content: string }>(`/admin/crowdsec/file?path=${encodeURIComponent(path)}`)
return resp.data
}
export async function writeCrowdsecFile(path: string, content: string) {
const resp = await client.post('/admin/crowdsec/file', { path, content })
return resp.data
}
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile }
+1 -1
View File
@@ -216,7 +216,7 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
<label htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
Type *
<a
href="https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type"
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
@@ -63,7 +63,7 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect
</a>
{' • '}
<a
href="https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type"
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline inline-flex items-center gap-1"
+1
View File
@@ -64,6 +64,7 @@ export default function Layout({ children }: LayoutProps) {
icon: '⚙️',
children: [
{ name: 'System', path: '/settings/system', icon: '⚙️' },
{ name: 'CrowdSec', path: '/settings/crowdsec', icon: '🛡️' },
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
]
},
+1 -1
View File
@@ -232,7 +232,7 @@ export default function AccessLists() {
<Button
variant="secondary"
size="sm"
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type', '_blank')}
onClick={() => window.open('https://wikid82.github.io/charon/security#acl-best-practices-by-service-type', '_blank')}
>
<ExternalLink className="h-4 w-4 mr-2" />
Best Practices
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react'
import { Button } from '../components/ui/Button'
import { Card } from '../components/ui/Card'
import { getSecurityStatus } from '../api/security'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } from '../api/crowdsec'
import { createBackup } from '../api/backups'
import { updateSetting } from '../api/settings'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../utils/toast'
export default function CrowdSecConfig() {
const { data: status } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
const [file, setFile] = useState<File | null>(null)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string | null>(null)
const queryClient = useQueryClient()
const backupMutation = useMutation({ mutationFn: () => createBackup() })
const importMutation = useMutation({
mutationFn: async (file: File) => {
return await importCrowdsecConfig(file)
},
onSuccess: () => {
toast.success('CrowdSec config imported (backup created)')
queryClient.invalidateQueries({ queryKey: ['security-status'] })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to import')
}
})
const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles })
const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) })
const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } })
const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['security-status'] }) })
const handleExport = async () => {
try {
const blob = await exportCrowdsecConfig()
const url = window.URL.createObjectURL(new Blob([blob]))
const a = document.createElement('a')
a.href = url
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
toast.success('CrowdSec configuration exported')
} catch {
toast.error('Failed to export CrowdSec configuration')
}
}
const handleImport = async () => {
if (!file) return
try {
await backupMutation.mutateAsync()
await importMutation.mutateAsync(file)
setFile(null)
} catch {
// handled in onError
}
}
const handleReadFile = async (path: string) => {
setSelectedPath(path)
await readMutation.mutateAsync(path)
}
const handleSaveFile = async () => {
if (!selectedPath || fileContent === null) return
try {
await backupMutation.mutateAsync()
await writeMutation.mutateAsync({ path: selectedPath, content: fileContent })
} catch {
// handled
}
}
const handleModeChange = async (mode: string) => {
updateModeMutation.mutate(mode)
if (mode === 'external') {
toast.error('External CrowdSec mode is not supported in this release')
} else {
toast.success('CrowdSec mode saved (restart may be required)')
}
}
if (!status) return <div className="p-8 text-center">Loading...</div>
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">CrowdSec Configuration</h1>
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Mode</h2>
<div className="flex items-center gap-4">
<label className="text-sm text-gray-400">Mode:</label>
<div className="flex items-center gap-3">
<select value={status.crowdsec.mode} onChange={(e) => handleModeChange(e.target.value)} className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white">
<option value="disabled">Disabled</option>
<option value="local">Local</option>
<option value="external">External (unsupported)</option>
</select>
</div>
</div>
{status.crowdsec.mode === 'disabled' && (
<p className="text-xs text-yellow-500">Note: External CrowdSec mode is not supported in this build.</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={handleExport}>Export</Button>
</div>
</div>
</Card>
<Card>
<div className="space-y-4">
<h3 className="text-md font-semibold">Import Configuration</h3>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
<div className="flex gap-2">
<Button onClick={handleImport} disabled={!file || importMutation.isPending} data-testid="import-btn">
{importMutation.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="space-y-4">
<h3 className="text-md font-semibold">Edit Configuration Files</h3>
<div className="flex items-center gap-2">
<select className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white" value={selectedPath ?? ''} onChange={(e) => handleReadFile(e.target.value)}>
<option value="">Select a file...</option>
{listMutation.data?.files.map((f) => (
<option value={f} key={f}>{f}</option>
))}
</select>
<Button variant="secondary" onClick={() => listMutation.refetch()}>Refresh</Button>
</div>
<textarea value={fileContent ?? ''} onChange={(e) => setFileContent(e.target.value)} rows={12} className="w-full bg-gray-900 border border-gray-700 rounded-lg p-3 text-white" />
<div className="flex gap-2">
<Button onClick={handleSaveFile} isLoading={writeMutation.isPending || backupMutation.isPending}>Save</Button>
<Button variant="secondary" onClick={() => { setSelectedPath(null); setFileContent(null) }}>Close</Button>
</div>
</div>
</Card>
</div>
)
}
+172 -46
View File
@@ -2,8 +2,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink } from 'lucide-react'
import { getSecurityStatus } from '../api/security'
import { exportCrowdsecConfig } from '../api/crowdsec'
import { updateSetting } from '../api/settings'
import { Switch } from '../components/ui/Switch'
import { toast } from '../utils/toast'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
@@ -14,6 +16,21 @@ export default function Security() {
queryFn: getSecurityStatus,
})
const queryClient = useQueryClient()
// Generic toggle mutation for per-service settings
const toggleServiceMutation = useMutation({
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
await updateSetting(key, enabled ? 'true' : 'false', 'security', 'bool')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
queryClient.invalidateQueries({ queryKey: ['security-status'] })
toast.success('Security setting updated')
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
toast.error(`Failed to update setting: ${msg}`)
},
})
const toggleCerberusMutation = useMutation({
mutationFn: async (enabled: boolean) => {
await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
@@ -34,51 +51,84 @@ export default function Security() {
const allDisabled = !status?.crowdsec?.enabled && !status?.waf?.enabled && !status?.rate_limit?.enabled && !status?.acl?.enabled
if (allDisabled) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-6">
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full">
<Shield className="w-16 h-16 text-gray-400" />
</div>
<div className="max-w-md space-y-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Security Services Not Enabled</h2>
<p className="text-gray-500 dark:text-gray-400">
Charon supports advanced security features like CrowdSec, WAF, ACLs, and Rate Limiting.
These are optional and can be enabled via environment variables.
</p>
</div>
<Button
variant="primary"
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
View Implementation Guide
</Button>
// Replace the previous early-return that instructed enabling via env vars.
// If allDisabled, show a banner and continue to render the dashboard with disabled controls.
const headerBanner = allDisabled ? (
<div className="flex flex-col items-center justify-center text-center space-y-4 p-6 bg-gray-900/5 dark:bg-gray-800 rounded-lg">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-gray-400" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Security Suite Disabled</h2>
</div>
)
}
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg">
Charon supports advanced security features (CrowdSec, WAF, ACLs, Rate Limiting). Enable the global Cerberus toggle in System Settings and activate individual services below.
</p>
<Button
variant="primary"
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Documentation
</Button>
</div>
) : null
return (
<div className="space-y-6">
{headerBanner}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ShieldCheck className="w-8 h-8 text-green-500" />
Security Dashboard
</h1>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
<Switch
checked={status?.cerberus?.enabled ?? false}
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
/>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
<Switch
checked={status?.cerberus?.enabled ?? false}
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
data-testid="toggle-cerberus"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={() => {
// enable all services
const keys = [
'security.crowdsec.enabled',
'security.waf.enabled',
'security.acl.enabled',
'security.rate_limit.enabled',
]
keys.forEach(k => toggleServiceMutation.mutate({ key: k, enabled: true }))
}}
data-testid="enable-all-btn"
>
Enable All
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
const keys = [
'security.crowdsec.enabled',
'security.waf.enabled',
'security.acl.enabled',
'security.rate_limit.enabled',
]
keys.forEach(k => toggleServiceMutation.mutate({ key: k, enabled: false }))
}}
data-testid="disable-all-btn"
>
Disable All
</Button>
</div>
<Button
variant="secondary"
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html', '_blank')}
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
@@ -91,7 +141,22 @@ export default function Security() {
<Card className={status.crowdsec.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">CrowdSec</h3>
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
<div className="flex items-center gap-3">
<Switch
checked={status.crowdsec.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => {
// pre-validate if enabling external CrowdSec without API URL
if (e.target.checked && status.crowdsec?.mode === 'external') {
toast.error('External CrowdSec mode is not supported in this release')
return
}
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
}}
data-testid="toggle-crowdsec"
/>
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
@@ -103,16 +168,43 @@ export default function Security() {
: 'Intrusion Prevention System'}
</p>
{status.crowdsec.enabled && (
<div className="mt-4">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/tasks/logs?search=crowdsec')}
>
View Logs
</Button>
</div>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/tasks/logs?search=crowdsec')}
>
View Logs
</Button>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={async () => {
// download config
try {
const resp = await exportCrowdsecConfig()
const url = window.URL.createObjectURL(new Blob([resp]))
const a = document.createElement('a')
a.href = url
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
toast.success('CrowdSec configuration exported')
} catch {
toast.error('Failed to export CrowdSec configuration')
}
}}
>
Export
</Button>
<Button variant="secondary" size="sm" className="w-full" onClick={() => navigate('/settings/crowdsec')}>
Configure
</Button>
</div>
)}
</div>
</Card>
@@ -121,7 +213,15 @@ export default function Security() {
<Card className={status.waf.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">WAF (Coraza)</h3>
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
<div className="flex items-center gap-3">
<Switch
checked={status.waf.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.waf.enabled', enabled: e.target.checked })}
data-testid="toggle-waf"
/>
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
@@ -137,7 +237,15 @@ export default function Security() {
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">Access Control</h3>
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
<div className="flex items-center gap-3">
<Switch
checked={status.acl.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: e.target.checked })}
data-testid="toggle-acl"
/>
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
@@ -158,6 +266,11 @@ export default function Security() {
</Button>
</div>
)}
{!status.acl.enabled && (
<div className="mt-4">
<Button size="sm" variant="secondary" onClick={() => navigate('/access-lists')}>Configure</Button>
</div>
)}
</div>
</Card>
@@ -165,7 +278,15 @@ export default function Security() {
<Card className={status.rate_limit.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">Rate Limiting</h3>
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
<div className="flex items-center gap-3">
<Switch
checked={status.rate_limit.enabled}
disabled={!status.cerberus?.enabled}
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.rate_limit.enabled', enabled: e.target.checked })}
data-testid="toggle-rate-limit"
/>
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
@@ -181,6 +302,11 @@ export default function Security() {
</Button>
</div>
)}
{!status.rate_limit.enabled && (
<div className="mt-4">
<Button variant="secondary" size="sm" onClick={() => navigate('/settings/system')}>Configure</Button>
</div>
)}
</div>
</Card>
</div>
+17 -1
View File
@@ -8,7 +8,7 @@ import { toast } from '../utils/toast'
import { getSettings, updateSetting } from '../api/settings'
import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
import client from '../api/client'
import { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig } from '../api/crowdsec'
import { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig } from '../api/crowdsec'
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
interface HealthResponse {
@@ -356,6 +356,22 @@ export default function SystemSettings() {
<div className="flex items-center gap-3">
<Button onClick={() => startMutation.mutate()} isLoading={startMutation.isPending}>Start</Button>
<Button onClick={() => stopMutation.mutate()} isLoading={stopMutation.isPending} variant="secondary">Stop</Button>
<Button onClick={async () => {
try {
const b = await exportCrowdsecConfig()
const url = window.URL.createObjectURL(new Blob([b]))
const a = document.createElement('a')
a.href = url
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
toast.success('CrowdSec configuration exported')
} catch {
toast.error('Failed to export CrowdSec configuration')
}
}} variant="secondary">Export</Button>
</div>
</div>
@@ -0,0 +1,101 @@
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 { BrowserRouter } from 'react-router-dom'
import CrowdSecConfig from '../CrowdSecConfig'
import * as api from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
const qc = createQueryClient()
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
{ui}
</BrowserRouter>
</QueryClientProvider>
)
}
describe('CrowdSecConfig', () => {
beforeEach(() => vi.clearAllMocks())
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 } } as any)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
it('uploads a file and calls import on Import (backup before save)', 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 } } as any)
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({ status: 'imported' } as any)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const input = screen.getByTestId('import-file') as HTMLInputElement
const file = new File(['dummy'], 'cfg.tar.gz')
await userEvent.upload(input, file)
const btn = screen.getByTestId('import-btn')
await userEvent.click(btn)
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
})
it('lists files, reads file content and can save edits (backup before save)', async () => {
const status = { crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['conf.d/a.conf', 'b.conf'] } as any)
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'rule1' } as any)
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' } as any)
renderWithProviders(<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]
await userEvent.selectOptions(select, 'conf.d/a.conf')
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
// ensure textarea populated
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('rule1')
// edit and save
await userEvent.clear(textarea)
await userEvent.type(textarea, 'updated')
const saveBtn = screen.getByText('Save')
await userEvent.click(saveBtn)
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf', 'updated'))
})
it('persists crowdsec.mode via settings when changed', async () => {
const status = { crowdsec: { enabled: true, mode: 'disabled', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const selects = screen.getAllByRole('combobox')
const modeSelect = selects[0]
await userEvent.selectOptions(modeSelect, 'local')
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
})
})
@@ -0,0 +1,105 @@
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 { BrowserRouter } from 'react-router-dom'
import Security from '../Security'
import * as api from '../../api/security'
import type { SecurityStatus } from '../../api/security'
import * as settingsApi from '../../api/settings'
import * as crowdsecApi from '../../api/crowdsec'
vi.mock('../../api/security')
vi.mock('../../api/settings')
vi.mock('../../api/crowdsec')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
const qc = createQueryClient()
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
{ui}
</BrowserRouter>
</QueryClientProvider>
)
}
describe('Security page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows banner when all services are disabled and links to docs', async () => {
const status: SecurityStatus = {
cerberus: { enabled: false },
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)
renderWithProviders(<Security />)
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
const docBtns = screen.getAllByText('Documentation')
expect(docBtns.length).toBeGreaterThan(0)
})
it('renders per-service toggles and calls updateSetting on change', async () => {
const status: SecurityStatus = {
cerberus: { enabled: true },
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)
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
expect(crowdsecToggle).toBeTruthy()
await userEvent.click(crowdsecToggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
// Test Enable All toggles also call updateSetting for multiple keys
const enableAllBtn = screen.getByTestId('enable-all-btn')
await userEvent.click(enableAllBtn)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
})
it('calls export endpoint when clicking Export', async () => {
const status: SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: true, mode: 'local' 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)
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
it('disables service toggles when cerberus is off', async () => {
const status: SecurityStatus = {
cerberus: { enabled: false },
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)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
expect(crowdsecToggle).toBeDisabled()
})
})