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

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as presets from '../presets'
import client from '../client'
vi.mock('../client')
describe('crowdsec presets API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('lists presets via GET', async () => {
const mockData = { presets: [{ slug: 'bot', title: 'Bot', summary: 'desc', source: 'hub', requires_hub: true, available: true, cached: false }] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await presets.listCrowdsecPresets()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
expect(result).toEqual(mockData)
})
it('pulls a preset via POST', async () => {
const mockData = { status: 'pulled', slug: 'bot', preview: 'configs: {}', cache_key: 'cache-1' }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await presets.pullCrowdsecPreset('bot')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', { slug: 'bot' })
expect(result).toEqual(mockData)
})
it('applies a preset via POST', async () => {
const mockData = { status: 'applied', backup: '/tmp/backup', cache_key: 'cache-1' }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const payload = { slug: 'bot', cache_key: 'cache-1' }
const result = await presets.applyCrowdsecPreset(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
expect(result).toEqual(mockData)
})
it('fetches cached preview by slug', async () => {
const mockData = { preview: 'cached', cache_key: 'cache-1', etag: 'etag-1' }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await presets.getCrowdsecPresetCache('bot/collection')
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets/cache/bot%2Fcollection')
expect(result).toEqual(mockData)
})
it('exports default bundle', () => {
expect(presets.default).toHaveProperty('listCrowdsecPresets')
expect(presets.default).toHaveProperty('pullCrowdsecPreset')
expect(presets.default).toHaveProperty('applyCrowdsecPreset')
expect(presets.default).toHaveProperty('getCrowdsecPresetCache')
})
})

View File

@@ -0,0 +1,67 @@
import client from './client'
export interface CrowdsecPresetSummary {
slug: string
title: string
summary: string
source: string
tags?: string[]
requires_hub: boolean
available: boolean
cached: boolean
cache_key?: string
etag?: string
retrieved_at?: string
}
export interface PullCrowdsecPresetResponse {
status: string
slug: string
preview: string
cache_key: string
etag?: string
retrieved_at?: string
source?: string
}
export interface ApplyCrowdsecPresetResponse {
status: string
backup?: string
reload_hint?: string
used_cscli?: boolean
cache_key?: string
slug?: string
}
export interface CachedCrowdsecPresetPreview {
preview: string
cache_key: string
etag?: string
}
export async function listCrowdsecPresets() {
const resp = await client.get<{ presets: CrowdsecPresetSummary[] }>('/admin/crowdsec/presets')
return resp.data
}
export async function pullCrowdsecPreset(slug: string) {
const resp = await client.post<PullCrowdsecPresetResponse>('/admin/crowdsec/presets/pull', { slug })
return resp.data
}
export async function applyCrowdsecPreset(payload: { slug: string; cache_key?: string }) {
const resp = await client.post<ApplyCrowdsecPresetResponse>('/admin/crowdsec/presets/apply', payload)
return resp.data
}
export async function getCrowdsecPresetCache(slug: string) {
const resp = await client.get<CachedCrowdsecPresetPreview>(`/admin/crowdsec/presets/cache/${encodeURIComponent(slug)}`)
return resp.data
}
export default {
listCrowdsecPresets,
pullCrowdsecPreset,
applyCrowdsecPreset,
getCrowdsecPresetCache,
}

View File

