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

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

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AxiosError } from 'axios'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -8,11 +9,14 @@ import * as api from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
import * as presetsApi from '../../api/presets'
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../api/presets')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
@@ -27,13 +31,46 @@ const renderWithProviders = (ui: React.ReactNode) => {
}
describe('CrowdSecConfig', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
presets: CROWDSEC_PRESETS.map((preset) => ({
slug: preset.slug,
title: preset.title,
summary: preset.description,
source: 'charon',
requires_hub: false,
available: true,
cached: false,
})),
})
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
status: 'pulled',
slug: 'bot-mitigation-essentials',
preview: CROWDSEC_PRESETS[0].content,
cache_key: 'cache-123',
etag: 'etag-123',
retrieved_at: '2024-01-01T00:00:00Z',
source: 'hub',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
status: 'applied',
backup: '/tmp/backup.tar.gz',
reload_hint: 'CrowdSec reloaded',
used_cscli: true,
cache_key: 'cache-123',
slug: 'bot-mitigation-essentials',
})
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' })
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
})
it('exports config when clicking Export', async () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
@@ -69,8 +106,7 @@ describe('CrowdSecConfig', () => {
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
// wait for file list
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
const selects = screen.getAllByRole('combobox')
const select = selects[1]
const select = screen.getByTestId('crowdsec-file-select')
await userEvent.selectOptions(select, 'conf.d/a.conf')
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
// ensure textarea populated
@@ -93,9 +129,123 @@ describe('CrowdSecConfig', () => {
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
const selects = screen.getAllByRole('combobox')
const modeSelect = selects[0]
await userEvent.selectOptions(modeSelect, 'local')
const modeToggle = screen.getByTestId('crowdsec-mode-toggle')
await userEvent.click(modeToggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
})
it('renders preset preview and applies with backup when backend apply is unavailable', async () => {
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || ''
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, {
status: 501,
statusText: 'Not Implemented',
headers: {},
config: {},
data: {},
} as any)
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:'))
const fileSelect = screen.getByTestId('crowdsec-file-select')
await userEvent.selectOptions(fileSelect, 'acquis.yaml')
const applyBtn = screen.getByTestId('apply-preset-btn')
await userEvent.click(applyBtn)
await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' }))
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent))
})
it('surfaces validation error when slug is invalid', async () => {
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
const validationError = new AxiosError('invalid', undefined, undefined, undefined, {
status: 400,
statusText: 'Bad Request',
headers: {},
config: {},
data: { error: 'slug invalid' },
} as any)
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError)
renderWithProviders(<CrowdSecConfig />)
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid'))
})
it('disables apply and offers cached preview when hub is unavailable', async () => {
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
presets: [
{
slug: 'hub-only',
title: 'Hub Only',
summary: 'Needs hub',
source: 'hub',
requires_hub: true,
available: true,
cached: true,
cache_key: 'cache-hub',
etag: 'etag-hub',
},
],
})
const hubError = new AxiosError('unavailable', undefined, undefined, undefined, {
status: 503,
statusText: 'Service Unavailable',
headers: {},
config: {},
data: { error: 'hub service unavailable' },
} as any)
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError)
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' })
renderWithProviders(<CrowdSecConfig />)
const select = await screen.findByTestId('preset-select')
await waitFor(() => expect(screen.getByText('Hub Only')).toBeInTheDocument())
await userEvent.selectOptions(select, 'hub-only')
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement
expect(applyBtn.disabled).toBe(true)
await userEvent.click(screen.getByText('Use cached preview'))
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
})
it('shows apply response metadata including backup path', async () => {
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({
status: 'applied',
backup: '/tmp/crowdsec-backup',
reload_hint: 'crowdsec reloaded',
used_cscli: true,
cache_key: 'cache-123',
slug: 'bot-mitigation-essentials',
})
renderWithProviders(<CrowdSecConfig />)
const applyBtn = await screen.findByTestId('apply-preset-btn')
await userEvent.click(applyBtn)
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup'))
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec reloaded')
})
})

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import CrowdSecConfig from '../CrowdSecConfig'
import * as securityApi from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
import * as presetsApi from '../../api/presets'
import { toast } from '../../utils/toast'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../api/presets')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
describe('CrowdSecConfig', () => {
const createClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = () => {
const queryClient = createClient()
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CrowdSecConfig />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
cerberus: { enabled: true },
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
waf: { mode: 'enabled', enabled: true },
rate_limit: { enabled: true },
acl: { enabled: true },
})
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
status: 'pulled',
slug: 'bot-mitigation-essentials',
preview: 'configs: {}',
cache_key: 'cache-123',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' })
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' })
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
window.URL.createObjectURL = vi.fn(() => 'blob:url')
window.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
})
it('toggles mode between local and disabled', async () => {
renderWithProviders()
await waitFor(() => screen.getByTestId('crowdsec-mode-toggle'))
const toggle = screen.getByTestId('crowdsec-mode-toggle')
await userEvent.click(toggle)
await waitFor(() => {
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
'security.crowdsec.mode',
'disabled',
'security',
'string'
)
expect(toast.success).toHaveBeenCalledWith('CrowdSec disabled')
})
})
it('exports configuration packages with prompted filename', async () => {
renderWithProviders()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
await userEvent.click(exportButton)
await waitFor(() => {
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
})
})
it('shows Configuration Packages heading', async () => {
renderWithProviders()
await waitFor(() => screen.getByText('Configuration Packages'))
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ImportCrowdSec from '../ImportCrowdSec'
import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import { toast } from 'react-hot-toast'
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('react-hot-toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}))
describe('ImportCrowdSec', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
})
const renderPage = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ImportCrowdSec />
</MemoryRouter>
</QueryClientProvider>
)
}
it('renders configuration packages heading', async () => {
renderPage()
await waitFor(() => screen.getByText('CrowdSec Configuration Packages'))
expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument()
})
it('creates a backup before importing selected package', async () => {
renderPage()
const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
await userEvent.upload(fileInput, file)
const importButton = screen.getByRole('button', { name: /Import/i })
await userEvent.click(importButton)
await waitFor(() => {
expect(backupsApi.createBackup).toHaveBeenCalled()
expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file)
expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported')
})
})
})

