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:
59
frontend/src/api/__tests__/presets.test.ts
Normal file
59
frontend/src/api/__tests__/presets.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
67
frontend/src/api/presets.ts
Normal file
67
frontend/src/api/presets.ts
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
118
frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
Normal file
118
frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
64
frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
Normal file
64
frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
24
frontend/src/utils/crowdsecExport.ts
Normal file
24
frontend/src/utils/crowdsecExport.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user