@@ -62,8 +62,8 @@ export default function Layout({ children }: LayoutProps) {
{ name: 'Domains', path: '/domains', icon: '🌍' },
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
{ name: 'Uptime', path: '/uptime', icon: '📈' },
{ name: 'Security', path: '/security', icon: '🛡️', children: [
{ name: 'Overview', path: '/security', icon: '🛡️' },
{ name: 'Cerberus', path: '/security', icon: '🛡️', children: [
{ name: 'Dashboard', path: '/security', icon: '🛡️' },
{ name: 'CrowdSec', path: '/security/crowdsec', icon: '🛡️' },
{ name: 'Access Lists', path: '/security/access-lists', icon: '🔒' },
{ name: 'Rate Limiting', path: '/security/rate-limiting', icon: '⚡' },
@@ -104,7 +104,7 @@ export default function Layout({ children }: LayoutProps) {
// Optional Features Logic
// Default to visible (true) if flags are loading or undefined
if (item.name === 'Uptime') return featureFlags?.['feature.uptime.enabled'] !== false
if (item.name === 'Security') return featureFlags?.['feature.cerberus.enabled'] !== false
if (item.name === 'Cerberus') return featureFlags?.['feature.cerberus.enabled'] !== false
return true
})

View File

@@ -149,7 +149,7 @@ describe('Layout', () => {
})
describe('Feature Flags - Conditional Sidebar Items', () => {
it('displays Security nav item when Cerberus is enabled', async () => {
it('displays Cerberus nav item when Cerberus is enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
@@ -162,11 +162,11 @@ describe('Layout', () => {
)
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument()
expect(screen.getByText('Cerberus')).toBeInTheDocument()
})
})
it('hides Security nav item when Cerberus is disabled', async () => {
it('hides Cerberus nav item when Cerberus is disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': true,
@@ -179,7 +179,7 @@ describe('Layout', () => {
)
await waitFor(() => {
expect(screen.queryByText('Security')).not.toBeInTheDocument()
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
})
})
@@ -217,7 +217,7 @@ describe('Layout', () => {
})
})
it('shows Security and Uptime when both features are enabled', async () => {
it('shows Cerberus and Uptime when both features are enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': true,
@@ -230,12 +230,12 @@ describe('Layout', () => {
)
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument()
expect(screen.getByText('Cerberus')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
})
})
it('hides both Security and Uptime when both features are disabled', async () => {
it('hides both Cerberus and Uptime when both features are disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
@@ -248,12 +248,12 @@ describe('Layout', () => {
)
await waitFor(() => {
expect(screen.queryByText('Security')).not.toBeInTheDocument()
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
})
})
it('defaults to showing Security and Uptime when feature flags are loading', async () => {
it('defaults to showing Cerberus and Uptime when feature flags are loading', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue(undefined as any)
@@ -265,7 +265,7 @@ describe('Layout', () => {
// When flags are undefined, items should be visible by default (conservative approach)
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument()
expect(screen.getByText('Cerberus')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
})
})

View File

@@ -1,25 +1,39 @@
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { isAxiosError } from 'axios'
import { Button } from '../components/ui/Button'
import { Card } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { Switch } from '../components/ui/Switch'
import { getSecurityStatus } from '../api/security'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec'
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
import { createBackup } from '../api/backups'
import { updateSetting } from '../api/settings'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../utils/toast'
import { ConfigReloadOverlay } from '../components/LoadingStates'
import { Shield, ShieldOff, Trash2 } from 'lucide-react'
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
import { CROWDSEC_PRESETS, CrowdsecPreset } from '../data/crowdsecPresets'
export default function CrowdSecConfig() {
const { data: status, isLoading, error } = 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 [selectedPresetSlug, setSelectedPresetSlug] = useState<string>('')
const [showBanModal, setShowBanModal] = useState(false)
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
const [isApplyingPreset, setIsApplyingPreset] = useState(false)
const [presetPreview, setPresetPreview] = useState<string>('')
const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null)
const [presetStatusMessage, setPresetStatusMessage] = useState<string | null>(null)
const [hubUnavailable, setHubUnavailable] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: string; usedCscli?: boolean; cacheKey?: string } | null>(null)
const queryClient = useQueryClient()
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
const backupMutation = useMutation({ mutationFn: () => createBackup() })
const importMutation = useMutation({
@@ -38,13 +52,133 @@ export default function CrowdSecConfig() {
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 updateModeMutation = useMutation({
mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'),
onSuccess: (_data, mode) => {
queryClient.invalidateQueries({ queryKey: ['security-status'] })
toast.success(mode === 'disabled' ? 'CrowdSec disabled' : 'CrowdSec set to Local mode')
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to update mode'
toast.error(msg)
},
})
const presetsQuery = useQuery({
queryKey: ['crowdsec-presets'],
queryFn: listCrowdsecPresets,
enabled: !!status?.crowdsec,
retry: false,
})
type PresetCatalogEntry = CrowdsecPreset & {
requiresHub: boolean
available?: boolean
cached?: boolean
cacheKey?: string
etag?: string
retrievedAt?: string
source?: string
}
const presetCatalog: PresetCatalogEntry[] = useMemo(() => {
const remotePresets = presetsQuery.data?.presets
const localMap = new Map(CROWDSEC_PRESETS.map((preset) => [preset.slug, preset]))
if (remotePresets?.length) {
return remotePresets.map((preset) => {
const local = localMap.get(preset.slug)
return {
slug: preset.slug,
title: preset.title || local?.title || preset.slug,
description: local?.description || preset.summary,
content: local?.content || '',
tags: local?.tags || preset.tags,
warning: local?.warning,
requiresHub: Boolean(preset.requires_hub),
available: preset.available,
cached: preset.cached,
cacheKey: preset.cache_key,
etag: preset.etag,
retrievedAt: preset.retrieved_at,
source: preset.source,
}
})
}
return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' }))
}, [presetsQuery.data])
useEffect(() => {
if (!presetCatalog.length) return
if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) {
setSelectedPresetSlug(presetCatalog[0].slug)
}
}, [presetCatalog, selectedPresetSlug])
const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug)
const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false
const pullPresetMutation = useMutation({
mutationFn: (slug: string) => pullCrowdsecPreset(slug),
onSuccess: (data) => {
setPresetPreview(data.preview)
setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source })
setPresetStatusMessage('Preview fetched from hub')
setHubUnavailable(false)
setValidationError(null)
},
onError: (err: unknown) => {
setPresetStatusMessage(null)
if (isAxiosError(err)) {
if (err.response?.status === 400) {
setValidationError(err.response.data?.error || 'Preset slug is invalid')
return
}
if (err.response?.status === 503) {
setHubUnavailable(true)
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
return
}
setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview')
} else {
setPresetStatusMessage('Failed to pull preset preview')
}
},
})
useEffect(() => {
if (!selectedPreset) return
setValidationError(null)
setPresetStatusMessage(null)
setApplyInfo(null)
setPresetMeta({
cacheKey: selectedPreset.cacheKey,
etag: selectedPreset.etag,
retrievedAt: selectedPreset.retrievedAt,
source: selectedPreset.source,
})
setPresetPreview(selectedPreset.content || '')
pullPresetMutation.mutate(selectedPreset.slug)
}, [selectedPreset?.slug])
const loadCachedPreview = async () => {
if (!selectedPreset) return
try {
const cached = await getCrowdsecPresetCache(selectedPreset.slug)
setPresetPreview(cached.preview)
setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source })
setPresetStatusMessage('Loaded cached preview')
setHubUnavailable(false)
} catch (err) {
const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview'
toast.error(msg)
}
}
// Banned IPs queries and mutations
const decisionsQuery = useQuery({
queryKey: ['crowdsec-decisions'],
queryFn: listCrowdsecDecisions,
enabled: status?.crowdsec?.mode !== 'disabled',
enabled: isLocalMode,
})
const banMutation = useMutation({
@@ -73,16 +207,13 @@ export default function CrowdSecConfig() {
})
const handleExport = async () => {
const defaultName = buildCrowdsecExportFilename()
const filename = promptCrowdsecFilename(defaultName)
if (!filename) return
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)
downloadCrowdsecExport(blob, filename)
toast.success('CrowdSec configuration exported')
} catch {
toast.error('Failed to export CrowdSec configuration')
@@ -115,22 +246,129 @@ export default function CrowdSecConfig() {
}
}
const handleModeChange = async (mode: string) => {
const handleModeToggle = (nextEnabled: boolean) => {
const mode = nextEnabled ? 'local' : 'disabled'
updateModeMutation.mutate(mode)
toast.success('CrowdSec mode saved (restart may be required)')
}
const applyPresetLocally = async (reason?: string) => {
if (!selectedPreset) {
toast.error('Select a preset to apply')
return
}
const targetPath = selectedPath ?? listMutation.data?.files?.[0]
if (!targetPath) {
toast.error('Select a configuration file to apply the preset')
return
}
const content = presetPreview || selectedPreset.content
if (!content) {
toast.error('Preset preview is unavailable; retry pulling before applying')
return
}
try {
await backupMutation.mutateAsync()
await writeCrowdsecFile(targetPath, content)
queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] })
setSelectedPath(targetPath)
setFileContent(content)
setApplyInfo({ status: 'applied-locally', cacheKey: presetMeta?.cacheKey })
toast.success(reason ? `${reason}: preset applied locally` : 'Preset applied locally (backup created)')
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to apply preset locally'
toast.error(msg)
}
}
const handleApplyPreset = async () => {
if (!selectedPreset) {
toast.error('Select a preset to apply')
return
}
setIsApplyingPreset(true)
setApplyInfo(null)
setValidationError(null)
try {
const res = await applyCrowdsecPreset({ slug: selectedPreset.slug, cache_key: presetMeta?.cacheKey })
setApplyInfo({
status: res.status,
backup: res.backup,
reloadHint: res.reload_hint,
usedCscli: res.used_cscli,
cacheKey: res.cache_key,
})
const reloadNote = res.reload_hint ? ` (${res.reload_hint})` : ''
toast.success(`Preset applied via backend${reloadNote}`)
if (res.backup) {
setPresetStatusMessage(`Backup stored at ${res.backup}`)
}
} catch (err) {
if (isAxiosError(err)) {
if (err.response?.status === 501) {
toast.info('Preset apply is not available on the server; applying locally instead')
await applyPresetLocally('Backend apply unavailable')
return
}
if (err.response?.status === 400) {
setValidationError(err.response?.data?.error || 'Preset validation failed')
toast.error('Preset validation failed')
return
}
if (err.response?.status === 503) {
setHubUnavailable(true)
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
toast.error('Hub unavailable; retry pull/apply or use cached copy')
return
}
const backupPath = (err.response?.data as { backup?: string })?.backup
if (backupPath) {
setApplyInfo({ status: 'failed', backup: backupPath, cacheKey: presetMeta?.cacheKey })
toast.error(`Apply failed. Restore from backup at ${backupPath}`)
return
}
toast.error(err.response?.data?.error || err.message)
} else {
toast.error('Failed to apply preset')
}
} finally {
setIsApplyingPreset(false)
}
}
const presetActionDisabled =
!selectedPreset ||
isApplyingPreset ||
backupMutation.isPending ||
pullPresetMutation.isPending ||
(selectedPresetRequiresHub && hubUnavailable)
// Determine if any operation is in progress
const isApplyingConfig =
importMutation.isPending ||
writeMutation.isPending ||
updateModeMutation.isPending ||
backupMutation.isPending ||
pullPresetMutation.isPending ||
isApplyingPreset ||
banMutation.isPending ||
unbanMutation.isPending
// Determine contextual message
const getMessage = () => {
if (pullPresetMutation.isPending) {
return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' }
}
if (isApplyingPreset) {
return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' }
}
if (importMutation.isPending) {
return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' }
}
@@ -168,37 +406,163 @@ export default function CrowdSecConfig() {
<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>
</select>
</div>
</div>
{status.crowdsec.mode === 'disabled' && (
<p className="text-xs text-yellow-500">CrowdSec is disabled</p>
)}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="space-y-1">
<h2 className="text-lg font-semibold">CrowdSec Mode</h2>
<p className="text-sm text-gray-400">
{isLocalMode ? 'CrowdSec runs locally; disable to pause decisions.' : 'CrowdSec decisions are paused; enable to resume local protection.'}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={handleExport}>Export</Button>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Disabled</span>
<Switch
checked={isLocalMode}
onChange={(e) => handleModeToggle(e.target.checked)}
disabled={updateModeMutation.isPending}
data-testid="crowdsec-mode-toggle"
/>
<span className="text-sm text-gray-200">Local</span>
</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 className="flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-md font-semibold">Configuration Packages</h3>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleExport}
disabled={importMutation.isPending || backupMutation.isPending}
>
Export
</Button>
<Button onClick={handleImport} disabled={!file || importMutation.isPending || backupMutation.isPending} data-testid="import-btn">
{importMutation.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
<p className="text-sm text-gray-400">Import or export CrowdSec configuration packages. A backup is created before imports.</p>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
</div>
</Card>
<Card>
<div className="space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1">
<h3 className="text-md font-semibold">CrowdSec Presets</h3>
<p className="text-sm text-gray-400">Select a curated preset, preview it, then apply with an automatic backup.</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
value={selectedPresetSlug}
onChange={(e) => setSelectedPresetSlug(e.target.value)}
data-testid="preset-select"
>
{presetCatalog.map((preset) => (
<option key={preset.slug} value={preset.slug}>{preset.title}</option>
))}
</select>
<Button
variant="secondary"
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
disabled={!selectedPreset || pullPresetMutation.isPending}
isLoading={pullPresetMutation.isPending}
>
Pull Preview
</Button>
<Button
onClick={handleApplyPreset}
disabled={presetActionDisabled}
isLoading={isApplyingPreset}
data-testid="apply-preset-btn"
>
Apply Preset
</Button>
</div>
</div>
{validationError && (
<p className="text-sm text-red-400" data-testid="preset-validation-error">{validationError}</p>
)}
{presetStatusMessage && (
<p className="text-sm text-yellow-300" data-testid="preset-status">{presetStatusMessage}</p>
)}
{hubUnavailable && (
<div className="flex flex-wrap gap-2 items-center text-sm text-red-300" data-testid="preset-hub-unavailable">
<span>Hub unreachable. Retry pull or load cached copy if available.</span>
<Button
size="sm"
variant="secondary"
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
disabled={pullPresetMutation.isPending}
>
Retry
</Button>
{selectedPreset?.cached && (
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>Use cached preview</Button>
)}
</div>
)}
{selectedPreset && (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-white">{selectedPreset.title}</p>
<p className="text-sm text-gray-400">{selectedPreset.description}</p>
{selectedPreset.warning && (
<p className="text-xs text-yellow-300" data-testid="preset-warning">{selectedPreset.warning}</p>
)}
<p className="text-xs text-gray-500">Target file: {selectedPath ?? 'Select a file below (used for local fallback)'} </p>
</div>
{presetMeta && (
<div className="text-xs text-gray-400 flex flex-wrap gap-3" data-testid="preset-meta">
<span>Cache key: {presetMeta.cacheKey || '—'}</span>
<span>Etag: {presetMeta.etag || '—'}</span>
<span>Source: {presetMeta.source || selectedPreset.source || '—'}</span>
<span>Fetched: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}</span>
</div>
)}
<div>
<p className="text-xs text-gray-400 mb-2">Preset preview (YAML)</p>
<pre
className="bg-gray-900 border border-gray-800 rounded-lg p-3 text-sm text-gray-200 whitespace-pre-wrap"
data-testid="preset-preview"
>
{presetPreview || selectedPreset.content || 'Preview unavailable. Pull from hub or use cached copy.'}
</pre>
</div>
{applyInfo && (
<div className="rounded-lg border border-gray-800 bg-gray-900/70 p-3 text-xs text-gray-200" data-testid="preset-apply-info">
<p>Status: {applyInfo.status || 'applied'}</p>
{applyInfo.backup && <p>Backup: {applyInfo.backup}</p>}
{applyInfo.reloadHint && <p>Reload: {applyInfo.reloadHint}</p>}
{applyInfo.usedCscli !== undefined && <p>Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}</p>}
</div>
)}
<div className="flex flex-wrap gap-2 items-center text-xs text-gray-400">
{selectedPreset.cached && (
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>
Load cached preview
</Button>
)}
{selectedPresetRequiresHub && hubUnavailable && (
<span className="text-red-300">Apply disabled while hub is offline.</span>
)}
</div>
</div>
)}
{presetCatalog.length === 0 && (
<p className="text-sm text-gray-500">No presets available. Ensure Cerberus is enabled.</p>
)}
</div>
</Card>
@@ -206,7 +570,12 @@ export default function CrowdSecConfig() {
<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)}>
<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)}
data-testid="crowdsec-file-select"
>
<option value="">Select a file...</option>
{listMutation.data?.files?.map((f) => (
<option value={f} key={f}>{f}</option>

View File

@@ -47,11 +47,11 @@ export default function ImportCrowdSec() {
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">Import CrowdSec</h1>
<h1 className="text-3xl font-bold text-white mb-6">CrowdSec Configuration Packages</h1>
<Card className="p-6">
<div className="space-y-4">
<p className="text-sm text-gray-400">Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.</p>
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" />
<p className="text-sm text-gray-400">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.</p>
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" data-testid="crowdsec-import-file" />
<div className="flex gap-2">
<Button onClick={() => handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>Import</Button>
</div>

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>

View File

@@ -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(<CrowdSecConfig />)
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(<CrowdSecConfig />)
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(<CrowdSecConfig />)
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(<CrowdSecConfig />)
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(<CrowdSecConfig />)
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(<CrowdSecConfig />)
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')
})
})

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CrowdSecConfig />
</MemoryRouter>
</QueryClientProvider>
)
}
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()
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ImportCrowdSec />
</MemoryRouter>
</QueryClientProvider>
)
}
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')
})
})
})

