import { AxiosError } from 'axios' import { screen, waitFor, act, cleanup, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient } from '@tanstack/react-query' import { describe, it, expect, vi, beforeEach } from 'vitest' import CrowdSecConfig from '../CrowdSecConfig' import * as securityApi from '../../api/security' import * as crowdsecApi from '../../api/crowdsec' import * as presetsApi from '../../api/presets' import * as backupsApi from '../../api/backups' import * as settingsApi from '../../api/settings' import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets' import { renderWithQueryClient, createTestQueryClient } from '../../test-utils/renderWithQueryClient' import { toast } from '../../utils/toast' import * as exportUtils from '../../utils/crowdsecExport' vi.mock('../../api/security') vi.mock('../../api/crowdsec') vi.mock('../../api/presets') vi.mock('../../api/backups') vi.mock('../../api/settings') vi.mock('../../utils/crowdsecExport', () => ({ buildCrowdsecExportFilename: vi.fn(() => 'crowdsec-default.tar.gz'), promptCrowdsecFilename: vi.fn(() => 'crowdsec.tar.gz'), downloadCrowdsecExport: vi.fn(), })) vi.mock('../../utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), }, })) const baseStatus = { cerberus: { enabled: true }, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, waf: { enabled: true, mode: 'enabled' as const }, rate_limit: { enabled: true }, acl: { enabled: true }, } const disabledStatus = { ...baseStatus, crowdsec: { ...baseStatus.crowdsec, enabled: true, mode: 'disabled' as const }, } const presetFromCatalog = CROWDSEC_PRESETS[0] const axiosError = (status: number, message: string, data?: Record) => new AxiosError(message, undefined, undefined, undefined, { status, statusText: String(status), headers: {}, config: {}, data: data ?? { error: message }, } as never) const defaultFileList = ['acquis.yaml', 'collections.yaml'] const renderPage = async (client?: QueryClient) => { const result = renderWithQueryClient(, { client }) await waitFor(() => screen.getByText('CrowdSec Configuration')) return result } describe('CrowdSecConfig coverage', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus) vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: defaultFileList }) vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'file-content' }) vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue(undefined) vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] }) vi.mocked(crowdsecApi.banIP).mockResolvedValue(undefined) vi.mocked(crowdsecApi.unbanIP).mockResolvedValue(undefined) vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data'])) vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue(undefined) vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [ { slug: presetFromCatalog.slug, title: presetFromCatalog.title, summary: presetFromCatalog.description, source: 'hub', requires_hub: false, available: true, cached: false, cache_key: 'cache-123', etag: 'etag-123', retrieved_at: '2024-01-01T00:00:00Z', }, ], }) vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({ status: 'pulled', slug: presetFromCatalog.slug, preview: presetFromCatalog.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: true, used_cscli: true, cache_key: 'cache-123', slug: presetFromCatalog.slug, }) vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-123', etag: 'etag-123', }) vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' }) vi.mocked(settingsApi.updateSetting).mockResolvedValue() }) it('renders loading and error boundaries', async () => { vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {})) renderWithQueryClient() expect(await screen.findByText('Loading CrowdSec configuration...')).toBeInTheDocument() cleanup() vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('boom')) renderWithQueryClient() expect(await screen.findByText(/Error loading CrowdSec status/)).toBeInTheDocument() }) it('handles missing status and missing crowdsec sections', async () => { vi.mocked(securityApi.getSecurityStatus).mockRejectedValueOnce(new Error('data is undefined')) renderWithQueryClient() expect(await screen.findByText(/Error loading CrowdSec status/)).toBeInTheDocument() cleanup() vi.mocked(securityApi.getSecurityStatus).mockResolvedValueOnce({ cerberus: { enabled: true } } as never) renderWithQueryClient() expect(await screen.findByText('CrowdSec configuration not found in security status')).toBeInTheDocument() }) it('renders disabled mode message and bans control disabled', async () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus) await renderPage(createTestQueryClient()) expect(screen.getByText('Enable CrowdSec to manage banned IPs')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Ban IP/ })).toBeDisabled() }) it('shows info banner directing to Security Dashboard', async () => { await renderPage() expect(screen.getByText(/CrowdSec is controlled via the toggle on the/i)).toBeInTheDocument() expect(screen.getByRole('link', { name: /Security/i })).toHaveAttribute('href', '/security') }) it('guards import without a file and shows error on import failure', async () => { await renderPage() const importBtn = screen.getByTestId('import-btn') await userEvent.click(importBtn) expect(backupsApi.createBackup).not.toHaveBeenCalled() const fileInput = screen.getByTestId('import-file') as HTMLInputElement const file = new File(['data'], 'cfg.tar.gz') await userEvent.upload(fileInput, file) vi.mocked(crowdsecApi.importCrowdsecConfig).mockRejectedValueOnce(new Error('bad import')) await userEvent.click(importBtn) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('bad import')) }) it('imports configuration after creating a backup', async () => { await renderPage() const fileInput = screen.getByTestId('import-file') as HTMLInputElement await userEvent.upload(fileInput, new File(['data'], 'cfg.tar.gz')) await userEvent.click(screen.getByTestId('import-btn')) await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled()) await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled()) }) it('exports configuration success and failure', async () => { await renderPage() await userEvent.click(screen.getByRole('button', { name: 'Export' })) await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()) expect(exportUtils.downloadCrowdsecExport).toHaveBeenCalled() expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported') vi.mocked(exportUtils.promptCrowdsecFilename).mockReturnValueOnce('crowdsec.tar.gz') vi.mocked(crowdsecApi.exportCrowdsecConfig).mockRejectedValueOnce(new Error('fail')) await userEvent.click(screen.getByRole('button', { name: 'Export' })) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to export CrowdSec configuration')) }) it('auto-selects first preset and pulls preview', async () => { await renderPage() // Component auto-selects first preset from the list on render await waitFor(() => expect(presetsApi.pullCrowdsecPreset).toHaveBeenCalledWith(presetFromCatalog.slug)) const previewText = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ') expect(previewText).toContain('crowdsecurity/http-cve') expect(screen.getByTestId('preset-meta')).toHaveTextContent('cache-123') }) it('handles pull validation, hub unavailable, and generic errors', async () => { vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(400, 'slug invalid', { error: 'slug invalid' })) await renderPage() expect(await screen.findByTestId('preset-validation-error')).toHaveTextContent('slug invalid') vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub down', { error: 'hub down' })) await userEvent.click(screen.getByText('Pull Preview')) await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument()) vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'boom', { error: 'boom' })) await userEvent.click(screen.getByText('Pull Preview')) await waitFor(() => expect(screen.getByTestId('preset-status')).toHaveTextContent('boom')) }) it('loads cached preview and reports cache errors', async () => { vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({ presets: [ { slug: presetFromCatalog.slug, title: presetFromCatalog.title, summary: presetFromCatalog.description, source: 'hub', requires_hub: false, available: true, cached: true, cache_key: 'cache-123', etag: 'etag-123', retrieved_at: '2024-01-01T00:00:00Z', }, ], }) await renderPage() await userEvent.click(screen.getByText('Pull Preview')) await waitFor(() => { const preview = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ') expect(preview).toContain('crowdsecurity/http-cve') }) await userEvent.click(screen.getByText('Load cached preview')) await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview')) vi.mocked(presetsApi.getCrowdsecPresetCache).mockRejectedValueOnce(axiosError(500, 'cache-miss')) await userEvent.click(screen.getByText('Load cached preview')) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('cache-miss')) }) it('sets apply info on backend success', async () => { await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: /tmp/backup.tar.gz')) }) it('falls back to local apply on 501 and covers validation/hub/offline branches', async () => { vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({}) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented')) await renderPage() const applyBtn = screen.getByTestId('apply-preset-btn') await userEvent.click(applyBtn) await waitFor(() => expect(toast.info).toHaveBeenCalledWith('Preset apply is not available on the server; applying locally instead')) await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalled()) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(400, 'bad', { error: 'validation failed' })) await userEvent.click(applyBtn) await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('validation failed')) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub')) await userEvent.click(applyBtn) await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument()) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'not cached', { error: 'Pull the preset first' })) await userEvent.click(applyBtn) await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('Preset must be pulled')) }) it('records backup info on apply failure and generic errors', async () => { vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'failed', { error: 'boom', backup: '/tmp/backup' })) await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/backup')) cleanup() vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(new Error('unexpected')) await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to apply preset')) }) it('disables apply when hub is unavailable for hub-only preset', async () => { 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', }, ], }) vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub')) await renderPage() await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument()) expect((screen.getByTestId('apply-preset-btn') as HTMLButtonElement).disabled).toBe(true) }) it('guards local apply prerequisites and succeeds when content exists', async () => { vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValueOnce({ files: [] }) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented')) await renderPage() await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Select a configuration file to apply the preset')) cleanup() vi.mocked(toast.error).mockClear() vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] }) vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [ { slug: 'custom-empty', title: 'Empty', summary: 'empty preset', source: 'hub', requires_hub: false, available: true, cached: false, cache_key: 'cache-empty', etag: 'etag-empty', }, ], }) vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({ status: 'pulled', slug: 'custom-empty', preview: '', cache_key: 'cache-empty', }) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented')) await renderPage() await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml') await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Preset preview is unavailable; retry pulling before applying')) cleanup() vi.mocked(toast.error).mockClear() vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({ status: 'pulled', slug: presetFromCatalog.slug, preview: 'content', cache_key: 'cache-123', }) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented')) vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({}) await renderPage() await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml') await userEvent.click(screen.getByTestId('apply-preset-btn')) await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalled()) }) it('reads, edits, saves, and closes files', async () => { await renderPage() await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml') await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('acquis.yaml')) // Use getAllByRole and filter for textarea (not the search input) const textareas = screen.getAllByRole('textbox') const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea') as HTMLTextAreaElement expect(textarea.value).toBe('file-content') await userEvent.clear(textarea) await userEvent.type(textarea, 'updated') await userEvent.click(screen.getByText('Save')) await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled()) await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', 'updated')) await userEvent.click(screen.getByText('Close')) expect((screen.getByTestId('crowdsec-file-select') as HTMLSelectElement).value).toBe('') }) it('shows decisions table, handles loading/error/empty states, and unban errors', async () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus) await renderPage() expect(screen.getByText('Enable CrowdSec to manage banned IPs')).toBeInTheDocument() cleanup() vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus) vi.mocked(crowdsecApi.listCrowdsecDecisions).mockReturnValue(new Promise(() => {})) await renderPage() expect(screen.getByText('Loading banned IPs...')).toBeInTheDocument() cleanup() vi.mocked(crowdsecApi.listCrowdsecDecisions).mockRejectedValueOnce(new Error('decisions')) await renderPage() expect(await screen.findByText('Failed to load banned IPs')).toBeInTheDocument() cleanup() vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({ decisions: [] }) await renderPage() expect(await screen.findByText('No banned IPs')).toBeInTheDocument() cleanup() vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({ decisions: [ { id: '1', ip: '1.1.1.1', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' }, ], }) await renderPage() expect(await screen.findByText('1.1.1.1')).toBeInTheDocument() vi.mocked(crowdsecApi.unbanIP).mockRejectedValueOnce(new Error('unban fail')) await userEvent.click(screen.getAllByText('Unban')[0]) const confirmModal = screen.getByText('Confirm Unban').closest('div') as HTMLElement await userEvent.click(within(confirmModal).getByRole('button', { name: 'Unban' })) await waitFor(() => expect(toast.error).toHaveBeenCalledWith('unban fail')) }) it('bans and unbans IPs with overlay messaging', async () => { vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [ { id: '1', ip: '1.1.1.1', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' }, ], }) await renderPage() await userEvent.click(screen.getByRole('button', { name: /Ban IP/ })) const banModal = screen.getByText('Ban IP Address').closest('div') as HTMLElement const ipInput = within(banModal).getByPlaceholderText('192.168.1.100') as HTMLInputElement await userEvent.type(ipInput, '2.2.2.2') await userEvent.click(within(banModal).getByRole('button', { name: 'Ban IP' })) await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('2.2.2.2', '24h', '')) // keep ban pending to assert overlay message let resolveBan: (() => void) | undefined vi.mocked(crowdsecApi.banIP).mockImplementationOnce( () => new Promise((resolve) => { resolveBan = () => resolve() }), ) await userEvent.click(screen.getByRole('button', { name: /Ban IP/ })) const banModalSecond = screen.getByText('Ban IP Address').closest('div') as HTMLElement await userEvent.type(within(banModalSecond).getByPlaceholderText('192.168.1.100'), '3.3.3.3') await userEvent.click(within(banModalSecond).getByRole('button', { name: 'Ban IP' })) expect(await screen.findByText('Guardian raises shield...')).toBeInTheDocument() resolveBan?.() vi.mocked(crowdsecApi.unbanIP).mockImplementationOnce(() => new Promise(() => {})) const unbanButtons = await screen.findAllByText('Unban') await userEvent.click(unbanButtons[0]) const confirmDialog = screen.getByText('Confirm Unban').closest('div') as HTMLElement await userEvent.click(within(confirmDialog).getByRole('button', { name: 'Unban' })) expect(await screen.findByText('Guardian lowers shield...')).toBeInTheDocument() }) it('shows overlay messaging for preset pull, apply, import, write, and mode updates', async () => { // pull pending vi.mocked(presetsApi.pullCrowdsecPreset).mockImplementation(() => new Promise(() => {})) await renderPage() await userEvent.click(screen.getByText('Pull Preview')) expect(await screen.findByText('Fetching preset...')).toBeInTheDocument() cleanup() vi.mocked(presetsApi.pullCrowdsecPreset).mockReset() vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({ status: 'pulled', slug: presetFromCatalog.slug, preview: presetFromCatalog.content, cache_key: 'cache-123', }) // apply pending vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValueOnce({ status: 'pulled', slug: presetFromCatalog.slug, preview: presetFromCatalog.content, cache_key: 'cache-123', }) let resolveApply: (() => void) | undefined vi.mocked(presetsApi.applyCrowdsecPreset).mockImplementationOnce( () => new Promise((resolve) => { resolveApply = () => resolve({ status: 'applied', cache_key: 'cache-123' } as never) }), ) await renderPage() await userEvent.click(screen.getAllByTestId('apply-preset-btn')[0]) expect(await screen.findByText('Loading preset...')).toBeInTheDocument() resolveApply?.() cleanup() // import pending vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValueOnce({ status: 'pulled', slug: presetFromCatalog.slug, preview: presetFromCatalog.content, cache_key: 'cache-123', }) let resolveImport: (() => void) | undefined vi.mocked(crowdsecApi.importCrowdsecConfig).mockImplementationOnce( () => new Promise((resolve) => { resolveImport = () => resolve({}) }), ) const { queryClient } = await renderPage(createTestQueryClient()) await waitFor(() => expect(screen.getByTestId('preset-preview')).toBeInTheDocument()) const fileInput = screen.getByTestId('import-file') as HTMLInputElement await userEvent.upload(fileInput, new File(['data'], 'cfg.tar.gz')) await userEvent.click(screen.getByTestId('import-btn')) expect(await screen.findByText('Summoning the guardian...')).toBeInTheDocument() resolveImport?.() await act(async () => queryClient.cancelQueries()) cleanup() // write pending shows loading overlay let resolveWrite: (() => void) | undefined vi.mocked(crowdsecApi.writeCrowdsecFile).mockImplementationOnce( () => new Promise((resolve) => { resolveWrite = () => resolve({}) }), ) await renderPage() await waitFor(() => expect(screen.getByTestId('preset-preview')).toBeInTheDocument()) await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml') // Use getAllByRole and filter for textarea (not the search input) const textareas = screen.getAllByRole('textbox') const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea') as HTMLTextAreaElement await userEvent.type(textarea, 'x') await userEvent.click(screen.getByText('Save')) expect(await screen.findByText('Guardian inscribes...')).toBeInTheDocument() resolveWrite?.() }) })