- 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.
384 lines
16 KiB
TypeScript
384 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { act, 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 securityApi from '../../api/security'
|
|
import * as crowdsecApi from '../../api/crowdsec'
|
|
import * as settingsApi from '../../api/settings'
|
|
import { toast } from '../../utils/toast'
|
|
|
|
vi.mock('../../api/security')
|
|
vi.mock('../../api/crowdsec')
|
|
vi.mock('../../api/settings')
|
|
vi.mock('../../utils/toast', () => ({
|
|
toast: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}))
|
|
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
|
return {
|
|
...actual,
|
|
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
|
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
|
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
|
useRuleSets: vi.fn(() => ({
|
|
data: {
|
|
rulesets: [
|
|
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
|
]
|
|
}
|
|
})),
|
|
}
|
|
})
|
|
|
|
describe('Security', () => {
|
|
let queryClient: QueryClient
|
|
|
|
beforeEach(() => {
|
|
queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
vi.clearAllMocks()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
|
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
|
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 }) => (
|
|
<QueryClientProvider client={queryClient}>
|
|
<BrowserRouter>{children}</BrowserRouter>
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
const renderSecurityPage = async () => {
|
|
await act(async () => {
|
|
render(<Security />, { wrapper })
|
|
})
|
|
}
|
|
|
|
const mockSecurityStatus = {
|
|
cerberus: { enabled: true },
|
|
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
|
waf: { mode: 'enabled' as const, enabled: true },
|
|
rate_limit: { enabled: true },
|
|
acl: { enabled: true }
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should show loading state initially', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
|
|
|
await renderSecurityPage()
|
|
|
|
expect(screen.getByText(/Loading security status/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show error if security status fails to load', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
|
|
await renderSecurityPage()
|
|
await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument())
|
|
})
|
|
|
|
it('should render Cerberus Dashboard when status loads', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
await renderSecurityPage()
|
|
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(/Cerberus Disabled/i)).toBeInTheDocument())
|
|
})
|
|
})
|
|
|
|
describe('Service Toggles', () => {
|
|
it('should toggle CrowdSec on', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
|
|
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
|
const toggle = screen.getByTestId('toggle-crowdsec')
|
|
await user.click(toggle)
|
|
|
|
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
|
|
})
|
|
|
|
it('should toggle WAF on', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
|
|
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('toggle-waf'))
|
|
const toggle = screen.getByTestId('toggle-waf')
|
|
await act(async () => {
|
|
await user.click(toggle)
|
|
})
|
|
|
|
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
|
})
|
|
|
|
it('should toggle ACL on', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
|
|
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('toggle-acl'))
|
|
const toggle = screen.getByTestId('toggle-acl')
|
|
await user.click(toggle)
|
|
|
|
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
|
})
|
|
|
|
it('should toggle Rate Limiting on', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
|
|
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
|
const toggle = screen.getByTestId('toggle-rate-limit')
|
|
await user.click(toggle)
|
|
|
|
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.rate_limit.enabled', 'true', 'security', 'bool'))
|
|
})
|
|
})
|
|
|
|
describe('Admin Whitelist', () => {
|
|
it('should load admin whitelist from config', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
|
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should update admin whitelist on save', async () => {
|
|
const user = userEvent.setup()
|
|
const mockMutate = vi.fn()
|
|
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
|
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
|
|
|
const saveButton = screen.getByRole('button', { name: /Save/i })
|
|
await user.click(saveButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mockMutate).toHaveBeenCalledWith({ name: 'default', admin_whitelist: '10.0.0.0/8' })
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('CrowdSec Controls', () => {
|
|
it('should start CrowdSec when toggling on', async () => {
|
|
const user = userEvent.setup()
|
|
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('toggle-crowdsec'))
|
|
const toggle = screen.getByTestId('toggle-crowdsec')
|
|
await act(async () => {
|
|
await user.click(toggle)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool')
|
|
expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
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 })
|
|
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
|
const toggle = screen.getByTestId('toggle-crowdsec')
|
|
await act(async () => {
|
|
await user.click(toggle)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool')
|
|
expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should export CrowdSec config', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['config data']))
|
|
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
|
window.URL.revokeObjectURL = vi.fn()
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
|
const exportButton = screen.getByRole('button', { name: /Export/i })
|
|
await user.click(exportButton)
|
|
|
|
await waitFor(() => {
|
|
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
|
|
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('WAF Controls', () => {
|
|
it('should change WAF mode', async () => {
|
|
const user = userEvent.setup()
|
|
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
|
const mockMutate = vi.fn()
|
|
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('waf-mode-select'))
|
|
const select = screen.getByTestId('waf-mode-select')
|
|
await user.selectOptions(select, 'monitor')
|
|
|
|
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({ name: 'default', waf_mode: 'monitor' }))
|
|
})
|
|
|
|
it('should change WAF ruleset', async () => {
|
|
const user = userEvent.setup()
|
|
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
|
const mockMutate = vi.fn()
|
|
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('waf-ruleset-select'))
|
|
const select = screen.getByTestId('waf-ruleset-select')
|
|
await user.selectOptions(select, 'OWASP CRS')
|
|
|
|
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({ name: 'default', waf_rules_source: 'OWASP CRS' }))
|
|
})
|
|
})
|
|
|
|
describe('Card Order (Pipeline Sequence)', () => {
|
|
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
|
|
|
// Get all card headings
|
|
const cards = screen.getAllByRole('heading', { level: 3 })
|
|
const cardNames = cards.map(card => card.textContent)
|
|
|
|
// Verify pipeline order: CrowdSec (Layer 1) → ACL (Layer 2) → WAF (Layer 3) → Rate Limiting (Layer 4)
|
|
expect(cardNames).toEqual(['CrowdSec', 'Access Control', 'WAF (Coraza)', 'Rate Limiting'])
|
|
})
|
|
|
|
it('should display layer indicators on each card', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
|
|
|
// Verify each layer indicator is present
|
|
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/Layer 2: Access Control/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/Layer 3: Request Inspection/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/Layer 4: Volume Control/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('should display threat protection summaries', async () => {
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
|
|
await renderSecurityPage()
|
|
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
|
|
|
// Verify threat protection descriptions
|
|
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Loading Overlay', () => {
|
|
it('should show overlay when service is toggling', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
|
|
|
await renderSecurityPage()
|
|
|
|
await waitFor(() => screen.getByTestId('toggle-waf'))
|
|
const toggle = screen.getByTestId('toggle-waf')
|
|
await user.click(toggle)
|
|
|
|
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
|
})
|
|
|
|
it('should show overlay when starting CrowdSec', async () => {
|
|
const user = userEvent.setup()
|
|
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('toggle-crowdsec'))
|
|
const toggle = screen.getByTestId('toggle-crowdsec')
|
|
await user.click(toggle)
|
|
|
|
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
|
|
})
|
|
|
|
it('should show overlay when stopping CrowdSec', async () => {
|
|
const user = userEvent.setup()
|
|
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
|
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
|
|
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
|
|
|
await renderSecurityPage()
|
|
|
|
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())
|
|
})
|
|
})
|
|
})
|