View File

@@ -2,7 +2,7 @@
* 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 { act, render, screen, waitFor } from '@testing-library/react'
@@ -58,6 +58,8 @@ describe('Security Page - QA Security Audit', () => {
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 }) => (
@@ -80,7 +82,7 @@ describe('Security Page - QA Security Audit', () => {
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)
@@ -94,7 +96,7 @@ describe('Security Page - QA Security Audit', () => {
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('')
@@ -115,21 +117,24 @@ describe('Security Page - QA Security Audit', () => {
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'))
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()
@@ -144,9 +149,9 @@ describe('Security Page - QA Security Audit', () => {
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()
@@ -176,7 +181,7 @@ describe('Security Page - QA Security Audit', () => {
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())
})
})
@@ -197,9 +202,12 @@ describe('Security Page - QA Security Audit', () => {
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 () => {
@@ -210,12 +218,12 @@ describe('Security Page - QA Security Audit', () => {
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 act(async () => {
@@ -235,7 +243,7 @@ describe('Security Page - QA Security Audit', () => {
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 })
@@ -260,7 +268,7 @@ describe('Security Page - QA Security Audit', () => {
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()
@@ -281,7 +289,7 @@ describe('Security Page - QA Security Audit', () => {
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()
@@ -297,7 +305,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
@@ -310,22 +318,25 @@ describe('Security Page - QA Security Audit', () => {
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 })
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)
})
})
@@ -335,7 +346,7 @@ describe('Security Page - QA Security Audit', () => {
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)
@@ -349,7 +360,7 @@ describe('Security Page - QA Security Audit', () => {
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()
@@ -363,7 +374,7 @@ describe('Security Page - QA Security Audit', () => {
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"
@@ -397,7 +408,7 @@ 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 () => {
@@ -407,7 +418,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
// Should not crash
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
})
})

View File

@@ -63,7 +63,7 @@ describe('Security page', () => {
} as SecurityStatus)
renderWithProviders(<Security />)
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(<Security />)
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(<Security />)
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(<Security />)
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(<Security />)
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(<Security />)
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(<Security />)
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(<Security />)
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()

View File

@@ -52,6 +52,7 @@ describe('Security', () => {
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 }) => (
@@ -89,16 +90,16 @@ describe('Security', () => {
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)
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
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 } })
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Disabled/i)).toBeInTheDocument())
})
})
@@ -192,24 +193,30 @@ 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 })
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')
await act(async () => {
await user.click(startButton)
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 })
@@ -217,13 +224,16 @@ describe('Security', () => {
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await act(async () => {
await user.click(stopButton)
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 () => {
@@ -285,7 +295,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Get all card headings
const cards = screen.getAllByRole('heading', { level: 3 })
@@ -299,7 +309,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Verify each layer indicator is present
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
@@ -312,7 +322,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Verify threat protection descriptions
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
@@ -339,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(() => {}))
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())
})
@@ -360,9 +373,9 @@ describe('Security', () => {
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())
})

View File

@@ -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)
}