View File

@@ -2,7 +2,7 @@
* Security Page - QA Security Audit Tests
*
* Tests edge cases, input validation, error states, and security concerns
* for the Security Dashboard implementation.
* for the Cerberus Dashboard implementation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, render, screen, waitFor } from '@testing-library/react'
@@ -58,6 +58,8 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -80,7 +82,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// DOM should not contain any actual script elements from user input
expect(document.querySelectorAll('script[src*="alert"]').length).toBe(0)
@@ -94,7 +96,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Empty whitelist input should exist and be empty
const whitelistInput = screen.getByDisplayValue('')
@@ -115,21 +117,24 @@ describe('Security Page - QA Security Audit', () => {
await user.click(toggle)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
})
})
it('handles CrowdSec start failure gracefully', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
...mockSecurityStatus,
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
})
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start'))
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
await user.click(startButton)
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await user.click(toggle)
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
@@ -144,9 +149,9 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')
await user.click(stopButton)
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await user.click(toggle)
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
@@ -176,7 +181,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
// Page should still render even if status check fails
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
})
@@ -197,9 +202,12 @@ describe('Security Page - QA Security Audit', () => {
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
})
it('prevents double-click on CrowdSec start button', async () => {
it('prevents double toggle when starting CrowdSec', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
...mockSecurityStatus,
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
})
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
let callCount = 0
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(async () => {
@@ -210,12 +218,12 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
// Double click
await user.click(startButton)
await user.click(startButton)
await user.click(toggle)
await user.click(toggle)
// Wait for potential multiple calls
await act(async () => {
@@ -235,7 +243,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Get initial card order
const initialCards = screen.getAllByRole('heading', { level: 3 })
@@ -260,7 +268,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Each layer should have correct emoji
expect(screen.getByText(/🛡️ Layer 1/)).toBeInTheDocument()
@@ -281,7 +289,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// All 4 cards should be present
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
@@ -297,7 +305,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
@@ -310,22 +318,25 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
expect(screen.getByTestId('waf-mode-select')).toBeInTheDocument()
expect(screen.getByTestId('waf-ruleset-select')).toBeInTheDocument()
})
it('CrowdSec buttons have proper test IDs when enabled', async () => {
it('CrowdSec controls surface primary actions when enabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
expect(screen.getByTestId('crowdsec-start')).toBeInTheDocument()
expect(screen.getByTestId('crowdsec-stop')).toBeInTheDocument()
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Logs/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
const configButtons = screen.getAllByRole('button', { name: /Config/i })
expect(configButtons.some(btn => btn.textContent === 'Config')).toBe(true)
})
})
@@ -335,7 +346,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
const cards = screen.getAllByRole('heading', { level: 3 })
const cardNames = cards.map(card => card.textContent)
@@ -349,7 +360,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// From spec: Layer 1: IP Reputation, Layer 2: Access Control, Layer 3: Request Inspection, Layer 4: Volume Control
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
@@ -363,7 +374,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// From spec:
// CrowdSec: "Known attackers, botnets, brute-force attempts"
@@ -397,7 +408,7 @@ describe('Security Page - QA Security Audit', () => {
}
// Page should still be functional
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
it('handles undefined crowdsec status gracefully', async () => {
@@ -407,7 +418,7 @@ describe('Security Page - QA Security Audit', () => {
await renderSecurityPage()
// Should not crash
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
})
})

View File

@@ -63,7 +63,7 @@ describe('Security page', () => {
} as SecurityStatus)
renderWithProviders(<Security />)
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
expect(await screen.findByText('Cerberus Disabled')).toBeInTheDocument()
const docBtns = screen.getAllByText('Documentation')
expect(docBtns.length).toBeGreaterThan(0)
})
@@ -80,14 +80,9 @@ describe('Security page', () => {
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
// debug: ensure element state
console.log('crowdsecToggle disabled:', (crowdsecToggle as HTMLInputElement).disabled)
expect(crowdsecToggle).toBeTruthy()
// Ensure the toggle exists and is not disabled
expect(crowdsecToggle).toBeTruthy()
expect((crowdsecToggle as HTMLInputElement).disabled).toBe(false)
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement
expect(crowdsecToggle.disabled).toBe(false)
// Ensure enable-all controls were removed
expect(screen.queryByTestId('enable-all-btn')).toBeNull()
})
@@ -103,7 +98,7 @@ describe('Security page', () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
const updateSpy = vi.mocked(settingsApi.updateSetting)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const aclToggle = screen.getByTestId('toggle-acl')
await userEvent.click(aclToggle)
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
@@ -120,42 +115,47 @@ describe('Security page', () => {
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
const blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
it('calls start/stop endpoints for CrowdSec', async () => {
const status: SecurityStatus = {
it('calls start/stop endpoints for CrowdSec via toggle', async () => {
const user = userEvent.setup()
const baseStatus: SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
waf: { enabled: false, mode: 'disabled' as const },
rate_limit: { enabled: false },
acl: { enabled: false },
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
// Test start
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
const startBtn = screen.getByText('Start')
await userEvent.click(startBtn)
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const toggle = screen.getByTestId('toggle-crowdsec')
await user.click(toggle)
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
// Cleanup before re-render to avoid multiple DOM instances
cleanup()
// Test stop: render with running state and click stop
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } }
vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123 })
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Stop')).toBeInTheDocument())
const stopBtn = screen.getAllByText('Stop').find(b => !b.hasAttribute('disabled'))
if (!stopBtn) throw new Error('No enabled Stop button found')
await userEvent.click(stopBtn)
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const stopToggle = screen.getByTestId('toggle-crowdsec')
await user.click(stopToggle)
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
})
@@ -169,7 +169,7 @@ describe('Security page', () => {
}
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Cerberus Disabled')).toBeInTheDocument())
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
expect(crowdsecToggle).toBeDisabled()
})
@@ -325,7 +325,7 @@ describe('Security page', () => {
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
// Mode selector and ruleset selector should not be visible
expect(screen.queryByTestId('waf-mode-select')).not.toBeInTheDocument()

View File

@@ -52,6 +52,7 @@ describe('Security', () => {
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
vi.spyOn(window, 'open').mockImplementation(() => null)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -89,16 +90,16 @@ describe('Security', () => {
await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument())
})
it('should render Security Dashboard when status loads', async () => {
it('should render Cerberus Dashboard when status loads', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
})
it('should show banner when Cerberus is disabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Cerberus Disabled/i)).toBeInTheDocument())
})
})
@@ -192,24 +193,30 @@ describe('Security', () => {
})
describe('CrowdSec Controls', () => {
it('should start CrowdSec', async () => {
it('should start CrowdSec when toggling on', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
...mockSecurityStatus,
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
})
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ success: true })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await act(async () => {
await user.click(startButton)
await user.click(toggle)
})
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
await waitFor(() => {
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool')
expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
})
})
it('should stop CrowdSec', async () => {
it('should stop CrowdSec when toggling off', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
@@ -217,13 +224,16 @@ describe('Security', () => {
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await act(async () => {
await user.click(stopButton)
await user.click(toggle)
})
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
await waitFor(() => {
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool')
expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
})
})
it('should export CrowdSec config', async () => {
@@ -285,7 +295,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Get all card headings
const cards = screen.getAllByRole('heading', { level: 3 })
@@ -299,7 +309,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Verify each layer indicator is present
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
@@ -312,7 +322,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Verify threat protection descriptions
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
@@ -339,15 +349,18 @@ describe('Security', () => {
it('should show overlay when starting CrowdSec', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
...mockSecurityStatus,
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
})
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
await user.click(startButton)
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await user.click(toggle)
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
})
@@ -360,9 +373,9 @@ describe('Security', () => {
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')
await user.click(stopButton)
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
await user.click(toggle)
await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument())
})