feat: enhance CrowdSec configuration tests and add new import/export functionality

- Added comprehensive tests for CrowdSec configuration, including preset application and validation error handling.
- Introduced new test cases for importing CrowdSec configurations, ensuring backup creation and successful import.
- Updated existing tests to reflect changes in UI elements and functionality, including toggling CrowdSec mode and exporting configurations.
- Created utility functions for building export filenames and handling downloads, improving code organization and reusability.
- Refactored existing tests to use new test IDs and ensure accurate assertions for UI elements and API calls.
This commit is contained in:
GitHub Actions
2025-12-08 21:01:24 +00:00
parent 35ff409fee
commit 3eadb2bee3
31 changed files with 3766 additions and 357 deletions

View File

@@ -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()
@@ -78,27 +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 =
toggleServiceMutation.isPending ||
updateSecurityConfigMutation.isPending ||
generateBreakGlassMutation.isPending ||
startMutation.isPending ||
stopMutation.isPending
crowdsecPowerMutation.isPending
// Determine contextual message
const getMessage = () => {
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' }
}
@@ -113,6 +163,10 @@ export default function Security() {
return <div className="p-8 text-center text-red-500">Failed to load security status</div>
}
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.
@@ -121,10 +175,10 @@ export default function Security() {
<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>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Cerberus 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.
Cerberus powers CrowdSec, WAF, ACLs, and Rate Limiting. Enable the Cerberus toggle in System Settings to awaken the guardian, then configure each head below.
</p>
<Button
variant="primary"
@@ -153,7 +207,7 @@ export default function Security() {
<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
Cerberus Dashboard
</h1>
<div/>
<Button
@@ -185,9 +239,9 @@ export default function Security() {
<div className="flex items-center gap-3">
<Switch
checked={status.crowdsec.enabled}
disabled={!status.cerberus?.enabled}
disabled={crowdsecToggleDisabled}
onChange={(e) => {
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
crowdsecPowerMutation.mutate(e.target.checked)
}}
data-testid="toggle-crowdsec"
/>
@@ -206,67 +260,35 @@ export default function Security() {
{crowdsecStatus && (
<p className="text-xs text-gray-500 dark:text-gray-400">{crowdsecStatus.running ? `Running (pid ${crowdsecStatus.pid})` : 'Stopped'}</p>
)}
{status.crowdsec.enabled && (
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => navigate('/tasks/logs?search=crowdsec')}
>
Logs
</Button>
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
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 text-xs" onClick={() => navigate('/security/crowdsec')}>
Config
</Button>
<Button
variant="primary"
size="sm"
className="w-full text-xs"
onClick={() => startMutation.mutate()}
data-testid="crowdsec-start"
isLoading={startMutation.isPending}
disabled={!!crowdsecStatus?.running}
>
Start
</Button>
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => stopMutation.mutate()}
data-testid="crowdsec-stop"
isLoading={stopMutation.isPending}
disabled={!crowdsecStatus?.running}
>
Stop
</Button>
</div>
)}
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => navigate('/tasks/logs?search=crowdsec')}
disabled={crowdsecControlsDisabled}
>
Logs
</Button>
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={handleCrowdsecExport}
disabled={crowdsecControlsDisabled}
>
Export
</Button>
<Button
variant="secondary"
size="sm"
className="w-full text-xs"
onClick={() => navigate('/security/crowdsec')}
disabled={crowdsecControlsDisabled}
>
Config
</Button>
</div>
</div>
</Card>