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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user