feat: Add CrowdSec configuration management and export functionality

- Implemented CrowdSec configuration page with import/export capabilities.
- Added API endpoints for exporting, importing, listing, reading, and writing CrowdSec configuration files.
- Enhanced security handler to support runtime overrides for CrowdSec mode and API URL.
- Updated frontend components to include CrowdSec settings in the UI.
- Added tests for CrowdSec configuration management and security handler behavior.
- Improved user experience with toast notifications for successful operations and error handling.
This commit is contained in:
GitHub Actions
2025-11-30 20:43:53 +00:00
parent 1244041bd7
commit d789ee85e5
29 changed files with 1721 additions and 607 deletions

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import CrowdSecConfig from '../CrowdSecConfig'
import * as api from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
const qc = createQueryClient()
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
{ui}
</BrowserRouter>
</QueryClientProvider>
)
}
describe('CrowdSecConfig', () => {
beforeEach(() => vi.clearAllMocks())
it('exports config when clicking Export', async () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
it('uploads a file and calls import on Import (backup before save)', async () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any)
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({ status: 'imported' } as any)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const input = screen.getByTestId('import-file') as HTMLInputElement
const file = new File(['dummy'], 'cfg.tar.gz')
await userEvent.upload(input, file)
const btn = screen.getByTestId('import-btn')
await userEvent.click(btn)
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
})
it('lists files, reads file content and can save edits (backup before save)', async () => {
const status = { crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['conf.d/a.conf', 'b.conf'] } as any)
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'rule1' } as any)
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' } as any)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
// wait for file list
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
const selects = screen.getAllByRole('combobox')
const select = selects[1]
await userEvent.selectOptions(select, 'conf.d/a.conf')
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
// ensure textarea populated
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('rule1')
// edit and save
await userEvent.clear(textarea)
await userEvent.type(textarea, 'updated')
const saveBtn = screen.getByText('Save')
await userEvent.click(saveBtn)
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf', 'updated'))
})
it('persists crowdsec.mode via settings when changed', async () => {
const status = { crowdsec: { enabled: true, mode: 'disabled', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const selects = screen.getAllByRole('combobox')
const modeSelect = selects[0]
await userEvent.selectOptions(modeSelect, 'local')
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
})
})

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import Security from '../Security'
import * as api from '../../api/security'
import type { SecurityStatus } from '../../api/security'
import * as settingsApi from '../../api/settings'
import * as crowdsecApi from '../../api/crowdsec'
vi.mock('../../api/security')
vi.mock('../../api/settings')
vi.mock('../../api/crowdsec')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
const qc = createQueryClient()
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
{ui}
</BrowserRouter>
</QueryClientProvider>
)
}
describe('Security page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows banner when all services are disabled and links to docs', async () => {
const status: SecurityStatus = {
cerberus: { enabled: false },
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
waf: { enabled: false, mode: 'disabled' as const },
rate_limit: { enabled: false },
acl: { enabled: false },
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
renderWithProviders(<Security />)
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
const docBtns = screen.getAllByText('Documentation')
expect(docBtns.length).toBeGreaterThan(0)
})
it('renders per-service toggles and calls updateSetting on change', async () => {
const status: SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
waf: { enabled: false, mode: 'disabled' as const },
rate_limit: { enabled: false },
acl: { enabled: false },
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
expect(crowdsecToggle).toBeTruthy()
await userEvent.click(crowdsecToggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
// Test Enable All toggles also call updateSetting for multiple keys
const enableAllBtn = screen.getByTestId('enable-all-btn')
await userEvent.click(enableAllBtn)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
})
it('calls export endpoint when clicking Export', async () => {
const status: SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
waf: { enabled: false, mode: 'disabled' as const },
rate_limit: { enabled: false },
acl: { enabled: false },
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
it('disables service toggles when cerberus is off', async () => {
const status: SecurityStatus = {
cerberus: { enabled: false },
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
waf: { enabled: false, mode: 'disabled' as const },
rate_limit: { enabled: false },
acl: { enabled: false },
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
expect(crowdsecToggle).toBeDisabled()
})
})