chore: refactor tests to improve clarity and reliability

- Removed unnecessary test.skip() calls in various test files, replacing them with comments for clarity.
- Enhanced retry logic in TestDataManager for API requests to handle rate limiting more gracefully.
- Updated security helper functions to include retry mechanisms for fetching security status and setting module states.
- Improved loading completion checks to handle page closure scenarios.
- Adjusted WebKit-specific tests to run in all browsers, removing the previous skip logic.
- General cleanup and refactoring across multiple test files to enhance readability and maintainability.
This commit is contained in:
GitHub Actions
2026-02-08 00:02:09 +00:00
parent 5054a334f2
commit aa85c911c0
71 changed files with 22475 additions and 3241 deletions
@@ -0,0 +1,332 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'
import AccessLists from '../AccessLists'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import type { AccessList, CreateAccessListRequest, TestIPResponse } from '../../api/accessLists'
import type { AccessListFormData } from '../../components/AccessListForm'
import { createBackup } from '../../api/backups'
import {
useAccessLists,
useCreateAccessList,
useDeleteAccessList,
useTestIP,
useUpdateAccessList,
} from '../../hooks/useAccessLists'
const translations: Record<string, string> = {
'accessLists.noAccessLists': 'No Access Lists',
'accessLists.noAccessListsDescription': 'No access list description',
'accessLists.createAccessList': 'Create Access List',
'accessLists.deleteAccessList': 'Delete Access List',
'accessLists.deleteSelectedAccessLists': 'Delete Selected Access Lists',
'accessLists.deleteItems': 'Delete ({{count}})',
'accessLists.testIp': 'Test IP',
'accessLists.testIpAddress': 'Test IP Address',
'accessLists.ipAddress': 'IP Address',
'accessLists.accessList': 'Access List',
'accessLists.deleteConfirmation': 'Delete {{name}}?',
'accessLists.bulkDeleteConfirmation': 'Delete {{count}}?',
'common.delete': 'Delete',
'common.cancel': 'Cancel',
'common.deleting': 'Deleting',
'common.test': 'Test',
'common.close': 'Close',
}
const t = (key: string, options?: Record<string, unknown>) => {
const template = translations[key] ?? key
if (!options) return template
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
return acc.replace(`{{${optionKey}}}`, String(optionValue))
}, template)
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t,
}),
}))
interface MockAccessListFormProps {
onSubmit: (data: AccessListFormData) => void
onCancel: () => void
onDelete?: () => void
isLoading?: boolean
isDeleting?: boolean
initialData?: AccessList
}
const defaultAccessList: AccessList = {
id: 1,
uuid: 'acl-1',
name: 'Office Access',
description: 'Office CIDR',
type: 'whitelist',
ip_rules: '[{"cidr":"10.0.0.0/8"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2026-02-01T10:00:00Z',
updated_at: '2026-02-01T10:00:00Z',
}
const createAccessList = (overrides: Partial<AccessList> = {}): AccessList => ({
...defaultAccessList,
...overrides,
})
const createMutationResult = <TData, TVariables>(
overrides: Partial<UseMutationResult<TData, Error, TVariables, unknown>> = {},
mutateImpl?: (
variables: TVariables,
options?: {
onSuccess?: (result: TData) => void
onError?: (error: Error) => void
onSettled?: () => void
}
) => void
): UseMutationResult<TData, Error, TVariables, unknown> => {
const mutate = vi.fn((variables: TVariables, options?: { onSuccess?: (result: TData) => void; onError?: (error: Error) => void; onSettled?: () => void }) => {
mutateImpl?.(variables, options)
}) as UseMutationResult<TData, Error, TVariables, unknown>['mutate']
const mutateAsync = vi.fn() as UseMutationResult<TData, Error, TVariables, unknown>['mutateAsync']
return {
mutate,
mutateAsync,
reset: vi.fn(),
status: 'idle',
data: undefined,
error: null,
variables: undefined as unknown as TVariables,
context: undefined,
failureCount: 0,
failureReason: null,
isPaused: false,
isError: false,
isIdle: true,
isPending: false,
isSuccess: false,
submittedAt: 0,
...overrides,
} as UseMutationResult<TData, Error, TVariables, unknown>
}
const createQueryResult = <TData,>(data: TData): UseQueryResult<TData, Error> => ({
data,
error: null,
isError: false,
isLoading: false,
isPending: false,
isLoadingError: false,
isRefetchError: false,
isSuccess: true,
status: 'success',
fetchStatus: 'idle',
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isPlaceholderData: false,
isRefetching: false,
isStale: false,
refetch: vi.fn(),
} as unknown as UseQueryResult<TData, Error>)
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(),
useCreateAccessList: vi.fn(),
useUpdateAccessList: vi.fn(),
useDeleteAccessList: vi.fn(),
useTestIP: vi.fn(),
}))
vi.mock('../../api/backups', () => ({
createBackup: vi.fn(),
}))
vi.mock('react-hot-toast', () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('../../components/AccessListForm', () => ({
AccessListForm: ({ onSubmit, onCancel, onDelete, isLoading, isDeleting }: MockAccessListFormProps) => (
<div>
<div>AccessListForm</div>
<button
type="button"
onClick={() =>
onSubmit({
name: 'New List',
description: '',
type: 'whitelist',
ip_rules: '',
country_codes: '',
local_network_only: false,
enabled: true,
})
}
disabled={isLoading}
>
Submit
</button>
<button type="button" onClick={onCancel}>
Cancel
</button>
{onDelete ? (
<button type="button" onClick={onDelete} disabled={isDeleting}>
Delete
</button>
) : null}
</div>
),
}))
describe('AccessLists', () => {
const createMutationMock = (): ReturnType<typeof useCreateAccessList> =>
createMutationResult<AccessList, CreateAccessListRequest>()
const updateMutationMock = (): ReturnType<typeof useUpdateAccessList> =>
createMutationResult<AccessList, { id: number; data: Partial<CreateAccessListRequest> }>()
const deleteMutationMock = (): ReturnType<typeof useDeleteAccessList> =>
createMutationResult<void, number>({}, (_id, options) => options?.onSuccess?.(undefined))
const testIPMutationMock = (): ReturnType<typeof useTestIP> =>
createMutationResult<TestIPResponse, { id: number; ipAddress: string }>({}, (_payload, options) =>
options?.onSuccess?.({ allowed: true, reason: 'Allowed by rule' })
)
beforeEach(() => {
vi.clearAllMocks()
if (!vi.isMockFunction(window.open)) {
vi.spyOn(window, 'open').mockImplementation(() => null)
}
vi.mocked(useCreateAccessList).mockReturnValue(createMutationMock())
vi.mocked(useUpdateAccessList).mockReturnValue(updateMutationMock())
vi.mocked(useDeleteAccessList).mockReturnValue(deleteMutationMock())
vi.mocked(useTestIP).mockReturnValue(testIPMutationMock())
})
it('renders empty state and opens create form', async () => {
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([]))
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
expect(await screen.findByText(t('accessLists.noAccessLists'))).toBeInTheDocument()
const emptyStateHeading = await screen.findByRole('heading', { name: t('accessLists.noAccessLists') })
const emptyStateContainer = emptyStateHeading.closest('div')
expect(emptyStateContainer).not.toBeNull()
await user.click(within(emptyStateContainer as HTMLElement).getByRole('button', { name: t('accessLists.createAccessList') }))
expect(screen.getByText('AccessListForm')).toBeInTheDocument()
})
it('shows CGNAT warning and allows dismiss', async () => {
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([createAccessList()]))
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
const alert = await screen.findByRole('alert')
expect(alert).toBeInTheDocument()
await user.click(within(alert).getByRole('button'))
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
it('deletes access list with backup', async () => {
const deleteMutation = deleteMutationMock()
vi.mocked(useDeleteAccessList).mockReturnValue(deleteMutation)
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([createAccessList()]))
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
const row = (await screen.findByText('Office Access')).closest('tr')
expect(row).not.toBeNull()
await user.click(within(row as HTMLElement).getByTitle(t('common.delete')))
const dialog = await screen.findByRole('dialog', { name: t('accessLists.deleteAccessList') })
await user.click(within(dialog).getByRole('button', { name: t('common.delete') }))
await waitFor(() => {
expect(createBackup).toHaveBeenCalled()
expect(deleteMutation.mutate).toHaveBeenCalledWith(1, expect.any(Object))
})
})
it('bulk deletes selected access lists', async () => {
const deleteMutation = deleteMutationMock()
vi.mocked(useDeleteAccessList).mockReturnValue(deleteMutation)
vi.mocked(useAccessLists).mockReturnValue(
createQueryResult([createAccessList({ id: 1 }), createAccessList({ id: 2, uuid: 'acl-2', name: 'Branch Office' })])
)
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
await user.click(await screen.findByRole('checkbox', { name: 'Select row 1' }))
const bulkDeleteButton = await screen.findByRole('button', { name: `${t('common.delete')} (1)` })
await user.click(bulkDeleteButton)
const dialog = await screen.findByRole('dialog', { name: t('accessLists.deleteSelectedAccessLists') })
await user.click(within(dialog).getByRole('button', { name: t('accessLists.deleteItems', { count: 1 }) }))
await waitFor(() => {
expect(createBackup).toHaveBeenCalled()
expect(deleteMutation.mutate).toHaveBeenCalledTimes(1)
})
})
it('tests IP against access list', async () => {
const testIPMutation = testIPMutationMock()
vi.mocked(useTestIP).mockReturnValue(testIPMutation)
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([createAccessList()]))
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
const row = (await screen.findByText('Office Access')).closest('tr')
expect(row).not.toBeNull()
await user.click(within(row as HTMLElement).getByTitle(t('accessLists.testIp')))
const dialog = await screen.findByRole('dialog', { name: t('accessLists.testIpAddress') })
const input = within(dialog).getByRole('textbox')
await user.type(input, '192.168.1.5')
await user.click(within(dialog).getByRole('button', { name: t('common.test') }))
await waitFor(() => {
expect(testIPMutation.mutate).toHaveBeenCalledWith(
{ id: 1, ipAddress: '192.168.1.5' },
expect.any(Object)
)
})
})
})
@@ -0,0 +1,147 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Certificates from '../Certificates'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import type { Certificate } from '../../api/certificates'
import { uploadCertificate } from '../../api/certificates'
import { toast } from '../../utils/toast'
const translations: Record<string, string> = {
'certificates.addCertificate': 'Add Certificate',
'certificates.uploadCertificate': 'Upload Certificate',
'certificates.friendlyName': 'Friendly Name',
'certificates.certificatePem': 'Certificate (PEM)',
'certificates.privateKeyPem': 'Private Key (PEM)',
'certificates.uploadSuccess': 'Certificate uploaded successfully',
'certificates.uploadFailed': 'Failed to upload certificate',
'common.upload': 'Upload',
'common.cancel': 'Cancel',
}
const t = (key: string, options?: Record<string, unknown>) => {
const template = translations[key] ?? key
if (!options) return template
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
return acc.replace(`{{${optionKey}}}`, String(optionValue))
}, template)
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t,
}),
}))
vi.mock('../../components/CertificateList', () => ({
default: () => <div>CertificateList</div>,
}))
vi.mock('../../api/certificates', () => ({
uploadCertificate: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
describe('Certificates', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uploads certificate and closes dialog on success', async () => {
const certificate: Certificate = {
domain: 'example.com',
issuer: 'Test CA',
expires_at: '2026-03-01T00:00:00Z',
status: 'valid',
provider: 'custom',
}
vi.mocked(uploadCertificate).mockResolvedValue(certificate)
const user = userEvent.setup()
const { queryClient } = renderWithQueryClient(<Certificates />)
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] })
expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess'))
})
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument()
})
})
it('surfaces upload errors', async () => {
vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed'))
const user = userEvent.setup()
renderWithQueryClient(<Certificates />)
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`)
})
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
@@ -157,16 +157,13 @@ describe('CrowdSecConfig', () => {
await user.click(screen.getByTestId('ban-ip-trigger'))
// Modal opens
await waitFor(() => screen.getByText('Ban IP Address'))
const dialog = await screen.findByRole('dialog', { name: 'Ban IP Address' })
// Fill form
await user.type(screen.getByLabelText(/IP Address/i), '5.6.7.8')
await user.type(screen.getByPlaceholderText(/Reason for ban/i), 'manual ban')
await user.type(within(dialog).getByLabelText(/IP Address/i), '5.6.7.8')
await user.type(within(dialog).getByPlaceholderText(/Reason for ban/i), 'manual ban')
// Submit - Target the last button with name "Ban IP" (modal button)
const buttons = screen.getAllByRole('button', { name: 'Ban IP' })
const submitBtn = buttons[buttons.length - 1]
await user.click(submitBtn)
await user.click(within(dialog).getByRole('button', { name: 'Ban IP' }))
await waitFor(() => {
expect(crowdsecApi.banIP).toHaveBeenCalledWith('5.6.7.8', '24h', 'manual ban')
@@ -6,6 +6,17 @@ import * as smtpApi from '../../api/smtp'
import { toast } from '../../utils/toast'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
const translations: Record<string, string> = {
'smtp.configured': 'SMTP Configured',
'smtp.notConfigured': 'SMTP Not Configured',
'smtp.saveSettings': 'Save Settings',
'smtp.testConnection': 'Test Connection',
'smtp.sendTestEmail': 'Send Test Email',
'smtp.sendTest': 'Send Test',
}
const t = (key: string) => translations[key] ?? key
// Mock API
vi.mock('../../api/smtp', () => ({
getSMTPConfig: vi.fn(),
@@ -62,7 +73,7 @@ describe('SMTPSettings', () => {
const portInput = screen.getByPlaceholderText('587') as HTMLInputElement
expect(portInput.value).toBe('587')
expect(screen.getByText('SMTP Configured')).toBeTruthy()
expect(screen.getByText(t('smtp.configured'))).toBeTruthy()
})
it('shows not configured state when SMTP is not set up', async () => {
@@ -79,7 +90,7 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('SMTP Not Configured')).toBeTruthy()
expect(screen.getByText(t('smtp.notConfigured'))).toBeTruthy()
})
})
@@ -110,7 +121,7 @@ describe('SMTPSettings', () => {
'test@example.com'
)
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
await user.click(screen.getByRole('button', { name: t('smtp.saveSettings') }))
await waitFor(() => {
expect(smtpApi.updateSMTPConfig).toHaveBeenCalled()
@@ -134,12 +145,11 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Test Connection')).toBeTruthy()
})
const testButton = await screen.findByRole('button', { name: t('smtp.testConnection') })
await waitFor(() => expect(testButton).toBeEnabled())
const user = userEvent.setup()
await user.click(screen.getByText('Test Connection'))
await user.click(testButton)
await waitFor(() => {
expect(smtpApi.testSMTPConnection).toHaveBeenCalled()
@@ -160,7 +170,7 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
expect(screen.getByText(t('smtp.sendTestEmail'))).toBeTruthy()
})
expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy()
@@ -184,7 +194,7 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
expect(screen.getByText(t('smtp.sendTestEmail'))).toBeTruthy()
})
const user = userEvent.setup()
@@ -192,7 +202,7 @@ describe('SMTPSettings', () => {
screen.getByPlaceholderText('recipient@example.com'),
'test@test.com'
)
await user.click(screen.getByRole('button', { name: /Send Test/i }))
await user.click(screen.getByRole('button', { name: t('smtp.sendTest') }))
await waitFor(() => {
expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' })
@@ -218,7 +228,7 @@ describe('SMTPSettings', () => {
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'bad.host')
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'ops@example.com')
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
await user.click(screen.getByRole('button', { name: t('smtp.saveSettings') }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('invalid host')
@@ -240,15 +250,17 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Test Connection')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(t('smtp.testConnection'))).toBeInTheDocument())
// Button should start disabled until host and from address are provided
expect(screen.getByRole('button', { name: 'Test Connection' })).toBeDisabled()
const testButton = screen.getByRole('button', { name: t('smtp.testConnection') })
expect(testButton).toBeDisabled()
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.acme.local')
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'from@acme.local')
await user.click(screen.getByRole('button', { name: 'Test Connection' }))
await waitFor(() => expect(testButton).toBeEnabled())
await user.click(testButton)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('cannot connect')
@@ -270,11 +282,11 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Send Test Email')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(t('smtp.sendTestEmail'))).toBeInTheDocument())
const input = screen.getByPlaceholderText('recipient@example.com') as HTMLInputElement
await user.type(input, 'keepme@example.com')
await user.click(screen.getByRole('button', { name: /Send Test/i }))
await user.click(screen.getByRole('button', { name: t('smtp.sendTest') }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('smtp unreachable')
@@ -26,6 +26,33 @@ vi.mock('../../api/logs', () => ({
connectLiveLogs: vi.fn(() => vi.fn()),
connectSecurityLogs: vi.fn(() => vi.fn()),
}))
vi.mock('../../components/LiveLogViewer', () => ({
LiveLogViewer: () => <div data-testid="live-log-viewer" />,
}))
vi.mock('../../components/SecurityNotificationSettingsModal', () => ({
SecurityNotificationSettingsModal: () => <div data-testid="security-notification-modal" />,
}))
vi.mock('../../components/CrowdSecKeyWarning', () => ({
CrowdSecKeyWarning: () => null,
}))
vi.mock('../../hooks/useNotifications', () => ({
useSecurityNotificationSettings: () => ({
data: {
enabled: false,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: true,
webhook_url: '',
email_recipients: '',
},
isLoading: false,
}),
useUpdateSecurityNotificationSettings: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
const defaultFeatureFlags = {
'feature.cerberus.enabled': true,
@@ -84,6 +111,7 @@ describe('Security page', () => {
// Mock WebSocket connections for LiveLogViewer
vi.mocked(logsApi.connectLiveLogs).mockReturnValue(vi.fn())
vi.mocked(logsApi.connectSecurityLogs).mockReturnValue(vi.fn())
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue({
env_key_rejected: false,
key_source: 'auto-generated',
@@ -4,7 +4,11 @@ import { MemoryRouter } from 'react-router-dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import SecurityHeaders from '../../pages/SecurityHeaders';
import { securityHeadersApi, SecurityHeaderProfile } from '../../api/securityHeaders';
import {
securityHeadersApi,
SecurityHeaderProfile,
type ScoreBreakdown,
} from '../../api/securityHeaders';
import { createBackup } from '../../api/backups';
vi.mock('../../api/securityHeaders');
@@ -26,6 +30,48 @@ const createWrapper = () => {
);
};
const createProfile = (
overrides: Partial<SecurityHeaderProfile> = {}
): SecurityHeaderProfile => ({
id: 1,
uuid: 'profile-uuid-1',
name: 'Profile',
hsts_enabled: false,
hsts_max_age: 0,
hsts_include_subdomains: false,
hsts_preload: false,
csp_enabled: false,
csp_directives: '',
csp_report_only: false,
csp_report_uri: '',
x_frame_options: '',
x_content_type_options: false,
referrer_policy: '',
permissions_policy: '',
cross_origin_opener_policy: '',
cross_origin_resource_policy: '',
cross_origin_embedder_policy: '',
xss_protection: false,
cache_control_no_store: false,
security_score: 0,
is_preset: false,
preset_type: '',
description: '',
created_at: '2025-12-18T00:00:00Z',
updated_at: '2025-12-18T00:00:00Z',
...overrides,
});
const createScoreBreakdown = (
overrides: Partial<ScoreBreakdown> = {}
): ScoreBreakdown => ({
score: 50,
max_score: 100,
breakdown: {},
suggestions: [],
...overrides,
});
describe('SecurityHeaders', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -678,7 +724,15 @@ describe('SecurityHeaders', () => {
it('should close create dialog on success', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({ id: 1, name: 'New Profile', security_score: 50, created_at: '', updated_at: '' } as any);
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue(
createProfile({
id: 1,
name: 'New Profile',
security_score: 50,
created_at: '',
updated_at: '',
})
);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -704,11 +758,19 @@ describe('SecurityHeaders', () => {
});
it('should close edit dialog on success', async () => {
const mockProfiles = [{ id: 1, name: 'Edit Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Edit Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50, max_score: 100, breakdown: {}, suggestions: [] } as any);
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(mockProfiles[0] as any);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(createScoreBreakdown());
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(mockProfiles[0]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -726,8 +788,16 @@ describe('SecurityHeaders', () => {
});
it('should handle delete failure', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Delete Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockResolvedValue({ filename: 'test-backup.tar.gz' });
vi.mocked(securityHeadersApi.deleteProfile).mockRejectedValue(new Error('Delete failed'));
@@ -750,8 +820,16 @@ describe('SecurityHeaders', () => {
});
it('should handle backup failure during delete', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Delete Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockRejectedValue(new Error('Backup failed'));
@@ -773,8 +851,17 @@ describe('SecurityHeaders', () => {
});
it('should handle unknown preset types', async () => {
const mockProfiles = [{ id: 1, name: 'Weird Preset', is_preset: true, preset_type: 'unknown_type', security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Weird Preset',
is_preset: true,
preset_type: 'unknown_type',
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -784,10 +871,20 @@ describe('SecurityHeaders', () => {
});
it('should handle cancel in edit dialog', async () => {
const mockProfiles = [{ id: 1, name: 'Edit Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Edit Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50 } as any);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(
createScoreBreakdown({ score: 50, max_score: 50 })
);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -805,10 +902,20 @@ describe('SecurityHeaders', () => {
});
it('should handle delete from edit dialog', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me from Edit', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Delete Me from Edit',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50 } as any);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(
createScoreBreakdown({ score: 50, max_score: 50 })
);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import '@testing-library/jest-dom/vitest'
import Settings from '../Settings'
const translations: Record<string, string> = {
'settings.title': 'Settings',
'settings.description': 'Configure your Charon instance',
'settings.system': 'System',
'navigation.notifications': 'Notifications',
'settings.smtp': 'Email (SMTP)',
'settings.account': 'Account',
}
const t = (key: string) => translations[key] ?? key
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t }),
}))
const renderWithRoute = (route: string) =>
render(
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/settings" element={<Settings />}>
<Route path="system" element={<div>System Page</div>} />
<Route path="notifications" element={<div>Notifications Page</div>} />
<Route path="smtp" element={<div>SMTP Page</div>} />
<Route path="account" element={<div>Account Page</div>} />
</Route>
</Routes>
</MemoryRouter>
)
describe('Settings page', () => {
it('highlights the active nav item for the current route', () => {
renderWithRoute('/settings/system')
const activeLink = screen.getByRole('link', { name: 'System' })
const inactiveLink = screen.getByRole('link', { name: 'Notifications' })
expect(activeLink).toHaveClass('bg-surface-elevated')
expect(activeLink).toHaveClass('text-content-primary')
expect(inactiveLink).toHaveClass('text-content-secondary')
expect(screen.getByText('System Page')).toBeInTheDocument()
})
it('keeps navigation order consistent', () => {
renderWithRoute('/settings/notifications')
const links = screen.getAllByRole('link')
const labels = links.map(link => link.textContent)
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Account'])
})
})