chore: git cache cleanup
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import AcceptInvite from '../AcceptInvite'
|
||||
import * as usersApi from '../../api/users'
|
||||
|
||||
// Mock APIs
|
||||
vi.mock('../../api/users', () => ({
|
||||
validateInvite: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
inviteUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
updateUserPermissions: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock react-router-dom navigate
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (initialRoute: string = '/accept-invite?token=test-token') => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<Routes>
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AcceptInvite', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows invalid link message when no token provided', async () => {
|
||||
renderWithProviders('/accept-invite')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid Link')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/This invitation link is invalid/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows validating state initially', () => {
|
||||
vi.mocked(usersApi.validateInvite).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
expect(screen.getByText('Validating invitation...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows error for invalid token', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockRejectedValue({
|
||||
response: { data: { error: 'Token expired' } },
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invitation Invalid')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders accept form for valid token', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/been invited/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/invited@example.com/)).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
// Password and confirm password have same placeholder
|
||||
expect(screen.getAllByPlaceholderText('••••••••').length).toBe(2)
|
||||
})
|
||||
|
||||
it('shows password mismatch error', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
|
||||
await user.type(passwordInput, 'password123')
|
||||
await user.type(confirmInput, 'differentpassword')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Passwords do not match')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form and shows success', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
vi.mocked(usersApi.acceptInvite).mockResolvedValue({
|
||||
message: 'Success',
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
|
||||
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
|
||||
await user.type(passwordInput, 'securepassword123')
|
||||
await user.type(confirmInput, 'securepassword123')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.acceptInvite).toHaveBeenCalledWith({
|
||||
token: 'test-token',
|
||||
name: 'John Doe',
|
||||
password: 'securepassword123',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Account Created!')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error on submit failure', async () => {
|
||||
vi.mocked(usersApi.validateInvite).mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'invited@example.com',
|
||||
})
|
||||
vi.mocked(usersApi.acceptInvite).mockRejectedValue({
|
||||
response: { data: { error: 'Token has expired' } },
|
||||
})
|
||||
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('John Doe')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe')
|
||||
const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••')
|
||||
await user.type(passwordInput, 'securepassword123')
|
||||
await user.type(confirmInput, 'securepassword123')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.acceptInvite).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The toast should show error but we don't need to test toast specifically
|
||||
})
|
||||
|
||||
it('navigates to login after clicking Go to Login button', async () => {
|
||||
renderWithProviders('/accept-invite')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid Link')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Go to Login' }))
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -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,437 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import AuditLogs from '../AuditLogs'
|
||||
import * as auditLogsApi from '../../api/auditLogs'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/auditLogs')
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('<AuditLogs />', () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const mockAuditLogs = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: '123e4567-e89b-12d3-a456-426614174000',
|
||||
actor: 'admin@example.com',
|
||||
action: 'dns_provider_create' as const,
|
||||
event_category: 'dns_provider' as const,
|
||||
resource_id: 1,
|
||||
resource_uuid: 'res-123',
|
||||
details: '{"name":"Cloudflare","type":"cloudflare"}',
|
||||
ip_address: '192.168.1.1',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
created_at: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: '223e4567-e89b-12d3-a456-426614174001',
|
||||
actor: 'user@example.com',
|
||||
action: 'credential_test' as const,
|
||||
event_category: 'dns_provider' as const,
|
||||
resource_uuid: 'res-456',
|
||||
details: '{"test_result":"success"}',
|
||||
ip_address: '192.168.1.2',
|
||||
created_at: '2026-01-03T11:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('renders page title and description', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Audit Logs')).toBeInTheDocument()
|
||||
expect(screen.getByText('View and filter security audit events')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays audit logs in table', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('dns_provider_create')).toBeInTheDocument()
|
||||
expect(screen.getByText('credential_test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state', () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no logs', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No audit logs found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles filter panel', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Initially filters are not shown
|
||||
expect(screen.queryByText('Start Date')).not.toBeInTheDocument()
|
||||
|
||||
// Click to show filters
|
||||
const filterButton = screen.getByRole('button', { name: /Filters/i })
|
||||
fireEvent.click(filterButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Date')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('applies category filter', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
// Open filters
|
||||
fireEvent.click(screen.getByRole('button', { name: /Filters/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify all filter inputs are present
|
||||
expect(screen.getByPlaceholderText('Filter by actor...')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Filter by action...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears all filters', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
// Open filters
|
||||
fireEvent.click(screen.getByRole('button', { name: /Filters/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /Clear All/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// Filters should still be visible after clearing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Date')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens detail modal when row is clicked', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click first row
|
||||
const firstRow = screen.getByText('admin@example.com').closest('tr')
|
||||
if (firstRow) {
|
||||
fireEvent.click(firstRow)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
|
||||
expect(screen.getByText('123e4567-e89b-12d3-a456-426614174000')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes detail modal', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click first row to open modal
|
||||
const firstRow = screen.getByText('admin@example.com').closest('tr')
|
||||
if (firstRow) {
|
||||
fireEvent.click(firstRow)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal using the "Close" button in footer
|
||||
const closeButtons = screen.getAllByRole('button', { name: /Close/i })
|
||||
const footerCloseButton = closeButtons.find(btn => btn.textContent === 'Close')
|
||||
if (footerCloseButton) {
|
||||
fireEvent.click(footerCloseButton)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Audit Log Details')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles pagination', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 100,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check pagination is present
|
||||
const nextButton = screen.getByRole('button', { name: /Next/i })
|
||||
expect(nextButton).not.toBeDisabled()
|
||||
|
||||
const prevButton = screen.getByRole('button', { name: /Previous/i })
|
||||
expect(prevButton).toBeDisabled()
|
||||
|
||||
// Check page indicator
|
||||
expect(screen.getByText(/Page 1 of 2/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports to CSV', async () => {
|
||||
const mockCSV = 'timestamp,actor,action\n2026-01-03,admin,create'
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
vi.spyOn(auditLogsApi, 'exportAuditLogsCSV').mockResolvedValue(mockCSV)
|
||||
|
||||
// Mock toast
|
||||
const toastSuccessSpy = vi.spyOn(toast, 'success')
|
||||
|
||||
// Mock URL APIs
|
||||
const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
|
||||
const mockRevokeObjectURL = vi.fn()
|
||||
window.URL.createObjectURL = mockCreateObjectURL
|
||||
window.URL.revokeObjectURL = mockRevokeObjectURL
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const exportButton = screen.getByRole('button', { name: /Export CSV/i })
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(auditLogsApi.exportAuditLogsCSV).toHaveBeenCalled()
|
||||
expect(toastSuccessSpy).toHaveBeenCalledWith('Audit logs exported successfully')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles export error', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
vi.spyOn(auditLogsApi, 'exportAuditLogsCSV').mockRejectedValue(
|
||||
new Error('Export failed')
|
||||
)
|
||||
|
||||
const toastErrorSpy = vi.spyOn(toast, 'error')
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const exportButton = screen.getByRole('button', { name: /Export CSV/i })
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith('Failed to export audit logs')
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
})
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('displays parsed JSON details in modal', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: mockAuditLogs,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click first row to open modal
|
||||
const firstRow = screen.getByText('admin@example.com').closest('tr')
|
||||
if (firstRow) {
|
||||
fireEvent.click(firstRow)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
|
||||
// Check that JSON is displayed
|
||||
expect(screen.getByText(/"name"/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/"Cloudflare"/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to raw details when details are not valid JSON', async () => {
|
||||
const invalidDetailsLog = {
|
||||
...mockAuditLogs[0],
|
||||
uuid: 'raw-details-log',
|
||||
details: 'not-json',
|
||||
}
|
||||
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [invalidDetailsLog],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const row = screen.getByText('admin@example.com').closest('tr')
|
||||
if (row) {
|
||||
fireEvent.click(row)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
|
||||
expect(screen.getByText(/"raw": "not-json"/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows filter count badge', async () => {
|
||||
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
renderWithProviders(<AuditLogs />)
|
||||
|
||||
// Open filters
|
||||
const filterButton = screen.getByRole('button', { name: /Filters/i })
|
||||
fireEvent.click(filterButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add a filter by typing in the actor input
|
||||
const actorInput = screen.getByPlaceholderText('Filter by actor...')
|
||||
fireEvent.change(actorInput, { target: { value: 'admin' } })
|
||||
|
||||
// The badge should show 1 active filter
|
||||
await waitFor(() => {
|
||||
const badge = filterButton.querySelector('.bg-brand-500')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge?.textContent).toBe('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,693 @@
|
||||
import { AxiosError } from 'axios'
|
||||
import { screen, waitFor, act, cleanup, within, fireEvent } 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 * as featureFlagsApi from '../../api/featureFlags'
|
||||
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('../../api/featureFlags')
|
||||
vi.mock('../../hooks/useConsoleEnrollment', () => ({
|
||||
useConsoleStatus: vi.fn(() => ({
|
||||
data: {
|
||||
status: 'not_enrolled',
|
||||
tenant: 'default',
|
||||
agent_name: 'charon-agent',
|
||||
last_error: null,
|
||||
last_attempt_at: null,
|
||||
enrolled_at: null,
|
||||
last_heartbeat_at: null,
|
||||
key_present: false,
|
||||
correlation_id: 'corr-1',
|
||||
},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
})),
|
||||
useEnrollConsole: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({
|
||||
status: 'enrolling',
|
||||
key_present: false,
|
||||
}),
|
||||
isPending: false,
|
||||
})),
|
||||
useClearConsoleEnrollment: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
|
||||
CrowdSecBouncerKeyDisplay: () => null,
|
||||
}))
|
||||
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<string, unknown>) =>
|
||||
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(<CrowdSecConfig />, { client })
|
||||
await waitFor(() => screen.getByText('CrowdSec Configuration'))
|
||||
return result
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123, lapi_ready: true })
|
||||
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()
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders loading and error boundaries', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
expect(await screen.findByText('Loading CrowdSec configuration...')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('boom'))
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
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(<CrowdSecConfig />)
|
||||
expect(await screen.findByText(/Error loading CrowdSec status/)).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValueOnce({ cerberus: { enabled: true } } as never)
|
||||
renderWithQueryClient(<CrowdSecConfig />)
|
||||
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('supports keyboard selection for preset cards (Enter and Space)', async () => {
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: CROWDSEC_PRESETS[0].slug,
|
||||
title: CROWDSEC_PRESETS[0].title,
|
||||
summary: CROWDSEC_PRESETS[0].description,
|
||||
source: 'hub',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
cache_key: 'cache-a',
|
||||
},
|
||||
{
|
||||
slug: CROWDSEC_PRESETS[1].slug,
|
||||
title: CROWDSEC_PRESETS[1].title,
|
||||
summary: CROWDSEC_PRESETS[1].description,
|
||||
source: 'hub',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
cache_key: 'cache-b',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await renderPage()
|
||||
|
||||
const firstCard = await screen.findByRole('button', { name: new RegExp(CROWDSEC_PRESETS[0].title, 'i') })
|
||||
const secondCard = await screen.findByRole('button', { name: new RegExp(CROWDSEC_PRESETS[1].title, 'i') })
|
||||
|
||||
firstCard.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
secondCard.focus()
|
||||
await userEvent.keyboard(' ')
|
||||
|
||||
await waitFor(() => expect(presetsApi.pullCrowdsecPreset).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
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('supports ban modal click and keyboard interactions', async () => {
|
||||
await renderPage()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
|
||||
expect(await screen.findByText('Ban IP Address')).toBeInTheDocument()
|
||||
|
||||
const banDialog = screen.getByRole('dialog', { name: 'Ban IP Address' })
|
||||
const banOverlay = banDialog.parentElement?.querySelector('[class*="bg-black/60"]') as HTMLElement
|
||||
fireEvent.click(banOverlay)
|
||||
await waitFor(() => expect(screen.queryByText('Ban IP Address')).not.toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
|
||||
const modalContainer = screen.getByRole('dialog', { name: 'Ban IP Address' })
|
||||
const ipInput = within(modalContainer).getByPlaceholderText('192.168.1.100')
|
||||
await userEvent.type(ipInput, '9.9.9.9')
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}')
|
||||
await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('9.9.9.9', '24h', ''))
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
|
||||
const secondModalContainer = screen.getByRole('dialog', { name: 'Ban IP Address' })
|
||||
const secondIpInput = within(secondModalContainer).getByPlaceholderText('192.168.1.100')
|
||||
await userEvent.type(secondIpInput, '8.8.8.8')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('8.8.8.8', '24h', ''))
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
|
||||
const thirdModalContainer = screen.getByRole('dialog', { name: 'Ban IP Address' })
|
||||
const thirdIpInput = within(thirdModalContainer).getByPlaceholderText('192.168.1.100')
|
||||
await userEvent.type(thirdIpInput, '8.8.8.8')
|
||||
const reasonInput = within(thirdModalContainer).getByLabelText('Reason')
|
||||
await userEvent.type(reasonInput, 'manual reason{Enter}')
|
||||
await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('8.8.8.8', '24h', 'manual reason'))
|
||||
})
|
||||
|
||||
it('supports unban modal overlay, Escape, Enter, and cancel button', async () => {
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({
|
||||
decisions: [
|
||||
{ id: '1', ip: '7.7.7.7', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' },
|
||||
],
|
||||
})
|
||||
|
||||
await renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Unban' }))
|
||||
expect(await screen.findByText('Confirm Unban')).toBeInTheDocument()
|
||||
|
||||
const unbanDialog = screen.getByRole('dialog', { name: 'Confirm Unban' })
|
||||
const unbanOverlay = unbanDialog.parentElement?.querySelector('[class*="bg-black/60"]') as HTMLElement
|
||||
fireEvent.click(unbanOverlay)
|
||||
await waitFor(() => expect(screen.queryByText('Confirm Unban')).not.toBeInTheDocument())
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Unban' }))
|
||||
expect(await screen.findByText('Confirm Unban')).toBeInTheDocument()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByText('Confirm Unban')).not.toBeInTheDocument())
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Unban' }))
|
||||
expect(await screen.findByText('Confirm Unban')).toBeInTheDocument()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await waitFor(() => expect(crowdsecApi.unbanIP).toHaveBeenCalledWith('7.7.7.7'))
|
||||
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({
|
||||
decisions: [
|
||||
{ id: '1', ip: '7.7.7.7', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' },
|
||||
],
|
||||
})
|
||||
await renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Unban' }))
|
||||
const confirmContainer = screen.getByRole('dialog', { name: 'Confirm Unban' })
|
||||
await userEvent.click(within(confirmContainer).getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByText('Confirm Unban')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
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<void>((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?.()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AxiosError, AxiosResponse } from 'axios'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as api from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
|
||||
import type { ConsoleEnrollmentStatus } from '../../api/consoleEnrollment'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../api/featureFlags')
|
||||
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
|
||||
CrowdSecBouncerKeyDisplay: () => null,
|
||||
}))
|
||||
const consoleStatusMock = vi.fn<() => ConsoleEnrollmentStatus>(() => ({ status: 'not_enrolled', key_present: false }))
|
||||
const enrollConsoleMock = vi.fn()
|
||||
const clearConsoleEnrollmentMock = vi.fn()
|
||||
|
||||
vi.mock('../../hooks/useConsoleEnrollment', () => ({
|
||||
useConsoleStatus: vi.fn(() => ({
|
||||
data: consoleStatusMock(),
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
})),
|
||||
useEnrollConsole: vi.fn(() => ({
|
||||
mutateAsync: enrollConsoleMock,
|
||||
isPending: false,
|
||||
})),
|
||||
useClearConsoleEnrollment: vi.fn(() => ({
|
||||
mutate: clearConsoleEnrollmentMock,
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: CROWDSEC_PRESETS.map((preset) => ({
|
||||
slug: preset.slug,
|
||||
title: preset.title,
|
||||
summary: preset.description,
|
||||
source: 'charon',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
})),
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: CROWDSEC_PRESETS[0].content,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
source: 'hub',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
|
||||
status: 'applied',
|
||||
backup: '/tmp/backup.tar.gz',
|
||||
reload_hint: true,
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' })
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
consoleStatusMock.mockReturnValue({ status: 'not_enrolled', key_present: false })
|
||||
enrollConsoleMock.mockResolvedValue({ status: 'enrolling', key_present: true })
|
||||
})
|
||||
|
||||
it('exports config when clicking Export', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('uploads a file and calls import on Import (backup before save)', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({ status: 'imported' })
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const input = screen.getByTestId('import-file') as HTMLInputElement
|
||||
const file = new File(['dummy'], 'cfg.tar.gz')
|
||||
await userEvent.upload(input, file)
|
||||
const btn = screen.getByTestId('import-btn')
|
||||
await userEvent.click(btn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('hides console enrollment when feature flag is off', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('console-enrollment-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows console enrollment form when feature flag is on', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-enrollment-card')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('console-enrollment-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates required console enrollment fields and acknowledgement', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const enrollBtn = await screen.findByTestId('console-enroll-btn')
|
||||
|
||||
// Button should be disabled when enrollment token is empty
|
||||
expect(enrollBtn).toBeDisabled()
|
||||
|
||||
// Type only token (missing agent name, tenant, and ack)
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'token-123')
|
||||
|
||||
// Now button should be enabled, click it
|
||||
await waitFor(() => expect(enrollBtn).not.toBeDisabled())
|
||||
await userEvent.click(enrollBtn)
|
||||
|
||||
// Should show validation errors for missing fields
|
||||
const errors = await screen.findAllByTestId('console-enroll-error')
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(enrollConsoleMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits console enrollment payload with snake_case fields', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
enrollConsoleMock.mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'agent-one', tenant: 'tenant-inc' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-enrollment-card')).toBeInTheDocument())
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'secret-1234567890')
|
||||
await userEvent.clear(screen.getByTestId('console-agent-name'))
|
||||
await userEvent.type(screen.getByTestId('console-agent-name'), 'agent-one')
|
||||
await userEvent.type(screen.getByTestId('console-tenant'), 'tenant-inc')
|
||||
await userEvent.click(screen.getByTestId('console-ack-checkbox'))
|
||||
await userEvent.click(screen.getByTestId('console-enroll-btn'))
|
||||
|
||||
await waitFor(() => expect(enrollConsoleMock).toHaveBeenCalledWith({
|
||||
enrollment_key: 'secret-1234567890',
|
||||
agent_name: 'agent-one',
|
||||
tenant: 'tenant-inc',
|
||||
force: false,
|
||||
}))
|
||||
|
||||
expect((screen.getByTestId('console-enrollment-token') as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('renders masked key state in console status', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
consoleStatusMock.mockReturnValue({ status: 'enrolled', key_present: true, agent_name: 'a1', tenant: 't1', last_heartbeat_at: '2024-01-01T00:00:00Z' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-token-state')).toHaveTextContent('Stored (masked)'))
|
||||
})
|
||||
|
||||
it('retries degraded enrollment and rotates key when enrolled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
consoleStatusMock.mockReturnValue({ status: 'failed', key_present: true, last_error: 'network' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-ack-checkbox')).toBeInTheDocument())
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'another-secret-123456')
|
||||
await userEvent.click(screen.getByTestId('console-ack-checkbox'))
|
||||
await userEvent.click(screen.getByTestId('console-retry-btn'))
|
||||
await waitFor(() => expect(enrollConsoleMock).toHaveBeenCalledWith(expect.objectContaining({ force: true })))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('console-rotate-btn')).not.toBeDisabled())
|
||||
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'rotate-token-987654321')
|
||||
await userEvent.click(screen.getByTestId('console-rotate-btn'))
|
||||
await waitFor(() => expect(enrollConsoleMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enrollment_key: 'rotate-token-987654321',
|
||||
force: true,
|
||||
})))
|
||||
})
|
||||
|
||||
it('lists files, reads file content and can save edits (backup before save)', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['conf.d/a.conf', 'b.conf'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'rule1' })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
// wait for file list
|
||||
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
|
||||
const select = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(select, 'conf.d/a.conf')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
|
||||
// ensure textarea populated - use getAllByRole and filter for textarea (not the search input)
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea')!
|
||||
expect(textarea).toHaveValue('rule1')
|
||||
// edit and save
|
||||
await userEvent.clear(textarea)
|
||||
await userEvent.type(textarea, 'updated')
|
||||
const saveBtn = screen.getByText('Save')
|
||||
await userEvent.click(saveBtn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf', 'updated'))
|
||||
})
|
||||
|
||||
it('shows info banner directing to Security Dashboard for mode control', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
expect(screen.getByText(/CrowdSec is controlled via the toggle on the/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Security/i })).toHaveAttribute('href', '/security')
|
||||
})
|
||||
|
||||
it('renders preset preview and applies with backup when backend apply is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || ''
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
|
||||
const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, {
|
||||
status: 501,
|
||||
statusText: 'Not Implemented',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
data: {},
|
||||
} as AxiosResponse)
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:'))
|
||||
const fileSelect = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(fileSelect, 'acquis.yaml')
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' }))
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent))
|
||||
})
|
||||
|
||||
it('surfaces validation error when slug is invalid', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const validationError = new AxiosError('invalid', undefined, undefined, undefined, {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
data: { error: 'slug invalid' },
|
||||
} as AxiosResponse)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid'))
|
||||
})
|
||||
|
||||
it('disables apply and offers cached preview when hub is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: 'hub-only',
|
||||
title: 'Hub Only',
|
||||
summary: 'Needs hub',
|
||||
source: 'hub',
|
||||
requires_hub: true,
|
||||
available: true,
|
||||
cached: true,
|
||||
cache_key: 'cache-hub',
|
||||
etag: 'etag-hub',
|
||||
},
|
||||
],
|
||||
})
|
||||
const hubError = new AxiosError('unavailable', undefined, undefined, undefined, {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
data: { error: 'hub service unavailable' },
|
||||
} as AxiosResponse)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError)
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
// Wait for presets to load and click on the preset card
|
||||
const presetCard = await screen.findByText('Hub Only')
|
||||
await userEvent.click(presetCard)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement
|
||||
expect(applyBtn.disabled).toBe(true)
|
||||
|
||||
await userEvent.click(screen.getByText('Use Cached'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
|
||||
})
|
||||
|
||||
it('shows apply response metadata including backup path', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
backup: '/tmp/crowdsec-backup',
|
||||
reload_hint: true,
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const applyBtn = await screen.findByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup'))
|
||||
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Status: applied')
|
||||
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Method: cscli')
|
||||
// reloadHint is a boolean and renders as empty/true - just verify the info section exists
|
||||
})
|
||||
|
||||
it('shows improved error message when preset is not cached', async () => {
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
error: 'CrowdSec preset not cached. Pull the preset first by clicking \'Pull Preview\', then try applying again.',
|
||||
},
|
||||
},
|
||||
message: 'Request failed',
|
||||
} as AxiosError
|
||||
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const applyBtn = await screen.findByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('Preset must be pulled before applying')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
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'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../api/featureFlags')
|
||||
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
|
||||
CrowdSecBouncerKeyDisplay: () => null,
|
||||
}))
|
||||
vi.mock('../../hooks/useConsoleEnrollment', () => ({
|
||||
useConsoleStatus: vi.fn(() => ({
|
||||
data: {
|
||||
status: 'not_enrolled',
|
||||
tenant: 'default',
|
||||
agent_name: 'charon-agent',
|
||||
last_error: null,
|
||||
last_attempt_at: null,
|
||||
enrolled_at: null,
|
||||
last_heartbeat_at: null,
|
||||
key_present: false,
|
||||
correlation_id: 'corr-1',
|
||||
},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
})),
|
||||
useEnrollConsole: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({
|
||||
status: 'enrolling',
|
||||
key_present: false,
|
||||
}),
|
||||
isPending: false,
|
||||
})),
|
||||
useClearConsoleEnrollment: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
const createClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
const queryClient = createClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CrowdSecConfig />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mocks
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled', enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
})
|
||||
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.crowdsec.console_enrollment': true
|
||||
})
|
||||
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['config.yaml', 'profiles.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'yaml content' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({
|
||||
decisions: [
|
||||
{ id: '1', ip: '1.2.3.4', reason: 'ssh-bf', duration: '23h', created_at: '2023-01-01', source: 'local' }
|
||||
]
|
||||
})
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
vi.mocked(crowdsecApi.banIP).mockResolvedValue(undefined)
|
||||
vi.mocked(crowdsecApi.unbanIP).mockResolvedValue(undefined)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({
|
||||
running: true,
|
||||
pid: 123,
|
||||
lapi_ready: true,
|
||||
})
|
||||
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: 'configs: {}',
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
|
||||
// Window Prompt Mock
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
// 1. Rendering basic elements
|
||||
it('renders page configuration elements', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
|
||||
// Updated text to match translation file
|
||||
expect(screen.getByText('Edit Configuration Files')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banned IPs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// 2. File Editor
|
||||
it('allows reading and saving config files', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-file-select'))
|
||||
|
||||
// Select file
|
||||
const select = screen.getByTestId('crowdsec-file-select')
|
||||
await user.selectOptions(select, 'config.yaml')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('config.yaml')
|
||||
expect(screen.getByDisplayValue('yaml content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edit content
|
||||
const textarea = screen.getByDisplayValue('yaml content')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'new content')
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('config.yaml', 'new content')
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled() // Should backup first
|
||||
})
|
||||
})
|
||||
|
||||
// 3. Banned IPs Table
|
||||
it('renders banned IPs table', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1.2.3.4')).toBeInTheDocument()
|
||||
expect(screen.getByText('ssh-bf')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// 4. Ban IP Action
|
||||
it('allows banning an IP', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => screen.getByText('Ban IP'))
|
||||
|
||||
// Click Ban IP trigger (using ID we added)
|
||||
await user.click(screen.getByTestId('ban-ip-trigger'))
|
||||
|
||||
// Modal opens
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Ban IP Address' })
|
||||
|
||||
// Fill form
|
||||
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')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Ban IP' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.banIP).toHaveBeenCalledWith('5.6.7.8', '24h', 'manual ban')
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 5. Unban IP Action
|
||||
it('allows unbanning an IP', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => screen.getByText('1.2.3.4'))
|
||||
|
||||
const unbanBtns = screen.getAllByRole('button', { name: 'Unban' })
|
||||
expect(unbanBtns.length).toBeGreaterThan(0)
|
||||
|
||||
// Click the unban button in the table (first one)
|
||||
await user.click(unbanBtns[0])
|
||||
|
||||
// Confirm modal
|
||||
await waitFor(() => screen.getByText('Confirm Unban'))
|
||||
|
||||
// Click confirm in modal. Use getAllByRole to get the modal one (last one)
|
||||
const modalButtons = screen.getAllByRole('button', { name: 'Unban' })
|
||||
const confirmBtn = modalButtons[modalButtons.length - 1]
|
||||
await user.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.unbanIP).toHaveBeenCalledWith('1.2.3.4')
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 6. Console Enrollment fields (if enabled)
|
||||
it('handles console enrollment form', async () => {
|
||||
// const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => screen.getByText('Console Enrollment'))
|
||||
|
||||
// Check inputs exist
|
||||
expect(screen.getByTestId('console-enrollment-token')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('console-agent-name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 7. Presets logic
|
||||
it('handles preset searching', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock presets with data
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: [
|
||||
{
|
||||
slug: 'ssh-bf',
|
||||
title: 'SSH Bruteforce',
|
||||
summary: 'Block SSH attacks',
|
||||
source: 'crowdsec',
|
||||
tags: ['linux'],
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(presetsApi.listCrowdsecPresets).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search presets...')
|
||||
expect(searchInput).toBeInTheDocument()
|
||||
|
||||
await user.type(searchInput, 'SSH')
|
||||
expect(searchInput).toHaveValue('SSH')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import DNS from '../DNS'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dns.title': 'DNS Management',
|
||||
'dns.description': 'Manage DNS providers and plugins for certificate automation',
|
||||
'navigation.dnsProviders': 'DNS Providers',
|
||||
'navigation.plugins': 'Plugins',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DNS page', () => {
|
||||
it('renders DNS management page with navigation tabs', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
expect(await screen.findByText('DNS Management')).toBeInTheDocument()
|
||||
expect(screen.getByText('DNS Providers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugins')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the navigation component', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
const nav = await screen.findByRole('navigation')
|
||||
expect(nav).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights active tab based on route', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
const nav = await screen.findByRole('navigation')
|
||||
const providersLink = within(nav).getByText('DNS Providers').closest('a')
|
||||
|
||||
// Active tab should have the elevated style class
|
||||
expect(providersLink).toHaveClass('bg-surface-elevated')
|
||||
})
|
||||
|
||||
it('displays plugins tab as inactive when on providers route', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
const nav = await screen.findByRole('navigation')
|
||||
const pluginsLink = within(nav).getByText('Plugins').closest('a')
|
||||
|
||||
// Inactive tab should not have the elevated style class
|
||||
expect(pluginsLink).not.toHaveClass('bg-surface-elevated')
|
||||
expect(pluginsLink).toHaveClass('text-content-secondary')
|
||||
})
|
||||
|
||||
it('renders navigation links with correct paths', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
const nav = await screen.findByRole('navigation')
|
||||
const providersLink = within(nav).getByText('DNS Providers').closest('a')
|
||||
const pluginsLink = within(nav).getByText('Plugins').closest('a')
|
||||
|
||||
expect(providersLink).toHaveAttribute('href', '/dns/providers')
|
||||
expect(pluginsLink).toHaveAttribute('href', '/dns/plugins')
|
||||
})
|
||||
|
||||
it('renders content area for child routes', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
// The content area should be rendered with the border-border class
|
||||
const contentArea = document.querySelector('.bg-surface-elevated.border.border-border')
|
||||
expect(contentArea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders cloud icon in header actions', async () => {
|
||||
renderWithQueryClient(<DNS />, { routeEntries: ['/dns/providers'] })
|
||||
|
||||
// Look for the Cloud icon in the header actions area
|
||||
const header = await screen.findByText('DNS Management')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import DNSProviders from '../DNSProviders'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { useDNSProviders, useDNSProviderMutations, type DNSProvider } from '../../hooks/useDNSProviders'
|
||||
import { getChallenge } from '../../api/manualChallenge'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDNSProviders', () => ({
|
||||
useDNSProviders: vi.fn(),
|
||||
useDNSProviderMutations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/manualChallenge', () => ({
|
||||
getChallenge: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../components/ui', () => ({
|
||||
Button: ({ children, onClick, variant }: { children: ReactNode; onClick?: () => void; variant?: string }) => (
|
||||
<button type="button" data-variant={variant} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
|
||||
EmptyState: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
Skeleton: () => <div data-testid="skeleton" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/DNSProviderCard', () => ({
|
||||
default: ({ provider }: { provider: DNSProvider }) => (
|
||||
<article data-testid="provider-card">
|
||||
<h3>{provider.name}</h3>
|
||||
</article>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../components/DNSProviderForm', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/dns-providers', () => ({
|
||||
ManualDNSChallenge: ({
|
||||
challenge,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: {
|
||||
challenge: { fqdn: string }
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<section data-testid="manual-dns-challenge">
|
||||
<div>{challenge.fqdn}</div>
|
||||
<button type="button" onClick={onComplete}>complete-manual</button>
|
||||
<button type="button" onClick={onCancel}>cancel-manual</button>
|
||||
</section>
|
||||
),
|
||||
}))
|
||||
|
||||
const buildProvider = (overrides: Partial<DNSProvider> = {}): DNSProvider => ({
|
||||
id: 7,
|
||||
uuid: 'provider-uuid',
|
||||
name: 'Seeded Provider',
|
||||
provider_type: 'manual',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 60,
|
||||
polling_interval: 5,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
created_at: '2026-02-15T00:00:00Z',
|
||||
updated_at: '2026-02-15T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DNSProviders page state behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useDNSProviderMutations).mockReturnValue({
|
||||
deleteMutation: { mutateAsync: vi.fn() },
|
||||
testMutation: { mutateAsync: vi.fn() },
|
||||
createMutation: { mutateAsync: vi.fn() },
|
||||
updateMutation: { mutateAsync: vi.fn() },
|
||||
testCredentialsMutation: { mutateAsync: vi.fn() },
|
||||
} as unknown as ReturnType<typeof useDNSProviderMutations>)
|
||||
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: [buildProvider()],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
})
|
||||
|
||||
it('keeps provider cards visible by default without challenge fetch side effects', async () => {
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Seeded Provider' })).toBeInTheDocument()
|
||||
expect(getChallenge).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not hide provider cards when manual challenge fetch fails', async () => {
|
||||
vi.mocked(getChallenge).mockRejectedValue(new Error('not found'))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChallenge).toHaveBeenCalledWith(7, 'active')
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Seeded Provider' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens manual challenge panel only after explicit action when fetch succeeds', async () => {
|
||||
vi.mocked(getChallenge).mockResolvedValue({
|
||||
id: 'active',
|
||||
status: 'pending',
|
||||
fqdn: '_acme-challenge.example.com',
|
||||
value: 'token',
|
||||
ttl: 300,
|
||||
created_at: '2026-02-15T00:00:00Z',
|
||||
expires_at: '2026-02-15T00:10:00Z',
|
||||
dns_propagated: false,
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
|
||||
|
||||
expect(await screen.findByTestId('manual-dns-challenge')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'complete-manual' }))
|
||||
await waitFor(() => {
|
||||
expect(getChallenge).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'cancel-manual' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('re-evaluates manual challenge visibility after completion refresh', async () => {
|
||||
vi.mocked(getChallenge)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'active',
|
||||
status: 'pending',
|
||||
fqdn: '_acme-challenge.example.com',
|
||||
value: 'token',
|
||||
ttl: 300,
|
||||
created_at: '2026-02-15T00:00:00Z',
|
||||
expires_at: '2026-02-15T00:10:00Z',
|
||||
dns_propagated: false,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('challenge missing after refresh'))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
|
||||
expect(await screen.findByTestId('manual-dns-challenge')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'complete-manual' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChallenge).toHaveBeenCalledTimes(2)
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no provider toast when manual challenge is requested without providers', async () => {
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('dnsProviders.noProviders')
|
||||
expect(getChallenge).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import Dashboard from '../Dashboard'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: () => ({
|
||||
hosts: [
|
||||
{ id: 1, enabled: true, ssl_forced: false, domain_names: 'test.com' },
|
||||
{ id: 2, enabled: false, ssl_forced: false, domain_names: 'test2.com' },
|
||||
],
|
||||
loading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: () => ({
|
||||
servers: [
|
||||
{ id: 1, enabled: true },
|
||||
{ id: 2, enabled: true },
|
||||
],
|
||||
loading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: () => ({
|
||||
certificates: [
|
||||
{ id: 1, status: 'valid', domain: 'test.com' },
|
||||
{ id: 2, status: 'expired', domain: 'expired.com' },
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAccessLists', () => ({
|
||||
useAccessLists: () => ({
|
||||
data: [{ id: 1, enabled: true }],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/health', () => ({
|
||||
checkHealth: vi.fn().mockResolvedValue({ status: 'ok', version: '1.0.0' }),
|
||||
}))
|
||||
|
||||
// Mock UptimeWidget to avoid complex dependencies
|
||||
vi.mock('../../components/UptimeWidget', () => ({
|
||||
default: () => <div data-testid="uptime-widget">Uptime Widget</div>,
|
||||
}))
|
||||
|
||||
describe('Dashboard page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders counts and health status', async () => {
|
||||
renderWithQueryClient(<Dashboard />)
|
||||
|
||||
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
|
||||
expect(await screen.findByText('1 enabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 enabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 valid')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Healthy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state when health check fails', async () => {
|
||||
const { checkHealth } = await import('../../api/health')
|
||||
vi.mocked(checkHealth).mockResolvedValueOnce({ status: 'fail', version: '1.0.0' } as never)
|
||||
|
||||
renderWithQueryClient(<Dashboard />)
|
||||
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import EncryptionManagement from '../EncryptionManagement'
|
||||
import * as encryptionApi from '../../api/encryption'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/encryption')
|
||||
|
||||
const mockEncryptionApi = encryptionApi as {
|
||||
getEncryptionStatus: ReturnType<typeof vi.fn>
|
||||
getRotationHistory: ReturnType<typeof vi.fn>
|
||||
rotateEncryptionKey: ReturnType<typeof vi.fn>
|
||||
validateKeyConfiguration: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('EncryptionManagement', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
const mockStatus = {
|
||||
current_version: 2,
|
||||
next_key_configured: true,
|
||||
legacy_key_count: 1,
|
||||
providers_on_current_version: 5,
|
||||
providers_on_older_versions: 2,
|
||||
}
|
||||
|
||||
const mockHistory = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'test-uuid-1',
|
||||
actor: 'admin',
|
||||
action: 'encryption_key_rotated',
|
||||
event_category: 'encryption',
|
||||
details: JSON.stringify({ new_key_version: 2, duration: '5.2s' }),
|
||||
created_at: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Setup default mocks
|
||||
mockEncryptionApi.getEncryptionStatus.mockResolvedValue(mockStatus)
|
||||
mockEncryptionApi.getRotationHistory.mockResolvedValue(mockHistory)
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EncryptionManagement />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders page title and description', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Encryption Key Management')).toBeInTheDocument()
|
||||
expect(screen.getByText('Manage encryption keys and rotate DNS provider credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays encryption status correctly', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Version 2/)[0]).toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument() // providers on current version
|
||||
expect(screen.getByText('Using current key version')).toBeInTheDocument()
|
||||
expect(screen.getByText('Configured')).toBeInTheDocument() // next key status
|
||||
})
|
||||
})
|
||||
|
||||
it('shows warning when providers on older versions exist', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Providers Outdated')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays legacy key warning when legacy keys exist', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Legacy Encryption Keys Detected')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 legacy keys are configured/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('enables rotation button when next key is configured', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
const rotateButton = screen.getByText('Rotate Encryption Key')
|
||||
expect(rotateButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('disables rotation button when next key is not configured', async () => {
|
||||
mockEncryptionApi.getEncryptionStatus.mockResolvedValue({
|
||||
...mockStatus,
|
||||
next_key_configured: false,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
const rotateButton = screen.getByText('Rotate Encryption Key')
|
||||
expect(rotateButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog when rotation is triggered', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rotate Encryption Key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const rotateButton = screen.getByText('Rotate Encryption Key')
|
||||
await user.click(rotateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Confirm Key Rotation')).toBeInTheDocument()
|
||||
expect(screen.getByText(/This will re-encrypt all DNS provider credentials/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('executes rotation when confirmed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResult = {
|
||||
total_providers: 7,
|
||||
success_count: 7,
|
||||
failure_count: 0,
|
||||
duration: '5.2s',
|
||||
new_key_version: 3,
|
||||
}
|
||||
|
||||
mockEncryptionApi.rotateEncryptionKey.mockResolvedValue(mockResult)
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rotate Encryption Key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open dialog
|
||||
const rotateButton = screen.getByText('Rotate Encryption Key')
|
||||
await user.click(rotateButton)
|
||||
|
||||
// Confirm rotation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Rotation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('Start Rotation')
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEncryptionApi.rotateEncryptionKey).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles rotation errors gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockEncryptionApi.rotateEncryptionKey.mockRejectedValue(new Error('Rotation failed'))
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rotate Encryption Key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const rotateButton = screen.getByText('Rotate Encryption Key')
|
||||
await user.click(rotateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Rotation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('Start Rotation')
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEncryptionApi.rotateEncryptionKey).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('validates key configuration when validate button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockValidation = {
|
||||
valid: true,
|
||||
warnings: ['Keep old keys for 30 days'],
|
||||
}
|
||||
|
||||
mockEncryptionApi.validateKeyConfiguration.mockResolvedValue(mockValidation)
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Validate Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const validateButton = screen.getByText('Validate Configuration')
|
||||
await user.click(validateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEncryptionApi.validateKeyConfiguration).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays rotation history', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rotation History')).toBeInTheDocument()
|
||||
expect(screen.getByText('admin')).toBeInTheDocument()
|
||||
expect(screen.getByText('encryption_key_rotated')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays environment variable guide', async () => {
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Environment Variable Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText(/CHARON_ENCRYPTION_KEY=/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/CHARON_ENCRYPTION_KEY_V2=/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state while fetching status', () => {
|
||||
mockEncryptionApi.getEncryptionStatus.mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Encryption Key Management')).toBeInTheDocument()
|
||||
// Should show skeletons
|
||||
expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows error state when status fetch fails', async () => {
|
||||
mockEncryptionApi.getEncryptionStatus.mockRejectedValue(new Error('Failed to fetch'))
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load encryption status. Please refresh the page.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,659 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ImportCaddy from '../ImportCaddy'
|
||||
import { useImport } from '../../hooks/useImport'
|
||||
import { createBackup } from '../../api/backups'
|
||||
|
||||
// Mock the hooks and API calls
|
||||
vi.mock('../../hooks/useImport')
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
// Mock translation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock navigate
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseImport = vi.mocked(useImport)
|
||||
const mockCreateBackup = vi.mocked(createBackup)
|
||||
|
||||
// Helper to create a mock file with text() method
|
||||
function createMockFile(content: string, name: string, type: string): File {
|
||||
const file = new File([content], name, { type })
|
||||
// Mock the text() method since jsdom doesn't fully support it
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve(content),
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
describe('ImportCaddy - Handlers and Interactions', () => {
|
||||
const defaultMockReturn = {
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn().mockResolvedValue(undefined),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseImport.mockReturnValue(defaultMockReturn)
|
||||
mockCreateBackup.mockResolvedValue({
|
||||
filename: 'backup.db',
|
||||
})
|
||||
// Reset confirm mock
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
vi.spyOn(window, 'alert').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('displays loading text on button when loading is true', () => {
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Button should show processing text when loading
|
||||
expect(screen.getByText('importCaddy.processing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables parse button when loading', () => {
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByText('importCaddy.processing')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables parse button when content is empty', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByText('importCaddy.parseAndReview')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables parse button when content exists and not loading', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
// Use fireEvent.change to avoid userEvent parsing curly braces as special keys
|
||||
fireEvent.change(textarea, { target: { value: 'example.com reverse_proxy localhost:8080' } })
|
||||
|
||||
const button = screen.getByText('importCaddy.parseAndReview')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpload', () => {
|
||||
it('shows alert when trying to upload empty content', async () => {
|
||||
vi.spyOn(window, 'alert')
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Type and then clear to have empty trimmed content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, ' ')
|
||||
|
||||
// Force-enable the button by changing content
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'x')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, ' ') // whitespace only
|
||||
|
||||
// Need to manually trigger click - button is disabled for empty content
|
||||
// Let's test by typing content first then clearing doesn't call upload
|
||||
// Actually, let's test with actual content that passes validation
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'valid config')
|
||||
|
||||
const button = screen.getByText('importCaddy.parseAndReview')
|
||||
expect(button).not.toBeDisabled()
|
||||
|
||||
// Clear and make whitespace-only
|
||||
await user.clear(textarea)
|
||||
fireEvent.change(textarea, { target: { value: ' ' } })
|
||||
|
||||
// Button becomes disabled again
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls upload function with content on valid submission', async () => {
|
||||
const mockUpload = vi.fn().mockResolvedValue(undefined)
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
upload: mockUpload,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
const testContent = 'example.com reverse_proxy localhost:8080'
|
||||
fireEvent.change(textarea, { target: { value: testContent } })
|
||||
|
||||
const button = screen.getByText('importCaddy.parseAndReview')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith(testContent)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles upload error gracefully', async () => {
|
||||
const mockUpload = vi.fn().mockRejectedValue(new Error('Parse error'))
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
upload: mockUpload,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'invalid content')
|
||||
|
||||
const button = screen.getByText('importCaddy.parseAndReview')
|
||||
await user.click(button)
|
||||
|
||||
// Should not throw - error handled by hook
|
||||
expect(mockUpload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleFileUpload', () => {
|
||||
it('reads file content and sets it to textarea', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const fileInput = screen.getByTestId('import-dropzone')
|
||||
const fileContent = 'example.com reverse_proxy localhost:3000'
|
||||
const file = createMockFile(fileContent, 'Caddyfile', 'text/plain')
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// Wait for file to be read and content to be set
|
||||
await waitFor(() => {
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe(fileContent)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty file selection gracefully', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const fileInput = screen.getByTestId('import-dropzone')
|
||||
|
||||
// Trigger change with no files
|
||||
fireEvent.change(fileInput, { target: { files: [] } })
|
||||
|
||||
// Should not crash - textarea should remain empty
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCancel', () => {
|
||||
it('calls cancel when user confirms cancellation', async () => {
|
||||
const mockCancel = vi.fn().mockResolvedValue(undefined)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
cancel: mockCancel,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// ImportBanner should be rendered - find Cancel button by text
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call cancel when user declines confirmation', () => {
|
||||
const mockCancel = vi.fn().mockResolvedValue(undefined)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
cancel: mockCancel,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// ImportBanner should be rendered - find Cancel button by text
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(mockCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles cancel error gracefully', async () => {
|
||||
const mockCancel = vi.fn().mockRejectedValue(new Error('Cancel failed'))
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
cancel: mockCancel,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Find Cancel button and click it
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
// Should not throw - error is handled by hook
|
||||
await waitFor(() => {
|
||||
expect(mockCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImportReviewTable Display', () => {
|
||||
it('displays review table when session and preview exist with Review button click', async () => {
|
||||
// Mock session with preview that requires showReview to be true
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com', forward_host: 'localhost', forward_port: 8080 }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: 'example.com reverse_proxy',
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Click "Review Changes" button in the banner to show the review table
|
||||
const reviewButton = screen.getByRole('button', { name: 'Review Changes' })
|
||||
fireEvent.click(reviewButton)
|
||||
|
||||
// Review table should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('import-review-table')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Success Modal Navigation', () => {
|
||||
it('displays success modal after commit', () => {
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
commitResult: { created: 1, updated: 0, skipped: 0, errors: [] },
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// The success modal component should be in the DOM
|
||||
// The actual visibility is controlled by the visible prop
|
||||
})
|
||||
|
||||
it('clears commit result when success modal is closed', async () => {
|
||||
const mockClearCommitResult = vi.fn()
|
||||
const mockCommit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
mockUseImport.mockReturnValue({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
commitResult: null,
|
||||
clearCommitResult: mockClearCommitResult,
|
||||
commit: mockCommit,
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Trigger the review table
|
||||
const reviewButton = screen.getByRole('button', { name: 'Review Changes' })
|
||||
fireEvent.click(reviewButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('import-review-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click Commit Import to trigger commit flow and open success modal
|
||||
const commitButton = screen.getByRole('button', { name: 'Commit Import' })
|
||||
fireEvent.click(commitButton)
|
||||
|
||||
// Wait for commit to be called
|
||||
await waitFor(() => {
|
||||
expect(mockCommit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Textarea Content Change', () => {
|
||||
it('updates content when textarea value changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
|
||||
await user.type(textarea, 'new content')
|
||||
expect(textarea.value).toBe('new content')
|
||||
})
|
||||
|
||||
it('allows clearing textarea content', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
await user.type(textarea, 'some content')
|
||||
expect(textarea.value).toBe('some content')
|
||||
|
||||
await user.clear(textarea)
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Page Title', () => {
|
||||
it('renders the page title', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('importCaddy.title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Labels', () => {
|
||||
it('renders upload and content labels', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('importCaddy.uploadCaddyfile')).toBeInTheDocument()
|
||||
expect(screen.getByText('importCaddy.caddyfileContent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders or divider text', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('importCaddy.orPasteContent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description text', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('importCaddy.description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImportCaddy - Commit Handler', () => {
|
||||
const mockClearCommitResult = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateBackup.mockResolvedValue({
|
||||
filename: 'backup.db',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows commit button in review table when reviewing', async () => {
|
||||
const mockCommit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
mockUseImport.mockReturnValue({
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: mockClearCommitResult,
|
||||
upload: vi.fn(),
|
||||
commit: mockCommit,
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Click Review Changes to show the review table
|
||||
const reviewButton = screen.getByRole('button', { name: 'Review Changes' })
|
||||
fireEvent.click(reviewButton)
|
||||
|
||||
// Verify review table is present with Commit button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('import-review-table')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Commit Import' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('triggers commit flow when commit button is clicked', async () => {
|
||||
const mockCommit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
mockUseImport.mockReturnValue({
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
caddyfile_content: '',
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: mockClearCommitResult,
|
||||
upload: vi.fn(),
|
||||
commit: mockCommit,
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Click Review Changes to show the review table
|
||||
const reviewButton = screen.getByRole('button', { name: 'Review Changes' })
|
||||
fireEvent.click(reviewButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('import-review-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click Commit Import button
|
||||
const commitButton = screen.getByRole('button', { name: 'Commit Import' })
|
||||
fireEvent.click(commitButton)
|
||||
|
||||
// Verify createBackup is called first
|
||||
await waitFor(() => {
|
||||
expect(mockCreateBackup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Then commit should be called
|
||||
await waitFor(() => {
|
||||
expect(mockCommit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ImportCaddy from '../ImportCaddy'
|
||||
|
||||
// Create a simple mock for useImport that returns the error state
|
||||
const mockUseImport = vi.fn()
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useImport', () => ({
|
||||
useImport: () => mockUseImport(),
|
||||
}))
|
||||
|
||||
// Mock translation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImportCaddy - Import Detection Error Display', () => {
|
||||
it('displays error message when import directives detected', () => {
|
||||
// Mock the hook to return error state with imports
|
||||
mockUseImport.mockReturnValue({
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: 'This Caddyfile contains import directives. Please use the multi-file import flow to upload all referenced files together.',
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check main error message is displayed
|
||||
expect(screen.getByText(/this caddyfile contains import directives/i)).toBeInTheDocument()
|
||||
|
||||
// Check multi-site import button is available as alternative
|
||||
const multiSiteButton = screen.getByTestId('multi-file-import-button')
|
||||
expect(multiSiteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays plain error when no imports detected', () => {
|
||||
// Mock the hook to return error without imports
|
||||
mockUseImport.mockReturnValue({
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: 'no sites found in uploaded Caddyfile',
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText('no sites found in uploaded Caddyfile')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImportCaddy from '../ImportCaddy'
|
||||
import { useImport } from '../../hooks/useImport'
|
||||
|
||||
// Mock the hooks and API calls
|
||||
vi.mock('../../hooks/useImport')
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
const mockUseImport = vi.mocked(useImport)
|
||||
|
||||
describe('ImportCaddy - Multi-File Modal', () => {
|
||||
const defaultMockReturn = {
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseImport.mockReturnValue(defaultMockReturn)
|
||||
})
|
||||
|
||||
it('renders multi-file button when no session exists', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByTestId('multi-file-import-button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).toHaveTextContent(/multi.*site.*import/i)
|
||||
})
|
||||
|
||||
it('shows import banner when session exists (multi-file hidden during active session)', () => {
|
||||
mockUseImport.mockReturnValueOnce({
|
||||
...defaultMockReturn,
|
||||
session: { id: 'test-session-id', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// When a session exists, the import banner is shown instead of the upload form
|
||||
expect(screen.getByTestId('import-banner')).toBeInTheDocument()
|
||||
// Multi-file button is part of upload form, which is hidden during active session
|
||||
expect(screen.queryByTestId('multi-file-import-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens modal when multi-file button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByTestId('multi-file-import-button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId('multi-site-modal')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('modal has correct accessibility attributes', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByTestId('multi-file-import-button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByRole('dialog')
|
||||
expect(modal).toBeInTheDocument()
|
||||
expect(modal).toHaveAttribute('aria-modal', 'true')
|
||||
expect(modal).toHaveAttribute('aria-labelledby', 'multi-site-modal-title')
|
||||
expect(modal).toHaveAttribute('data-testid', 'multi-site-modal')
|
||||
})
|
||||
})
|
||||
|
||||
it('modal contains correct title for screen readers', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByTestId('multi-file-import-button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
// Use heading role to specifically target the modal title, not the button
|
||||
const title = screen.getByRole('heading', { name: 'Multi-site Import' })
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(title).toHaveAttribute('id', 'multi-site-modal-title')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes modal when clicking outside overlay', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Open modal
|
||||
const button = screen.getByTestId('multi-file-import-button')
|
||||
await user.click(button)
|
||||
|
||||
// Wait for modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click overlay (the semi-transparent background)
|
||||
const overlay = screen.getByRole('dialog').querySelector('.bg-black\\/60')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
if (overlay) {
|
||||
await user.click(overlay)
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('opens modal and shows it correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
const button = screen.getByTestId('multi-file-import-button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify modal is displayed
|
||||
const modal = screen.getByRole('dialog')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('modal button text matches E2E test selector', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// E2E test uses: page.getByRole('button', { name: /multi.*file|multi.*site/i })
|
||||
const button = screen.getByRole('button', { name: /multi.*file|multi.*site/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles error state from import', async () => {
|
||||
mockUseImport.mockReturnValueOnce({
|
||||
...defaultMockReturn,
|
||||
error: 'Import directives detected',
|
||||
})
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ImportCaddy />
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
// Error message should display
|
||||
expect(screen.getByText(/Import directives detected/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ImportCaddy from '../ImportCaddy'
|
||||
|
||||
// Create a simple mock for useImport that returns the preview state
|
||||
const mockUseImport = vi.fn()
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useImport', () => ({
|
||||
useImport: () => mockUseImport(),
|
||||
}))
|
||||
|
||||
// Mock translation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImportCaddy - Warning Display', () => {
|
||||
it('displays empty file warning when session exists but no hosts found', () => {
|
||||
// Mock the hook to return session with empty hosts
|
||||
mockUseImport.mockReturnValue({
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing' },
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check empty file warning is displayed
|
||||
expect(screen.getByText('importCaddy.noDomainsFound')).toBeInTheDocument()
|
||||
expect(screen.getByText('importCaddy.emptyFileWarning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays import banner when session exists', () => {
|
||||
// Mock the hook to return session with hosts
|
||||
mockUseImport.mockReturnValue({
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check import banner is visible
|
||||
expect(screen.getByTestId('import-banner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display empty file warning when hosts exist', () => {
|
||||
// Mock the hook to return session with hosts
|
||||
mockUseImport.mockReturnValue({
|
||||
session: { id: 'test-session', state: 'reviewing', created_at: '', updated_at: '' },
|
||||
preview: {
|
||||
session: { id: 'test-session', state: 'reviewing' },
|
||||
preview: {
|
||||
hosts: [{ domain_names: 'example.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check empty file warning is NOT visible
|
||||
expect(screen.queryByText('importCaddy.noDomainsFound')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display import banner when no session exists', () => {
|
||||
// Mock the hook to return null session
|
||||
mockUseImport.mockReturnValue({
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check import banner is NOT visible
|
||||
expect(screen.queryByTestId('import-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays error message when error exists', () => {
|
||||
// Mock the hook to return error state
|
||||
mockUseImport.mockReturnValue({
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: 'Failed to parse Caddyfile',
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check error message is displayed
|
||||
expect(screen.getByText('Failed to parse Caddyfile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows upload form when no session exists', () => {
|
||||
// Mock the hook to return null session
|
||||
mockUseImport.mockReturnValue({
|
||||
session: null,
|
||||
preview: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
commitSuccess: false,
|
||||
commitResult: null,
|
||||
clearCommitResult: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
})
|
||||
|
||||
render(<ImportCaddy />, { wrapper: createWrapper() })
|
||||
|
||||
// Check upload form elements are visible
|
||||
expect(screen.getByTestId('import-dropzone')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('multi-file-import-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as api from '../../api/crowdsec'
|
||||
import * as backups from '../../api/backups'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImportCrowdSec page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates a backup then imports crowdsec', async () => {
|
||||
const file = new File(['fake'], 'crowdsec.zip', { type: 'application/zip' })
|
||||
vi.mocked(backups.createBackup).mockResolvedValue({ filename: 'b1' })
|
||||
vi.mocked(api.importCrowdsecConfig).mockResolvedValue({ success: true })
|
||||
|
||||
renderWithProviders(<ImportCrowdSec />)
|
||||
const fileInput = document.querySelector('input[type="file"]')
|
||||
expect(fileInput).toBeTruthy()
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
const importBtn = screen.getByText('Import')
|
||||
const user = userEvent.setup()
|
||||
await user.click(importBtn)
|
||||
|
||||
await waitFor(() => expect(backups.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(api.importCrowdsecConfig).toHaveBeenCalledWith(file))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ImportCrowdSec', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
})
|
||||
|
||||
const renderPage = () => {
|
||||
const qc = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ImportCrowdSec />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders configuration packages heading', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => screen.getByText('CrowdSec Configuration Packages'))
|
||||
expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a backup before importing selected package', async () => {
|
||||
renderPage()
|
||||
|
||||
const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
|
||||
const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.upload(fileInput, file)
|
||||
|
||||
const importButton = screen.getByRole('button', { name: /Import/i })
|
||||
await user.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled()
|
||||
expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file)
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Login from '../Login'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import client from '../../api/client'
|
||||
import * as setupApi from '../../api/setup'
|
||||
|
||||
// Mock modules
|
||||
vi.mock('../../api/client')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
vi.mock('../../api/setup')
|
||||
|
||||
const mockLogin = vi.fn()
|
||||
vi.mocked(authHook.useAuth).mockReturnValue({
|
||||
user: null,
|
||||
login: mockLogin,
|
||||
logout: vi.fn(),
|
||||
loading: false,
|
||||
} as unknown as ReturnType<typeof authHook.useAuth>)
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Login - Coin Overlay Security Audit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock setup status to resolve immediately with no setup required
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false })
|
||||
})
|
||||
|
||||
it('shows coin-themed overlay during login', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Coin-themed overlay should appear
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
|
||||
// Verify coin theme (gold/amber) - use querySelector to find actual overlay container
|
||||
const overlay = document.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('ATTACK: rapid fire login attempts are blocked by overlay', async () => {
|
||||
let resolveCount = 0
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolveCount++
|
||||
resolve({ data: {} })
|
||||
}, 200)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
|
||||
// Click multiple times rapidly
|
||||
await userEvent.click(submitButton)
|
||||
await userEvent.click(submitButton)
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should block subsequent clicks (form is disabled)
|
||||
expect(emailInput).toBeDisabled()
|
||||
expect(passwordInput).toBeDisabled()
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
|
||||
// Should only execute once
|
||||
expect(resolveCount).toBe(1)
|
||||
})
|
||||
|
||||
it('clears overlay on login error', async () => {
|
||||
// Use delayed rejection so overlay has time to appear
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise((_, reject) => {
|
||||
setTimeout(() => reject({ response: { data: { error: 'Invalid credentials' } } }), 100)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'wrong@example.com')
|
||||
await userEvent.type(passwordInput, 'wrong')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay appears
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
// Overlay clears after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
|
||||
// Form should be re-enabled
|
||||
expect(emailInput).not.toBeDisabled()
|
||||
expect(passwordInput).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('ATTACK: XSS in login credentials does not break overlay', async () => {
|
||||
// Use delayed promise so we can catch the overlay
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
// Use valid email format with XSS-like characters in password
|
||||
await userEvent.type(emailInput, 'test@example.com')
|
||||
await userEvent.type(passwordInput, '<img src=x onerror=alert(1)>')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should still work
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
})
|
||||
|
||||
it('ATTACK: network timeout does not leave overlay stuck', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Network timeout')), 100)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
// Overlay should clear after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('overlay has correct z-index hierarchy', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should be z-50
|
||||
const overlay = document.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('overlay renders CharonCoinLoader component', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// CharonCoinLoader has aria-label="Authenticating"
|
||||
expect(screen.getByLabelText('Authenticating')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
// Mock react-router-dom useNavigate at module level
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Login from '../Login'
|
||||
import * as setupApi from '../../api/setup'
|
||||
import client from '../../api/client'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import type { AuthContextType } from '../../context/AuthContextValue'
|
||||
import { toast } from '../../utils/toast'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
vi.mock('../../api/setup')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
|
||||
describe('<Login />', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => (
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: vi.fn() } as unknown as AuthContextType)
|
||||
})
|
||||
|
||||
it('navigates to /setup when setup is required', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: true })
|
||||
renderWithProviders(<Login />)
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/setup')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when login fails', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
const postSpy = vi.spyOn(client, 'post').mockRejectedValueOnce({ response: { data: { error: 'Bad creds' } } })
|
||||
const toastSpy = vi.spyOn(toast, 'error')
|
||||
renderWithProviders(<Login />)
|
||||
// Fill and submit
|
||||
const email = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const pass = screen.getByPlaceholderText(/••••••••/i)
|
||||
fireEvent.change(email, { target: { value: 'a@b.com' } })
|
||||
fireEvent.change(pass, { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign In/i }))
|
||||
// Wait for the promise chain
|
||||
await waitFor(() => expect(postSpy).toHaveBeenCalled())
|
||||
expect(toastSpy).toHaveBeenCalledWith('Bad creds')
|
||||
})
|
||||
|
||||
it('uses returned token when cookie is unavailable', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { token: 'bearer-token' } })
|
||||
const loginFn = vi.fn().mockResolvedValue(undefined)
|
||||
vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: loginFn } as unknown as AuthContextType)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
const email = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const pass = screen.getByPlaceholderText(/••••••••/i)
|
||||
fireEvent.change(email, { target: { value: 'a@b.com' } })
|
||||
fireEvent.change(pass, { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign In/i }))
|
||||
|
||||
await waitFor(() => expect(postSpy).toHaveBeenCalled())
|
||||
expect(loginFn).toHaveBeenCalledWith('bearer-token')
|
||||
})
|
||||
|
||||
it('has proper autocomplete attributes for password managers', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
await waitFor(() => screen.getByPlaceholderText(/admin@example.com/i))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/••••••••/i)
|
||||
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'current-password')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,602 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Notifications from '../Notifications'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import * as notificationsApi from '../../api/notifications'
|
||||
import { toast } from '../../utils/toast'
|
||||
import type { NotificationProvider } from '../../api/notifications'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/notifications', () => ({
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook'],
|
||||
getProviders: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
previewProvider: vi.fn(),
|
||||
getExternalTemplates: vi.fn(),
|
||||
previewExternalTemplate: vi.fn(),
|
||||
createExternalTemplate: vi.fn(),
|
||||
updateExternalTemplate: vi.fn(),
|
||||
deleteExternalTemplate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const baseProvider: NotificationProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Discord Alerts',
|
||||
type: 'discord',
|
||||
url: 'https://discord.com/api/webhooks/abc',
|
||||
config: '{"message":"test"}',
|
||||
template: 'minimal',
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_remote_servers: true,
|
||||
notify_domains: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true,
|
||||
notify_security_waf_blocks: false,
|
||||
notify_security_acl_denies: false,
|
||||
notify_security_rate_limit_hits: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const setupMocks = (providers: NotificationProvider[] = []) => {
|
||||
vi.mocked(notificationsApi.getProviders).mockResolvedValue(providers)
|
||||
vi.mocked(notificationsApi.getTemplates).mockResolvedValue([])
|
||||
vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([])
|
||||
vi.mocked(notificationsApi.createProvider).mockResolvedValue(baseProvider)
|
||||
vi.mocked(notificationsApi.updateProvider).mockResolvedValue(baseProvider)
|
||||
}
|
||||
|
||||
let user: ReturnType<typeof userEvent.setup>
|
||||
|
||||
describe('Notifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupMocks()
|
||||
user = userEvent.setup()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects invalid protocol URLs', async () => {
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Webhook')
|
||||
await user.type(screen.getByTestId('provider-url'), 'ftp://example.com/hook')
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
expect(screen.getByTestId('provider-url-error')).toHaveTextContent('notificationProviders.invalidUrl')
|
||||
expect(notificationsApi.createProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed URLs', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Webhook')
|
||||
await user.type(screen.getByTestId('provider-url'), 'not-a-url')
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
expect(screen.getByTestId('provider-url-error')).toHaveTextContent('notificationProviders.invalidUrl')
|
||||
expect(notificationsApi.createProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a valid https URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Webhook')
|
||||
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.createProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0]
|
||||
expect(payload.url).toBe('https://example.com/webhook')
|
||||
expect(payload.type).toBe('discord')
|
||||
})
|
||||
|
||||
it('accepts a valid http URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Webhook')
|
||||
await user.type(screen.getByTestId('provider-url'), 'http://example.com/webhook')
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.createProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0]
|
||||
expect(payload.url).toBe('http://example.com/webhook')
|
||||
expect(payload.type).toBe('discord')
|
||||
})
|
||||
|
||||
it('shows supported provider type options', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
|
||||
const options = Array.from(typeSelect.options)
|
||||
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook'])
|
||||
expect(typeSelect.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('associates provider type label with select control', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
|
||||
const typeSelect = screen.getByTestId('provider-type')
|
||||
expect(typeSelect).toHaveAttribute('id', 'provider-type')
|
||||
expect(screen.getByLabelText('common.type')).toBe(typeSelect)
|
||||
})
|
||||
|
||||
it('submits selected provider type without forcing discord', async () => {
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.selectOptions(screen.getByTestId('provider-type'), 'webhook')
|
||||
await user.type(screen.getByTestId('provider-name'), 'Normalized Provider')
|
||||
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
|
||||
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
|
||||
expect(typeSelect.value).toBe('webhook')
|
||||
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.createProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0]
|
||||
expect(payload.type).toBe('webhook')
|
||||
})
|
||||
|
||||
it('shows and hides the update indicator after save', async () => {
|
||||
setupMocks([baseProvider])
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
await user.click(buttons[1])
|
||||
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
expect(notificationsApi.updateProvider).toHaveBeenCalled()
|
||||
|
||||
expect(screen.getByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeInTheDocument()
|
||||
expect(toast.success).toHaveBeenCalledWith('common.saved')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeNull()
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the update indicator timer on unmount', async () => {
|
||||
setupMocks([baseProvider])
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
await user.click(buttons[1])
|
||||
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
expect(notificationsApi.updateProvider).toHaveBeenCalled()
|
||||
expect(screen.getByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets event checkboxes when switching from edit to add', async () => {
|
||||
const providerWithDisabledEvents: NotificationProvider = {
|
||||
...baseProvider,
|
||||
notify_proxy_hosts: false,
|
||||
notify_remote_servers: false,
|
||||
}
|
||||
|
||||
setupMocks([providerWithDisabledEvents])
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId(`provider-row-${providerWithDisabledEvents.id}`)
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
await user.click(buttons[1])
|
||||
|
||||
const notifyProxyHosts = screen.getByTestId('notify-proxy-hosts') as HTMLInputElement
|
||||
expect(notifyProxyHosts.checked).toBe(false)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
|
||||
const resetNotifyProxyHosts = screen.getByTestId('notify-proxy-hosts') as HTMLInputElement
|
||||
expect(resetNotifyProxyHosts.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('renders external template loading and rows when templates are present', async () => {
|
||||
const template = {
|
||||
id: 'template-1',
|
||||
name: 'Ops Payload',
|
||||
description: 'Template for ops alerts',
|
||||
template: 'custom' as const,
|
||||
config: '{"text":"{{.Message}}"}',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(notificationsApi.getExternalTemplates).mockReturnValue(new Promise(() => {}))
|
||||
const { unmount } = renderWithQueryClient(<Notifications />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' }))
|
||||
expect(screen.getByTestId('external-templates-loading')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([template])
|
||||
renderWithQueryClient(<Notifications />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' }))
|
||||
|
||||
expect(await screen.findByTestId('external-template-row-template-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ops Payload')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens external template editor and deletes template on confirm', async () => {
|
||||
const template = {
|
||||
id: 'template-2',
|
||||
name: 'Security Payload',
|
||||
description: 'Template for security alerts',
|
||||
template: 'custom' as const,
|
||||
config: '{"text":"{{.Message}}"}',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([template])
|
||||
vi.mocked(notificationsApi.deleteExternalTemplate).mockResolvedValue(undefined)
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
await user.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' }))
|
||||
|
||||
const row = await screen.findByTestId('external-template-row-template-2')
|
||||
expect(row).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('external-template-edit-template-2'))
|
||||
await waitFor(() => {
|
||||
expect((screen.getByTestId('template-name') as HTMLInputElement).value).toBe('Security Payload')
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('external-template-delete-template-2'))
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(notificationsApi.deleteExternalTemplate).toHaveBeenCalledWith('template-2')
|
||||
})
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not render a standalone security notifications section', async () => {
|
||||
renderWithQueryClient(<Notifications />)
|
||||
await screen.findByTestId('add-provider-btn')
|
||||
expect(screen.queryByTestId('security-notifications-section')).toBeNull()
|
||||
expect(screen.queryByTestId('security-compatibility-banner')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows security event subscription controls in provider form', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
|
||||
expect(screen.getByTestId('notify-security-waf-blocks')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notify-security-acl-denies')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notify-security-rate-limit-hits')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps add-provider guidance aligned with Discord webhook UX', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
|
||||
expect(typeSelect.value).toBe('discord')
|
||||
expect(screen.getByTestId('provider-url')).toHaveAttribute('placeholder', 'https://discord.com/api/webhooks/...')
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits gotify token on create for gotify provider mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.selectOptions(screen.getByTestId('provider-type'), 'gotify')
|
||||
await user.type(screen.getByTestId('provider-name'), 'Gotify Alerts')
|
||||
await user.type(screen.getByTestId('provider-url'), 'https://gotify.example.com/message')
|
||||
await user.type(screen.getByTestId('provider-gotify-token'), 'super-secret-token')
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.createProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0]
|
||||
expect(payload.type).toBe('gotify')
|
||||
expect(payload.token).toBe('super-secret-token')
|
||||
})
|
||||
|
||||
it('uses masked gotify token input and never pre-fills token on edit', async () => {
|
||||
const gotifyProvider: NotificationProvider = {
|
||||
...baseProvider,
|
||||
id: 'provider-gotify',
|
||||
type: 'gotify',
|
||||
url: 'https://gotify.example.com/message',
|
||||
}
|
||||
|
||||
setupMocks([gotifyProvider])
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId('provider-row-provider-gotify')
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
await user.click(buttons[1])
|
||||
|
||||
const tokenInput = screen.getByTestId('provider-gotify-token') as HTMLInputElement
|
||||
expect(tokenInput.type).toBe('password')
|
||||
expect(tokenInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('renders external template action buttons and skips delete when confirm is cancelled', async () => {
|
||||
const template = {
|
||||
id: 'template-cancel',
|
||||
name: 'Cancel Delete Template',
|
||||
description: 'Template used for cancel delete branch',
|
||||
template: 'custom' as const,
|
||||
config: '{"text":"{{.Message}}"}',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([template])
|
||||
vi.mocked(notificationsApi.deleteExternalTemplate).mockResolvedValue(undefined)
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
await user.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' }))
|
||||
|
||||
expect(await screen.findByTestId('external-template-row-template-cancel')).toBeInTheDocument()
|
||||
|
||||
const editButton = screen.getByTestId('external-template-edit-template-cancel')
|
||||
const deleteButton = screen.getByTestId('external-template-delete-template-cancel')
|
||||
|
||||
await user.click(editButton)
|
||||
await waitFor(() => {
|
||||
expect((screen.getByTestId('template-name') as HTMLInputElement).value).toBe('Cancel Delete Template')
|
||||
})
|
||||
|
||||
await user.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(notificationsApi.deleteExternalTemplate).not.toHaveBeenCalled()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders non-discord providers with explicit deprecated and non-dispatch messaging', async () => {
|
||||
const legacyProvider: NotificationProvider = {
|
||||
...baseProvider,
|
||||
id: 'legacy-provider',
|
||||
name: 'Legacy Slack',
|
||||
type: 'slack',
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
setupMocks([legacyProvider])
|
||||
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const legacyRow = await screen.findByTestId('provider-row-legacy-provider')
|
||||
expect(within(legacyRow).getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getByTestId('provider-deprecated-status-legacy-provider')).toHaveTextContent('notificationProviders.deprecatedReadOnly')
|
||||
expect(screen.getByTestId('provider-nondispatch-status-legacy-provider')).toHaveTextContent('notificationProviders.nonDispatch')
|
||||
expect(screen.getByTestId('provider-deprecated-message-legacy-provider')).toHaveTextContent('notificationProviders.deprecatedProviderMessage')
|
||||
})
|
||||
|
||||
it('submits provider test action from form using normalized discord type', async () => {
|
||||
vi.mocked(notificationsApi.testProvider).mockResolvedValue(undefined)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Preview/Test Provider')
|
||||
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
|
||||
|
||||
await user.click(screen.getByTestId('provider-test-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.testProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = vi.mocked(notificationsApi.testProvider).mock.calls[0][0]
|
||||
expect(payload.type).toBe('discord')
|
||||
})
|
||||
|
||||
it('uses previewProvider for non-uuid template selections', async () => {
|
||||
vi.mocked(notificationsApi.previewProvider).mockResolvedValue({
|
||||
rendered: '{"message":"preview"}',
|
||||
parsed: undefined,
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Preview Provider')
|
||||
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
|
||||
await user.click(screen.getByTestId('provider-preview-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.previewProvider).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('treats empty legacy type as unsupported and keeps row read-only', async () => {
|
||||
const emptyTypeProvider: NotificationProvider = {
|
||||
...baseProvider,
|
||||
id: 'provider-empty-type',
|
||||
type: '',
|
||||
}
|
||||
|
||||
setupMocks([emptyTypeProvider])
|
||||
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId('provider-row-provider-empty-type')
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(screen.getByTestId('provider-deprecated-status-provider-empty-type')).toHaveTextContent('notificationProviders.deprecatedReadOnly')
|
||||
})
|
||||
|
||||
it('triggers row-level send test action with discord payload', async () => {
|
||||
setupMocks([baseProvider])
|
||||
vi.mocked(notificationsApi.testProvider).mockResolvedValue(undefined)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
|
||||
await user.click(buttons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.testProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = vi.mocked(notificationsApi.testProvider).mock.calls[0][0]
|
||||
expect(payload.type).toBe('discord')
|
||||
})
|
||||
|
||||
it('shows token-stored indicator when editing provider with has_token=true', async () => {
|
||||
const gotifyProviderWithToken: NotificationProvider = {
|
||||
...baseProvider,
|
||||
id: 'provider-gotify-has-token',
|
||||
type: 'gotify',
|
||||
url: 'https://gotify.example.com/message',
|
||||
has_token: true,
|
||||
}
|
||||
|
||||
setupMocks([gotifyProviderWithToken])
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId('provider-row-provider-gotify-has-token')
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
await user.click(buttons[1])
|
||||
|
||||
expect(screen.getByTestId('gotify-token-stored-indicator')).toHaveTextContent('notificationProviders.gotifyTokenStored')
|
||||
const tokenInput = screen.getByTestId('provider-gotify-token') as HTMLInputElement
|
||||
expect(tokenInput.placeholder).toBe('notificationProviders.gotifyTokenKeepPlaceholder')
|
||||
})
|
||||
|
||||
it('hides token-stored indicator when has_token is false', async () => {
|
||||
const gotifyProviderNoToken: NotificationProvider = {
|
||||
...baseProvider,
|
||||
id: 'provider-gotify-no-token',
|
||||
type: 'gotify',
|
||||
url: 'https://gotify.example.com/message',
|
||||
has_token: false,
|
||||
}
|
||||
|
||||
setupMocks([gotifyProviderNoToken])
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId('provider-row-provider-gotify-no-token')
|
||||
const buttons = within(row).getAllByRole('button')
|
||||
await user.click(buttons[1])
|
||||
|
||||
expect(screen.queryByTestId('gotify-token-stored-indicator')).toBeNull()
|
||||
const tokenInput = screen.getByTestId('provider-gotify-token') as HTMLInputElement
|
||||
expect(tokenInput.placeholder).toBe('notificationProviders.gotifyTokenPlaceholder')
|
||||
})
|
||||
|
||||
it('shows error toast when test mutation fails', async () => {
|
||||
vi.mocked(notificationsApi.testProvider).mockRejectedValue(new Error('Connection refused'))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.type(screen.getByTestId('provider-name'), 'Failing Provider')
|
||||
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
|
||||
await user.click(screen.getByTestId('provider-test-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Connection refused')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows JSON template selector for gotify provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.selectOptions(screen.getByTestId('provider-type'), 'gotify')
|
||||
|
||||
expect(screen.getByTestId('provider-config')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows JSON template selector for webhook provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.selectOptions(screen.getByTestId('provider-type'), 'webhook')
|
||||
|
||||
expect(screen.getByTestId('provider-config')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,475 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Plugins from '../Plugins'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import type { PluginInfo } from '../../api/plugins'
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string | Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'plugins.title': 'DNS Provider Plugins',
|
||||
'plugins.description': 'Manage built-in and external DNS provider plugins for certificate automation',
|
||||
'plugins.note': 'Note',
|
||||
'plugins.noteText': 'External plugins extend Charon with custom DNS providers. Only install plugins from trusted sources.',
|
||||
'plugins.builtInPlugins': 'Built-in Providers',
|
||||
'plugins.externalPlugins': 'External Plugins',
|
||||
'plugins.noPlugins': 'No Plugins Found',
|
||||
'plugins.noPluginsDescription': 'No DNS provider plugins are currently installed.',
|
||||
'plugins.reloadPlugins': 'Reload Plugins',
|
||||
'plugins.pluginDetails': 'Plugin Details',
|
||||
'plugins.type': 'Type',
|
||||
'plugins.status': 'Status',
|
||||
'plugins.version': 'Version',
|
||||
'plugins.author': 'Author',
|
||||
'plugins.pluginType': 'Plugin Type',
|
||||
'plugins.builtIn': 'Built-in',
|
||||
'plugins.external': 'External',
|
||||
'plugins.loadedAt': 'Loaded At',
|
||||
'plugins.documentation': 'Documentation',
|
||||
'plugins.errorDetails': 'Error Details',
|
||||
'plugins.details': 'Details',
|
||||
'plugins.docs': 'Docs',
|
||||
'plugins.loaded': 'Loaded',
|
||||
'plugins.error': 'Error',
|
||||
'plugins.pending': 'Pending',
|
||||
'plugins.disabled': 'Disabled',
|
||||
'common.close': 'Close',
|
||||
}
|
||||
if (typeof defaultValue === 'string') {
|
||||
return translations[key] || defaultValue
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockBuiltInPlugin: PluginInfo = {
|
||||
id: 1,
|
||||
uuid: 'builtin-cloudflare',
|
||||
name: 'Cloudflare',
|
||||
type: 'cloudflare',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
is_built_in: true,
|
||||
version: '1.0.0',
|
||||
description: 'Cloudflare DNS provider',
|
||||
documentation_url: 'https://developers.cloudflare.com',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockExternalPlugin: PluginInfo = {
|
||||
id: 2,
|
||||
uuid: 'external-powerdns',
|
||||
name: 'PowerDNS',
|
||||
type: 'powerdns',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
is_built_in: false,
|
||||
version: '1.0.0',
|
||||
author: 'Community',
|
||||
description: 'PowerDNS provider plugin',
|
||||
documentation_url: 'https://doc.powerdns.com',
|
||||
loaded_at: '2025-01-06T00:00:00Z',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-06T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockErrorPlugin: PluginInfo = {
|
||||
id: 3,
|
||||
uuid: 'external-error',
|
||||
name: 'Broken Plugin',
|
||||
type: 'broken',
|
||||
enabled: false,
|
||||
status: 'error',
|
||||
error: 'Failed to load: signature mismatch',
|
||||
is_built_in: false,
|
||||
version: '0.1.0',
|
||||
author: 'Unknown',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-06T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mock('../../hooks/usePlugins', () => ({
|
||||
usePlugins: vi.fn(() => ({
|
||||
data: [mockBuiltInPlugin, mockExternalPlugin, mockErrorPlugin],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useEnablePlugin: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ message: 'Plugin enabled' }),
|
||||
isPending: false,
|
||||
})),
|
||||
useDisablePlugin: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ message: 'Plugin disabled' }),
|
||||
isPending: false,
|
||||
})),
|
||||
useReloadPlugins: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ message: 'Plugins reloaded', count: 2 }),
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('Plugins page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders plugin management page', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// The page now renders inside DNS parent which provides the PageShell
|
||||
// Check that page content renders without errors
|
||||
expect(await screen.findByRole('button', { name: /reload plugins/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('Note:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays built-in plugins section', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Built-in Providers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cloudflare')).toBeInTheDocument()
|
||||
expect(screen.getByText('cloudflare')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays external plugins section', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('External Plugins')).toBeInTheDocument()
|
||||
expect(screen.getByText('PowerDNS')).toBeInTheDocument()
|
||||
expect(screen.getByText('powerdns')).toBeInTheDocument()
|
||||
expect(screen.getByText('by Community')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows status badges correctly', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Loaded status - should have at least one
|
||||
const loadedBadges = await screen.findAllByText(/loaded/i)
|
||||
expect(loadedBadges.length).toBe(2) // 2 loaded plugins
|
||||
|
||||
// Error message should be visible (from mockErrorPlugin)
|
||||
const errorMessage = await screen.findByText(/Failed to load: signature mismatch/i)
|
||||
expect(errorMessage).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays plugin descriptions', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Cloudflare DNS provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('PowerDNS provider plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message for failed plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Failed to load: signature mismatch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders reload plugins button', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const reloadButton = await screen.findByRole('button', { name: /reload plugins/i })
|
||||
expect(reloadButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles reload plugins action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { useReloadPlugins } = await import('../../hooks/usePlugins')
|
||||
const mockReloadMutation = vi.fn().mockResolvedValue({ message: 'Reloaded', count: 3 })
|
||||
vi.mocked(useReloadPlugins).mockReturnValueOnce({
|
||||
mutateAsync: mockReloadMutation,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useReloadPlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const reloadButton = await screen.findByRole('button', { name: /reload plugins/i })
|
||||
await user.click(reloadButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReloadMutation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays documentation links', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const docsLinks = await screen.findAllByText('Docs')
|
||||
expect(docsLinks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('displays details button for each plugin', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
expect(detailsButtons.length).toBe(3) // All 3 plugins should have details button
|
||||
})
|
||||
|
||||
it('opens metadata modal when details button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[0])
|
||||
|
||||
expect(await screen.findByText(/Plugin Details:/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays plugin information in metadata modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[1]) // Click PowerDNS plugin
|
||||
|
||||
// Modal title should include plugin name
|
||||
expect(await screen.findByText(/Plugin Details:/i)).toBeInTheDocument()
|
||||
|
||||
// Check for version label in metadata (not the banner version)
|
||||
const versionLabel = await screen.findByText('Version')
|
||||
expect(versionLabel).toBeInTheDocument()
|
||||
|
||||
// Check that Community author is shown
|
||||
expect(screen.getByText('Community')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows toggle switch for external plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Look for toggle buttons (the Switch component renders as a button)
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Should have reload button, details buttons, and toggle switches
|
||||
expect(buttons.length).toBeGreaterThan(5)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show toggle for built-in plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Built-in plugin section should not have toggle switches nearby
|
||||
const builtInSection = await screen.findByText('Built-in Providers')
|
||||
expect(builtInSection).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles enable/disable toggle action', async () => {
|
||||
const { useDisablePlugin } = await import('../../hooks/usePlugins')
|
||||
const mockDisableMutation = vi.fn().mockResolvedValue({ message: 'Disabled' })
|
||||
vi.mocked(useDisablePlugin).mockReturnValueOnce({
|
||||
mutateAsync: mockDisableMutation,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDisablePlugin>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Find all buttons and click one near the external plugin (simplified test)
|
||||
const allButtons = await screen.findAllByRole('button')
|
||||
// Just verify buttons exist - the actual toggle is tested via integration
|
||||
expect(allButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows loading state', async () => {
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValueOnce({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof usePlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Check for loading skeletons by class
|
||||
const loadingElements = document.querySelectorAll('.animate-pulse')
|
||||
expect(loadingElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows empty state when no plugins', async () => {
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValueOnce({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof usePlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('No Plugins Found')).toBeInTheDocument()
|
||||
expect(screen.getByText(/No DNS provider plugins are currently installed/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays info alert with security warning', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Note:')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/External plugins extend Charon with custom DNS providers/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Phase 2: Additional coverage tests
|
||||
|
||||
it('closes metadata modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[0])
|
||||
|
||||
expect(await screen.findByText(/Plugin Details:/i)).toBeInTheDocument()
|
||||
|
||||
// Get all close buttons and click the primary one (not the X)
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i })
|
||||
const primaryCloseButton = closeButtons.find(btn => btn.textContent === 'Close')
|
||||
await user.click(primaryCloseButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Plugin Details:/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays all metadata fields in modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[1]) // PowerDNS plugin
|
||||
|
||||
expect(await screen.findByText('Version')).toBeInTheDocument()
|
||||
expect(screen.getByText('Author')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugin Type')).toBeInTheDocument()
|
||||
// Text appears in both card and modal, so use getAllByText
|
||||
expect(screen.getAllByText('PowerDNS provider plugin').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('displays error status badge for failed plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// The error plugin should be rendered with an error indicator
|
||||
// Look for the error message which is more reliable than the badge text
|
||||
expect(await screen.findByText(/Failed to load: signature mismatch/i)).toBeInTheDocument()
|
||||
// Also verify the broken plugin name is present
|
||||
expect(screen.getByText('Broken Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays pending status badge for pending plugins', async () => {
|
||||
const mockPendingPlugin: PluginInfo = {
|
||||
...mockExternalPlugin,
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValueOnce({
|
||||
data: [mockPendingPlugin],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof usePlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Pending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens documentation URL in new tab', async () => {
|
||||
const mockWindowOpen = vi.fn()
|
||||
window.open = mockWindowOpen
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const docsLinks = await screen.findAllByText('Docs')
|
||||
await user.click(docsLinks[0])
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith('https://developers.cloudflare.com', '_blank')
|
||||
})
|
||||
|
||||
it('handles missing documentation URL gracefully', async () => {
|
||||
const mockPluginWithoutDocs: PluginInfo = {
|
||||
...mockExternalPlugin,
|
||||
documentation_url: undefined,
|
||||
}
|
||||
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValueOnce({
|
||||
data: [mockPluginWithoutDocs],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof usePlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Docs')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays loaded at timestamp in metadata modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[1]) // PowerDNS plugin with loaded_at
|
||||
|
||||
expect(await screen.findByText('Loaded At')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays error message inline for failed plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Error message should be visible in the card itself
|
||||
expect(await screen.findByText('Failed to load: signature mismatch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders documentation buttons for plugins with docs', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Should have at least one Docs button for plugins with documentation_url
|
||||
await waitFor(() => {
|
||||
const docsButtons = screen.queryAllByText('Docs')
|
||||
expect(docsButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows reload button loading state', async () => {
|
||||
const { useReloadPlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(useReloadPlugins).mockReturnValueOnce({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useReloadPlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const reloadButton = await screen.findByRole('button', { name: /reload plugins/i })
|
||||
expect(reloadButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has details button for each plugin', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Each plugin should have a details button
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
expect(detailsButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows disabled status badge for disabled plugins', async () => {
|
||||
const mockDisabledPlugin: PluginInfo = {
|
||||
...mockExternalPlugin,
|
||||
enabled: false,
|
||||
status: 'loaded',
|
||||
}
|
||||
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValueOnce({
|
||||
data: [mockDisabledPlugin],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof usePlugins>)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Disabled')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,585 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(),
|
||||
getBackups: vi.fn(),
|
||||
restoreBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
getCertificates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/accessLists', () => ({
|
||||
accessListsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
testIP: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
];
|
||||
|
||||
const mockAccessLists = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'acl-1',
|
||||
name: 'Admin Only',
|
||||
description: 'Admin access',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'acl-2',
|
||||
name: 'Local Network',
|
||||
description: 'Local network only',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: true,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'acl-3',
|
||||
name: 'Disabled ACL',
|
||||
description: 'This is disabled',
|
||||
type: 'blacklist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk ACL Modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue(mockAccessLists);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('renders Manage ACL button when hosts are selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using the select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Manage ACL button should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens bulk ACL modal when Manage ACL is clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Manage ACL
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Modal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Apply ACL and Remove ACL toggle buttons', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Should show toggle buttons
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply ACL' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Remove ACL' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only enabled access lists in the selection', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Should show enabled ACLs
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
expect(screen.getByText('Local Network')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should NOT show disabled ACL
|
||||
expect(screen.queryByText('Disabled ACL')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows ACL type alongside name', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Should show type - the modal should display ACL types
|
||||
await waitFor(() => {
|
||||
// Check that the ACL list is rendered with names
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
expect(screen.getByText('Local Network')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('has Apply button disabled when no ACL is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for modal to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply action button should be disabled (the one with bg-blue-600 class, not the toggle)
|
||||
// The action button text is "Apply" or "Apply (N)"
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const applyButton = buttons.find(btn => {
|
||||
const text = btn.textContent?.trim() || '';
|
||||
// Match "Apply" exactly but not "Apply ACL" (which is the toggle)
|
||||
const isApplyAction = text === 'Apply' || /^Apply \(\d+\)$/.test(text);
|
||||
return isApplyAction;
|
||||
});
|
||||
expect(applyButton).toBeTruthy();
|
||||
expect((applyButton as HTMLButtonElement)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enables Apply button when ACL is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
const user = userEvent.setup();
|
||||
await user.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await user.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for ACL list
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select an ACL
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
// Find the checkbox for Admin Only (should be after the host selection checkboxes)
|
||||
const aclCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (aclCheckbox) {
|
||||
await userEvent.click(aclCheckbox);
|
||||
}
|
||||
|
||||
// Apply button should be enabled and show count
|
||||
await waitFor(() => {
|
||||
const applyButton = screen.getByRole('button', { name: /Apply \(1\)/ });
|
||||
expect(applyButton).toBeTruthy();
|
||||
expect(applyButton).toHaveProperty('disabled', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('can select multiple ACLs', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for ACL list
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select multiple ACLs
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
const localCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Local Network')
|
||||
);
|
||||
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
if (localCheckbox) await userEvent.click(localCheckbox);
|
||||
|
||||
// Apply button should show count of 2
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies ACL to selected hosts successfully', async () => {
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 2,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for ACL list and select one
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
|
||||
// Click Apply
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ }));
|
||||
|
||||
// Should call API
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(
|
||||
['host-1', 'host-2'],
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
// Should show success toast
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Updated successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Remove ACL confirmation when Remove is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Wait for modal and find Remove ACL toggle (it's a button with flex-1 class)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find the toggle button by looking for flex-1 class
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const removeToggle = buttons.find(btn =>
|
||||
btn.textContent === 'Remove ACL' && btn.className.includes('flex-1')
|
||||
);
|
||||
expect(removeToggle).toBeTruthy();
|
||||
if (removeToggle) await userEvent.click(removeToggle);
|
||||
|
||||
// Should show warning message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/will become publicly accessible/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal on Cancel', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Modal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Apply Access List')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Cancel
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Apply Access List')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears selection and closes modal after successful apply', async () => {
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 2,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Select ACL and apply
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ }));
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Apply Access List')).toBeNull();
|
||||
});
|
||||
|
||||
// Selection should be cleared (Manage ACL button should be gone)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Manage ACL')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast on API failure', async () => {
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 1,
|
||||
errors: [{ uuid: 'host-2', error: 'Failed' }],
|
||||
});
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select hosts and open modal
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByText('Manage ACL'));
|
||||
|
||||
// Select ACL and apply
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Only')).toBeTruthy();
|
||||
});
|
||||
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox');
|
||||
const adminCheckbox = aclCheckboxes.find(cb =>
|
||||
cb.closest('label')?.textContent?.includes('Admin Only')
|
||||
);
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy();
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ }));
|
||||
|
||||
// Should show error toast
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to update');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }));
|
||||
vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() }));
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}));
|
||||
|
||||
const hosts = [
|
||||
createMockProxyHost({ uuid: 'h1', name: 'Host 1', domain_names: 'one.example.com' }),
|
||||
createMockProxyHost({ uuid: 'h2', name: 'Host 2', domain_names: 'two.example.com' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply all settings coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
});
|
||||
|
||||
it('renders all bulk apply setting labels and allows toggling', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Host 1')).toBeTruthy());
|
||||
|
||||
// select all
|
||||
const headerCheckbox = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(headerCheckbox);
|
||||
|
||||
// open Bulk Apply
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
const labels = [
|
||||
'Force SSL',
|
||||
'HTTP/2 Support',
|
||||
'HSTS Enabled',
|
||||
'HSTS Subdomains',
|
||||
'Block Exploits',
|
||||
'Websockets Support',
|
||||
];
|
||||
|
||||
const { within } = await import('@testing-library/react');
|
||||
|
||||
for (const lbl of labels) {
|
||||
expect(screen.getByText(lbl)).toBeTruthy();
|
||||
// Find the setting row and click the Radix Checkbox (role="checkbox")
|
||||
const labelEl = screen.getByText(lbl) as HTMLElement;
|
||||
const row = labelEl.closest('.p-3') as HTMLElement;
|
||||
const checkboxes = within(row).getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
}
|
||||
|
||||
// After toggling at least one, Apply should be enabled
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyBtn = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
expect(applyBtn).toBeTruthy();
|
||||
// Cancel to close
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: /Cancel/i }));
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() }))
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
const hosts = [
|
||||
createMockProxyHost({ uuid: 'p1', name: 'Progress 1', domain_names: 'p1.example.com' }),
|
||||
createMockProxyHost({ uuid: 'p2', name: 'Progress 2', domain_names: 'p2.example.com' }),
|
||||
]
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProxyHosts - Bulk Apply progress UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>)
|
||||
})
|
||||
|
||||
it('shows applying progress while updateProxyHost resolves', async () => {
|
||||
// Make updateProxyHost return controllable promises so we can assert the progress UI
|
||||
const updateMock = vi.mocked(proxyHostsApi.updateProxyHost)
|
||||
const resolvers: Array<(v: ProxyHost) => void> = []
|
||||
updateMock.mockImplementation(() => new Promise((res: (v: ProxyHost) => void) => { resolvers.push(res) }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Progress 1')).toBeTruthy())
|
||||
|
||||
// Select all
|
||||
const selectAll = screen.getByLabelText('Select all rows')
|
||||
await userEvent.click(selectAll)
|
||||
|
||||
// Open Bulk Apply
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
|
||||
// Enable one setting (Force SSL) - use Radix Checkbox (role="checkbox") in the row
|
||||
const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement
|
||||
const forceRow = forceLabel.closest('.p-3') as HTMLElement
|
||||
const { within } = await import('@testing-library/react')
|
||||
const forceCheckbox = within(forceRow).getAllByRole('checkbox')[0]
|
||||
await userEvent.click(forceCheckbox)
|
||||
|
||||
// Click Apply and assert progress UI appears
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i })
|
||||
await userEvent.click(applyButton)
|
||||
|
||||
// During the small delay the progress text should appear (there are two matching nodes)
|
||||
await waitFor(() => expect(screen.getAllByText(/Applying settings/i).length).toBeGreaterThan(0))
|
||||
|
||||
// Resolve both pending update promises to finish the operation
|
||||
resolvers.forEach(r => r(hosts[0]))
|
||||
// Ensure subsequent tests aren't blocked by the special mock: make updateProxyHost resolve normally
|
||||
updateMock.mockImplementation(() => Promise.resolve(hosts[0] as ProxyHost))
|
||||
|
||||
// Wait for updates to complete
|
||||
await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply Settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
});
|
||||
|
||||
it('shows Bulk Apply button when hosts selected and opens modal', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select first host using select-all checkbox
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Bulk Apply button should appear
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
|
||||
// Open modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('applies selected settings to all selected hosts by calling updateProxyHost merged payload', async () => {
|
||||
const updateMock = vi.mocked(proxyHostsApi.updateProxyHost);
|
||||
updateMock.mockResolvedValue(mockProxyHosts[0] as ProxyHost);
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Enable first setting checkbox (Force SSL) - find the row by text and then get the Radix Checkbox (role="checkbox")
|
||||
const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement;
|
||||
const forceRow = forceLabel.closest('.p-3') as HTMLElement;
|
||||
const { within } = await import('@testing-library/react');
|
||||
// The Radix Checkbox has role="checkbox"
|
||||
const forceCheckbox = within(forceRow).getAllByRole('checkbox')[0];
|
||||
await userEvent.click(forceCheckbox);
|
||||
|
||||
// Click Apply (find the dialog and get the button from the footer)
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Should call updateProxyHost for each selected host with merged payload containing ssl_forced
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
const calls = updateMock.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
expect(calls[0][1]).toHaveProperty('ssl_forced');
|
||||
expect(calls[1][1]).toHaveProperty('ssl_forced');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels bulk apply modal when Cancel clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as backupsApi from '../../api/backups';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateProxyHostACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(),
|
||||
getBackups: vi.fn(),
|
||||
restoreBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
getCertificates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/accessLists', () => ({
|
||||
accessListsApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
testIP: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
createMockProxyHost({ uuid: 'host-3', name: 'Test Host 3', domain_names: 'test3.example.com', forward_host: '192.168.1.30' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Delete with Backup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({});
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({
|
||||
filename: 'backup-2024-01-01-12-00-00.db',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders bulk delete button when hosts are selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using the select-all checkbox (checkboxes[0])
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Bulk delete button should appear in the selection bar
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows confirmation modal when delete button is clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Modal should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should list hosts to be deleted (hosts appear in both table and modal)
|
||||
expect(screen.getAllByText('Test Host 1').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Test Host 2').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Test Host 3').length).toBeGreaterThan(0);
|
||||
|
||||
// Should mention automatic backup
|
||||
expect(screen.getByText(/automatic backup/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates backup before deleting hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons and click delete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should create backup first
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show loading toast
|
||||
expect(toast.loading).toHaveBeenCalledWith('Creating backup before deletion...');
|
||||
|
||||
// Should show success toast with backup filename
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Backup created: backup-2024-01-01-12-00-00.db');
|
||||
});
|
||||
|
||||
// Should then delete the hosts
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes multiple selected hosts after backup', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should create backup first
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should delete all selected hosts
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-1');
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-2');
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('host-3');
|
||||
});
|
||||
|
||||
// Should show success message
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'Successfully deleted 3 host(s). Backup available for restore.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports partial success when some deletions fail', async () => {
|
||||
// Make second deletion fail
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost)
|
||||
.mockResolvedValueOnce() // host-1 succeeds
|
||||
.mockRejectedValueOnce(new Error('Network error')) // host-2 fails
|
||||
.mockResolvedValueOnce(); // host-3 succeeds
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal and confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Wait for backup
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show partial success
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Deleted 2 host(s), 1 failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles backup creation failure', async () => {
|
||||
vi.mocked(backupsApi.createBackup).mockRejectedValue(new Error('Backup failed'));
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal and confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should show error
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Backup failed');
|
||||
});
|
||||
|
||||
// Should NOT delete hosts if backup fails
|
||||
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes modal after successful deletion', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete 3 Proxy Hosts?/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears selection after successful deletion', async () => {
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Should show selection count
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/selected/)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click bulk delete button and confirm (find it via Manage ACL sibling)
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Selection should be cleared - bulk action buttons should disappear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Manage ACL')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables confirm button while creating backup', async () => {
|
||||
// Make backup creation take time
|
||||
vi.mocked(backupsApi.createBackup).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ filename: 'backup.db' }), 100))
|
||||
);
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click confirm delete
|
||||
const confirmButton = screen.getByText('Delete Permanently');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Backup should be called (confirms the button works and backup process starts)
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for deletion to complete to prevent test pollution
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('can cancel deletion from modal', async () => {
|
||||
// Clear mocks to ensure no pollution from previous tests
|
||||
vi.mocked(backupsApi.createBackup).mockClear();
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockClear();
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using select-all checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Wait for bulk action buttons to appear, then click bulk delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage ACL')).toBeTruthy();
|
||||
});
|
||||
const manageACLButton = screen.getByText('Manage ACL');
|
||||
const deleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement;
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Wait for modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete 3 Proxy Hosts?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete 3 Proxy Hosts?/i)).toBeNull();
|
||||
});
|
||||
|
||||
// Should NOT create backup or delete
|
||||
expect(backupsApi.createBackup).not.toHaveBeenCalled();
|
||||
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled();
|
||||
|
||||
// Selection should remain
|
||||
expect(screen.getByText(/selected/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows (all) indicator when all hosts selected for deletion', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Host 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select all hosts using the select-all checkbox
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Should show "(all)" indicator - format is "<strong>3</strong> host(s) selected (all)"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/host\(s\) selected/)).toBeTruthy();
|
||||
expect(screen.getByText(/\(all\)/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
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'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost, Certificate } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
getCertificates: vi.fn(),
|
||||
deleteCertificate: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
|
||||
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false }
|
||||
}
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}) => createMockProxyHost(overrides)
|
||||
|
||||
describe('ProxyHosts - Certificate Cleanup Prompts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([])
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.db' })
|
||||
})
|
||||
|
||||
it('prompts to delete certificate when deleting proxy host with unique custom cert', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'test.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({
|
||||
uuid: 'h1',
|
||||
name: 'Host1',
|
||||
certificate_id: 1,
|
||||
certificate: cert
|
||||
})
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.deleteCertificate).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears - "Delete Proxy Host?" confirmation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Proxy Host?')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Click "Delete" in the confirmation dialog to proceed
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Now Certificate cleanup dialog should appear (custom modal, not Radix)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/orphaned certificate/i)).toBeTruthy()
|
||||
expect(screen.getByText('CustomCert')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find the native checkbox by id="delete_certs"
|
||||
const checkbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
expect(checkbox).toBeTruthy()
|
||||
expect(checkbox.checked).toBe(false)
|
||||
|
||||
// Check the checkbox to delete certificate
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
// Confirm deletion in the CertificateCleanupDialog
|
||||
const submitButton = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(submitButton[submitButton.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT prompt for certificate deletion when cert is shared by multiple hosts', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'SharedCert',
|
||||
domains: 'shared.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteButtons[0])
|
||||
|
||||
// Should show standard confirmation dialog (not cert cleanup)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Proxy Host\?/)).toBeTruthy()
|
||||
})
|
||||
|
||||
// There should NOT be an orphaned certificate checkbox since cert is still used by Host2
|
||||
expect(screen.queryByText(/orphaned certificate/i)).toBeNull()
|
||||
|
||||
// Click Delete to confirm
|
||||
const confirmDeleteBtn = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDeleteBtn[confirmDeleteBtn.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
})
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT prompt for production Let\'s Encrypt certificates', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'letsencrypt',
|
||||
name: 'LE Prod',
|
||||
domains: 'prod.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Should show standard confirmation dialog (not cert cleanup with orphan checkbox)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Proxy Host\?/)).toBeTruthy()
|
||||
})
|
||||
|
||||
// There should NOT be an orphaned certificate option for production Let's Encrypt
|
||||
expect(screen.queryByText(/orphaned certificate/i)).toBeNull()
|
||||
|
||||
// Click Delete to confirm
|
||||
const confirmDeleteBtn = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDeleteBtn[confirmDeleteBtn.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
})
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts for staging certificates', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'letsencrypt-staging',
|
||||
name: 'Staging Cert',
|
||||
domains: 'staging.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears - "Delete Proxy Host?" confirmation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete Proxy Host?')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Click "Delete" in the confirmation dialog to proceed
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Certificate cleanup dialog should appear for staging certs
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/orphaned certificate/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
// Decline certificate deletion (click Delete without checking the box)
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles certificate deletion failure gracefully', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'custom.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.deleteCertificate).mockRejectedValue(
|
||||
new Error('Certificate is still in use')
|
||||
)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears
|
||||
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
|
||||
|
||||
// Click "Delete" in the confirmation dialog
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Certificate cleanup dialog should appear
|
||||
await waitFor(() => expect(screen.getByText(/orphaned certificate/i)).toBeTruthy())
|
||||
|
||||
// Check the certificate deletion checkbox
|
||||
const checkbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
// Confirm deletion
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
// Toast should show error about certificate but host was deleted
|
||||
const toast = await import('react-hot-toast')
|
||||
await waitFor(() => {
|
||||
expect(toast.toast.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to delete certificate')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('bulk delete prompts for orphaned certificates', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'BulkCert',
|
||||
domains: 'bulk.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.deleteCertificate).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Select all hosts
|
||||
const selectAllCheckbox = screen.getByLabelText('Select all rows')
|
||||
await userEvent.click(selectAllCheckbox)
|
||||
|
||||
// Click bulk delete button (the delete button in the toolbar, after Manage ACL)
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
const manageACLButton = screen.getByText('Manage ACL')
|
||||
const bulkDeleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement
|
||||
await userEvent.click(bulkDeleteButton)
|
||||
|
||||
// Confirm in bulk delete modal - text uses pluralized form "Proxy Host(s)"
|
||||
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Host/)).toBeTruthy())
|
||||
const deletePermBtn = screen.getByRole('button', { name: /Delete Permanently/i })
|
||||
await userEvent.click(deletePermBtn)
|
||||
|
||||
// Should show certificate cleanup dialog (both hosts use same cert, deleting both = orphaned)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/orphaned certificate/i)).toBeTruthy()
|
||||
expect(screen.getByText('BulkCert')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Check the certificate deletion checkbox
|
||||
const certCheckbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
await userEvent.click(certCheckbox)
|
||||
|
||||
// Confirm
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2')
|
||||
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('bulk delete does NOT prompt when certificate is still used by other hosts', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'SharedCert',
|
||||
domains: 'shared.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
|
||||
const host3 = baseHost({ uuid: 'h3', name: 'Host3', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2, host3])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Select only host1 and host2 (host3 still uses the cert)
|
||||
const host1Row = screen.getByText('Host1').closest('tr') as HTMLTableRowElement
|
||||
const host2Row = screen.getByText('Host2').closest('tr') as HTMLTableRowElement
|
||||
// Get the Radix Checkbox in each row (first checkbox, not the Switch which is input[type=checkbox].sr-only)
|
||||
const host1Checkbox = within(host1Row).getByLabelText(/Select row h1/)
|
||||
const host2Checkbox = within(host2Row).getByLabelText(/Select row h2/)
|
||||
|
||||
await userEvent.click(host1Checkbox)
|
||||
await userEvent.click(host2Checkbox)
|
||||
|
||||
// Wait for bulk operations to be available
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy())
|
||||
|
||||
// Click bulk delete - find the delete button in the toolbar (after Manage ACL)
|
||||
const manageACLButton = screen.getByText('Manage ACL')
|
||||
const bulkDeleteButton = manageACLButton.parentElement?.querySelector('button:last-child') as HTMLButtonElement
|
||||
await userEvent.click(bulkDeleteButton)
|
||||
|
||||
// Confirm in modal - text uses pluralized form "Proxy Host(s)"
|
||||
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Host/)).toBeTruthy())
|
||||
const deletePermBtn = screen.getByRole('button', { name: /Delete Permanently/i })
|
||||
await userEvent.click(deletePermBtn)
|
||||
|
||||
// Should NOT show certificate cleanup dialog (host3 still uses it)
|
||||
// It will directly delete without showing the orphaned cert dialog
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2')
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('allows cancelling certificate cleanup dialog', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'test.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Certificate cleanup dialog appears
|
||||
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
|
||||
|
||||
// Click Cancel
|
||||
const cancelBtn = screen.getByRole('button', { name: 'Cancel' })
|
||||
await userEvent.click(cancelBtn)
|
||||
|
||||
// Dialog should close, nothing deleted
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Proxy Host?')).toBeFalsy()
|
||||
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled()
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('default state is unchecked for certificate deletion (conservative)', async () => {
|
||||
const cert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
provider: 'custom',
|
||||
name: 'CustomCert',
|
||||
domains: 'test.com',
|
||||
expires_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
|
||||
|
||||
// Click row delete button
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// First dialog appears
|
||||
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
|
||||
|
||||
// Click "Delete" in the confirmation dialog
|
||||
const confirmDelete = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(confirmDelete[confirmDelete.length - 1])
|
||||
|
||||
// Certificate cleanup dialog should appear
|
||||
await waitFor(() => expect(screen.getByText(/orphaned certificate/i)).toBeTruthy())
|
||||
|
||||
// Checkbox should be unchecked by default
|
||||
const checkbox = document.getElementById('delete_certs') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
|
||||
// Confirm deletion without checking the box
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await userEvent.click(deleteButtons[deleteButtons.length - 1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
|
||||
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// We'll use per-test module mocks via `vi.doMock` and dynamic imports to avoid
|
||||
// leaking mocks into other tests. Each test creates its own QueryClient.
|
||||
|
||||
describe('ProxyHosts page - coverage targets (isolated)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const renderPage = async () => {
|
||||
// Dynamic mocks
|
||||
const mockUpdateHost = vi.fn()
|
||||
|
||||
vi.doMock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(() => ({
|
||||
hosts: [
|
||||
{
|
||||
uuid: 'host-1',
|
||||
name: 'StagingHost',
|
||||
domain_names: 'staging.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '10.0.0.1',
|
||||
forward_port: 80,
|
||||
ssl_forced: true,
|
||||
websocket_support: true,
|
||||
certificate: undefined,
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
uuid: 'host-2',
|
||||
name: 'CustomCertHost',
|
||||
domain_names: 'custom.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '10.0.0.2',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
certificate: { provider: 'custom', name: 'ACME-CUSTOM' },
|
||||
enabled: false,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => mockUpdateHost(uuid, data),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
isBulkUpdating: false,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.doMock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
|
||||
vi.doMock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null }))
|
||||
}))
|
||||
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({ 'ui.domain_link_behavior': 'new_window' })) }))
|
||||
|
||||
// Import page after mocks are in place
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = (ui: React.ReactNode) => (
|
||||
<QueryClientProvider client={qc}>{ui}</QueryClientProvider>
|
||||
)
|
||||
|
||||
return { ProxyHosts, mockUpdateHost, wrapper }
|
||||
}
|
||||
|
||||
it('renders SSL staging badge, websocket badge', async () => {
|
||||
const { ProxyHosts } = await renderPage()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument())
|
||||
|
||||
// Staging badge shows "Staging" text
|
||||
expect(screen.getByText('Staging')).toBeInTheDocument()
|
||||
// Websocket badge shows "WS"
|
||||
expect(screen.getByText('WS')).toBeInTheDocument()
|
||||
// Custom cert hosts don't show the cert name in the table - just check the host is shown
|
||||
expect(screen.getByText('CustomCertHost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens domain link in new window when linkBehavior is new_window', async () => {
|
||||
const { ProxyHosts } = await renderPage()
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('staging.example.com')).toBeInTheDocument())
|
||||
const link = screen.getByText('staging.example.com').closest('a') as HTMLAnchorElement
|
||||
await act(async () => {
|
||||
await userEvent.click(link!)
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('bulk apply merges host data and calls updateHost', async () => {
|
||||
const { ProxyHosts, mockUpdateHost } = await renderPage()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument())
|
||||
|
||||
// Select hosts by finding rows and clicking first checkbox (selection)
|
||||
const row1 = screen.getByText('StagingHost').closest('tr') as HTMLTableRowElement
|
||||
const row2 = screen.getByText('CustomCertHost').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row1).getAllByRole('checkbox')[0])
|
||||
await userEvent.click(within(row2).getAllByRole('checkbox')[0])
|
||||
|
||||
const bulkBtn = screen.getByText('Bulk Apply')
|
||||
await userEvent.click(bulkBtn)
|
||||
|
||||
// Find the modal dialog
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeInTheDocument())
|
||||
|
||||
// The bulk apply modal has checkboxes for each setting - find them by role
|
||||
const modalCheckboxes = screen.getAllByRole('checkbox').filter(
|
||||
cb => cb.closest('[role="dialog"]') !== null
|
||||
)
|
||||
expect(modalCheckboxes.length).toBeGreaterThan(0)
|
||||
// Click the first setting checkbox to enable it
|
||||
await userEvent.click(modalCheckboxes[0])
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /Apply/ })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHost).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const calls = vi.mocked(mockUpdateHost).mock.calls
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
const [calledUuid, calledData] = calls[0]
|
||||
expect(typeof calledUuid).toBe('string')
|
||||
expect(Object.prototype.hasOwnProperty.call(calledData, 'ssl_forced')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,999 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import { formatSettingLabel, settingHelpText, settingKeyToField, applyBulkSettingsToHosts } from '../../utils/proxyHostsHelpers'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
// Certificate type not required in this spec
|
||||
import type { UptimeMonitor } from '../../api/uptime'
|
||||
// toast is mocked in other tests; not used here
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
|
||||
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}) => createMockProxyHost(overrides)
|
||||
|
||||
describe('ProxyHosts - Coverage enhancements', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows empty message when no hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('creates a proxy host via Add Host form submit', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([])
|
||||
vi.mocked(proxyHostsApi.createProxyHost).mockResolvedValue({
|
||||
uuid: 'new1',
|
||||
name: 'NewHost',
|
||||
domain_names: 'new.example.com',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 8080,
|
||||
forward_scheme: 'http',
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as ProxyHost)
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeTruthy())
|
||||
const user = userEvent.setup()
|
||||
// Click the first Add Proxy Host button (in empty state)
|
||||
await user.click(screen.getAllByRole('button', { name: 'Add Proxy Host' })[0])
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Proxy Host' })).toBeTruthy())
|
||||
// Fill name
|
||||
const nameInput = screen.getByLabelText('Name *') as HTMLInputElement
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'NewHost')
|
||||
const domainInput = screen.getByLabelText('Domain Names (comma-separated)') as HTMLInputElement
|
||||
await user.clear(domainInput)
|
||||
await user.type(domainInput, 'new.example.com')
|
||||
// Fill forward host/port to satisfy required fields and save
|
||||
const forwardHost = screen.getByLabelText('Host') as HTMLInputElement
|
||||
await user.clear(forwardHost)
|
||||
await user.type(forwardHost, '127.0.0.1')
|
||||
const forwardPort = screen.getByLabelText('Port') as HTMLInputElement
|
||||
await user.clear(forwardPort)
|
||||
await user.type(forwardPort, '8080')
|
||||
// Save
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(proxyHostsApi.createProxyHost).toHaveBeenCalled())
|
||||
}, 15000)
|
||||
|
||||
it('handles equal sort values gracefully', async () => {
|
||||
const host1 = baseHost({ uuid: 'e1', name: 'Same', domain_names: 'a.example.com' })
|
||||
const host2 = baseHost({ uuid: 'e2', name: 'Same', domain_names: 'b.example.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getAllByText('Same').length).toBeGreaterThanOrEqual(2))
|
||||
// Sort by name (they are equal) should not throw and maintain rows
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByText('Name'))
|
||||
await waitFor(() => expect(screen.getAllByText('Same').length).toBeGreaterThanOrEqual(2))
|
||||
})
|
||||
|
||||
it('toggle select-all deselects when clicked twice', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
// Click select all header checkbox (has aria-label="Select all rows")
|
||||
const user = userEvent.setup()
|
||||
const selectAllBtn = screen.getByLabelText('Select all rows')
|
||||
await user.click(selectAllBtn)
|
||||
// Wait for selection UI to appear - text format includes "<strong>2</strong> host(s) selected (all)"
|
||||
await waitFor(() => expect(screen.getByText(/host\(s\) selected/)).toBeTruthy())
|
||||
// Also check for "(all)" indicator
|
||||
expect(screen.getByText(/\(all\)/)).toBeTruthy()
|
||||
// Click again to deselect
|
||||
await user.click(selectAllBtn)
|
||||
await waitFor(() => expect(screen.queryByText(/\(all\)/)).toBeNull())
|
||||
})
|
||||
|
||||
it('bulk update ACL reject triggers error toast', async () => {
|
||||
const host = baseHost({ uuid: 'b1', name: 'BHost' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bad things'))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('BHost')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
const label = screen.getByText('List1').closest('label') as HTMLElement
|
||||
// Radix Checkbox - query by role, not native input
|
||||
const checkbox = within(label).getByRole('checkbox')
|
||||
await user.click(checkbox)
|
||||
const applyBtn = await screen.findByRole('button', { name: /Apply\s*\(/i })
|
||||
await act(async () => {
|
||||
await user.click(applyBtn)
|
||||
})
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('switch toggles from disabled to enabled and calls API', async () => {
|
||||
const host = baseHost({ uuid: 'sw1', name: 'SwitchHost', enabled: false })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({ ...host, enabled: true })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('SwitchHost')).toBeTruthy())
|
||||
const row = screen.getByText('SwitchHost').closest('tr') as HTMLTableRowElement
|
||||
// Switch component uses a label wrapping a hidden checkbox - find the label and click it
|
||||
const switchLabel = row.querySelector('label.cursor-pointer') as HTMLElement
|
||||
const user = userEvent.setup()
|
||||
await user.click(switchLabel)
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalledWith('sw1', { enabled: true }))
|
||||
})
|
||||
|
||||
it('sorts hosts by column and toggles order indicator', async () => {
|
||||
const h1 = baseHost({ uuid: '1', name: 'aaa', domain_names: 'b.com' })
|
||||
const h2 = baseHost({ uuid: '2', name: 'zzz', domain_names: 'a.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('aaa')).toBeTruthy())
|
||||
|
||||
// Check both hosts are rendered
|
||||
expect(screen.getByText('aaa')).toBeTruthy()
|
||||
expect(screen.getByText('zzz')).toBeTruthy()
|
||||
|
||||
// Click domain header - should show sorting indicator
|
||||
const domainHeader = screen.getByText('Domain')
|
||||
const user = userEvent.setup()
|
||||
await user.click(domainHeader)
|
||||
|
||||
// After clicking domain header, the header should have aria-sort attribute
|
||||
await waitFor(() => {
|
||||
const th = domainHeader.closest('th')
|
||||
expect(th?.getAttribute('aria-sort')).toBe('ascending')
|
||||
})
|
||||
|
||||
// Click again to toggle to descending
|
||||
await user.click(domainHeader)
|
||||
await waitFor(() => {
|
||||
const th = domainHeader.closest('th')
|
||||
expect(th?.getAttribute('aria-sort')).toBe('descending')
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles row selection checkbox and shows checked state', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
|
||||
const row = screen.getByText('S1').closest('tr') as HTMLTableRowElement
|
||||
const selectBtn = within(row).getAllByRole('checkbox')[0]
|
||||
// Initially unchecked
|
||||
expect(selectBtn.getAttribute('aria-checked')).toBe('false')
|
||||
const user = userEvent.setup()
|
||||
await user.click(selectBtn)
|
||||
await waitFor(() => expect(selectBtn.getAttribute('aria-checked')).toBe('true'))
|
||||
await user.click(selectBtn)
|
||||
await waitFor(() => expect(selectBtn.getAttribute('aria-checked')).toBe('false'))
|
||||
})
|
||||
|
||||
it('closes bulk ACL modal when clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist', enabled: true, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('Apply Access List')).toBeTruthy())
|
||||
|
||||
// click backdrop (outer overlay) to close
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await user.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText('Apply Access List')).toBeNull())
|
||||
})
|
||||
|
||||
it('unchecks ACL via onChange (delete path)', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
const label = screen.getByText('List1').closest('label') as HTMLLabelElement
|
||||
// Radix Checkbox - query by role, not native input
|
||||
const checkbox = within(label).getByRole('checkbox')
|
||||
// initially unchecked via clear, click to check
|
||||
await user.click(checkbox)
|
||||
await waitFor(() => expect(checkbox.getAttribute('aria-checked')).toBe('true'))
|
||||
// click again to uncheck and hit delete path in onChange
|
||||
await user.click(checkbox)
|
||||
await waitFor(() => expect(checkbox.getAttribute('aria-checked')).toBe('false'))
|
||||
})
|
||||
|
||||
it('remove action triggers handleBulkApplyACL and shows removed toast', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
// Toggle to Remove ACL
|
||||
await user.click(screen.getByText('Remove ACL'))
|
||||
// Click the action button (Remove ACL) - it's the primary action (bg-red)
|
||||
const actionBtn = screen.getAllByRole('button', { name: 'Remove ACL' }).pop()
|
||||
if (actionBtn) await user.click(actionBtn)
|
||||
await waitFor(() => expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(['s1', 's2'], null))
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('toggle action remove -> apply then back', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('Apply ACL')).toBeTruthy())
|
||||
// Click Remove, then Apply to hit setBulkACLAction('apply')
|
||||
// Toggle Remove (header toggle) and back to Apply (header toggle)
|
||||
const headerToggles = screen.getAllByRole('button')
|
||||
const removeToggle = headerToggles.find(btn => btn.textContent === 'Remove ACL' && btn.className.includes('flex-1'))
|
||||
const applyToggle = headerToggles.find(btn => btn.textContent === 'Apply ACL' && btn.className.includes('flex-1'))
|
||||
if (removeToggle) await user.click(removeToggle)
|
||||
await waitFor(() => expect(removeToggle).toBeTruthy())
|
||||
if (applyToggle) await user.click(applyToggle)
|
||||
await waitFor(() => expect(applyToggle).toBeTruthy())
|
||||
})
|
||||
|
||||
it('remove action shows partial failure toast on API error result', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 1, errors: [{ uuid: 's2', error: 'Bad' }] })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(chk)
|
||||
await user.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Remove ACL'))
|
||||
const actionBtn = screen.getAllByRole('button', { name: 'Remove ACL' }).pop()
|
||||
if (actionBtn) await userEvent.click(actionBtn)
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('remove action reject triggers error toast', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bulk fail'))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const chk = screen.getAllByRole('checkbox')[0]
|
||||
await userEvent.click(chk)
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('List1')).toBeTruthy())
|
||||
// Toggle Remove mode
|
||||
await userEvent.click(screen.getByText('Remove ACL'))
|
||||
const actionBtn = screen.getAllByRole('button', { name: 'Remove ACL' }).pop()
|
||||
if (actionBtn) await userEvent.click(actionBtn)
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('close bulk delete modal by clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getByLabelText('Select all rows')
|
||||
await userEvent.click(headerCheckbox)
|
||||
// Wait for selection bar to appear and find the delete button - text format is "host(s) selected"
|
||||
await waitFor(() => expect(screen.getByText(/host\(s\) selected/)).toBeTruthy())
|
||||
// Click the bulk Delete button (with bg-error class) - there are multiple Delete buttons, get the one in selection bar
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /Delete/ })
|
||||
// The bulk delete button has bg-error class
|
||||
const bulkDeleteBtn = deleteButtons.find(btn => btn.classList.contains('bg-error'))
|
||||
await userEvent.click(bulkDeleteBtn!)
|
||||
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Hosts?/i)).toBeTruthy())
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await userEvent.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText(/Delete 2 Proxy Hosts?/i)).toBeNull())
|
||||
})
|
||||
|
||||
it('calls window.open when settings link behavior new_window', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([baseHost({ uuid: '1', name: 'One' })])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'new_window' })
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
||||
const anchor = screen.getByRole('link', { name: /(test1\.example\.com|example\.com|One)/i })
|
||||
await userEvent.click(anchor)
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses same_tab target for domain links when configured', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([baseHost({ uuid: '1', name: 'One' })])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'same_tab' })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
||||
const anchor = screen.getByRole('link', { name: /(example\.com|One)/i })
|
||||
// Anchor should render with target _self when same_tab
|
||||
expect(anchor.getAttribute('target')).toBe('_self')
|
||||
})
|
||||
|
||||
it('renders SSL states: custom, staging, letsencrypt variations', async () => {
|
||||
const hostCustom = baseHost({ uuid: 'c1', name: 'CustomHost', domain_names: 'custom.com', ssl_forced: true, certificate: { id: 123, uuid: 'cert-1', name: 'CustomCert', provider: 'custom', domains: 'custom.com', expires_at: '2026-01-01' } })
|
||||
const hostStaging = baseHost({ uuid: 's1', name: 'StagingHost', domain_names: 'staging.com', ssl_forced: true })
|
||||
const hostAuto = baseHost({ uuid: 'a1', name: 'AutoHost', domain_names: 'auto.com', ssl_forced: true })
|
||||
const hostLets = baseHost({ uuid: 'l1', name: 'LetsHost', domain_names: 'lets.com', ssl_forced: true })
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([
|
||||
{ domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
|
||||
{ domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
|
||||
])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('CustomHost')).toBeTruthy())
|
||||
|
||||
// Custom Cert - just verify the host renders
|
||||
expect(screen.getByText('CustomHost')).toBeTruthy()
|
||||
|
||||
// Staging host should show staging badge text (just "Staging" in Badge)
|
||||
expect(screen.getByText('StagingHost')).toBeTruthy()
|
||||
// The SSL badge for staging hosts shows "Staging" text
|
||||
const stagingBadges = screen.getAllByText('Staging')
|
||||
expect(stagingBadges.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// SSL badges are shown for valid certs
|
||||
const sslBadges = screen.getAllByText('SSL')
|
||||
expect(sslBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders multiple domains and websocket label', async () => {
|
||||
const host = baseHost({ uuid: 'multi1', name: 'Multi', domain_names: 'one.com,two.com,three.com', websocket_support: true })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Multi')).toBeTruthy())
|
||||
// Check multiple domain anchors; parse anchor hrefs instead of substring checks
|
||||
const anchors = screen.getAllByRole('link')
|
||||
const anchorHasHost = (el: Element | null, host: string) => {
|
||||
if (!el) return false
|
||||
const href = el.getAttribute('href') || ''
|
||||
try {
|
||||
// Use base to resolve relative URLs
|
||||
const parsed = new URL(href, 'http://localhost')
|
||||
return parsed.host === host
|
||||
} catch {
|
||||
return el.textContent?.includes(host) ?? false
|
||||
}
|
||||
}
|
||||
expect(anchors.some(a => anchorHasHost(a, 'one.com'))).toBeTruthy()
|
||||
expect(anchors.some(a => anchorHasHost(a, 'two.com'))).toBeTruthy()
|
||||
expect(anchors.some(a => anchorHasHost(a, 'three.com'))).toBeTruthy()
|
||||
// Check websocket label exists since websocket_support true
|
||||
expect(screen.getByText('WS')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles delete confirmation for a single host', async () => {
|
||||
const host = baseHost({ uuid: 'del1', name: 'Del', domain_names: 'del.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del')).toBeTruthy())
|
||||
// Click Delete button in the row
|
||||
const editButton = screen.getByText('Edit')
|
||||
const row = editButton.closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del1'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes associated uptime monitors when confirmed', async () => {
|
||||
const host = baseHost({ uuid: 'del2', name: 'Del2', forward_host: '127.0.0.5' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// uptime monitors associated with host
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([{ id: 'm1', name: 'm1', url: 'http://example', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, upstream_host: '127.0.0.5' } as UptimeMonitor])
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del2')).toBeTruthy())
|
||||
const row = screen.getByText('Del2').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
// Should call delete with deleteUptime true
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del2', true))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores uptime API errors and deletes host without deleting uptime', async () => {
|
||||
const host = baseHost({ uuid: 'del3', name: 'Del3', forward_host: '127.0.0.6' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// Make getMonitors throw
|
||||
vi.mocked(uptimeApi.getMonitors).mockRejectedValue(new Error('OOPS'))
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del3')).toBeTruthy())
|
||||
const row = screen.getByText('Del3').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
// Should call delete without second param
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del3'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('applies bulk settings sequentially with progress and updates hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 'host-1', name: 'H1' }),
|
||||
baseHost({ uuid: 'host-2', name: 'H2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({} as ProxyHost)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select both hosts
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
await userEvent.click(headerCheckbox)
|
||||
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
|
||||
// In the modal, find Force SSL row and enable apply and set value true
|
||||
const forceLabel = screen.getByText('Force SSL')
|
||||
// The row has class p-3 not p-2, and we need to get the parent flex container
|
||||
const rowEl = forceLabel.closest('.p-3') as HTMLElement || forceLabel.closest('div')?.parentElement as HTMLElement
|
||||
// Find the Radix checkbox (has role="checkbox" and is a button) and the switch (label with input)
|
||||
const allCheckboxes = within(rowEl).getAllByRole('checkbox')
|
||||
// First checkbox is the Radix Checkbox for "apply"
|
||||
const applyCheckbox = allCheckboxes[0]
|
||||
await userEvent.click(applyCheckbox)
|
||||
|
||||
// Click Apply in the modal - find button within the dialog
|
||||
const applyBtn = screen.getByRole('button', { name: /^Apply$/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Expect updateProxyHost called for each host with ssl_forced true included in payload
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalledTimes(2))
|
||||
const calls = vi.mocked(proxyHostsApi.updateProxyHost).mock.calls
|
||||
expect(calls.some(call => call[1] && (call[1] as Partial<ProxyHost>).ssl_forced === true)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Unnamed when name missing', async () => {
|
||||
const hostNoName = baseHost({ uuid: 'n1', name: '', domain_names: 'no-name.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostNoName])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Unnamed')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('toggles host enable state via Switch', async () => {
|
||||
const host = baseHost({ uuid: 't1', name: 'Toggle', enabled: true })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue(baseHost({ uuid: 't1', name: 'Toggle', enabled: true }))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Toggle')).toBeTruthy())
|
||||
// Locate the row and toggle the enabled switch - it's inside a label with cursor-pointer class
|
||||
const row = screen.getByText('Toggle').closest('tr') as HTMLTableRowElement
|
||||
// Switch component uses a label wrapping a hidden checkbox
|
||||
const switchLabel = row.querySelector('label.cursor-pointer') as HTMLElement
|
||||
expect(switchLabel).toBeTruthy()
|
||||
await userEvent.click(switchLabel)
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('opens add form and cancels', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Create your first proxy host/)).toBeTruthy())
|
||||
// Click the first Add Proxy Host button (in empty state)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Add Proxy Host' })[0])
|
||||
// Form should open with Add Proxy Host header
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Proxy Host' })).toBeTruthy())
|
||||
// Click Cancel should close the form
|
||||
const cancelButton = screen.getByText('Cancel')
|
||||
await userEvent.click(cancelButton)
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Add Proxy Host' })).toBeNull())
|
||||
})
|
||||
|
||||
it('opens edit form and submits update', async () => {
|
||||
const host = baseHost({ uuid: 'edit1', name: 'EditMe' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({ ...host, name: 'Edited' })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('EditMe')).toBeTruthy())
|
||||
const editBtn = screen.getByText('Edit')
|
||||
await userEvent.click(editBtn)
|
||||
|
||||
// Form header should show Edit Proxy Host
|
||||
await waitFor(() => expect(screen.getByText('Edit Proxy Host')).toBeTruthy())
|
||||
// Change name and click Save
|
||||
const nameInput = screen.getByLabelText('Name *') as HTMLInputElement
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'Edited')
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('alerts on delete when API fails', async () => {
|
||||
const host = baseHost({ uuid: 'delerr', name: 'DelErr' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockRejectedValue(new Error('Boom'))
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('DelErr')).toBeTruthy())
|
||||
const row = screen.getByText('DelErr').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Confirm in dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeTruthy())
|
||||
const confirmBtn = screen.getAllByRole('button', { name: 'Delete' }).pop()!
|
||||
await userEvent.click(confirmBtn)
|
||||
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts by domain and forward columns', async () => {
|
||||
const h1 = baseHost({ uuid: 'd1', name: 'A', domain_names: 'b.com', forward_host: 'foo' , forward_port: 8080 })
|
||||
const h2 = baseHost({ uuid: 'd2', name: 'B', domain_names: 'a.com', forward_host: 'bar' , forward_port: 80 })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeTruthy())
|
||||
|
||||
// Domain sort
|
||||
await userEvent.click(screen.getByText('Domain'))
|
||||
await waitFor(() => expect(screen.getByText('B')).toBeTruthy()) // domain 'a.com' should appear first
|
||||
|
||||
// Forward sort: toggle to change order
|
||||
await userEvent.click(screen.getByText('Forward To'))
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('applies multiple ACLs sequentially with progress', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 'host-1', name: 'H1' }),
|
||||
baseHost({ uuid: 'host-2', name: 'H2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-a1', name: 'A1', description: 'A1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-a2', name: 'A2', description: 'A2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] })
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select all hosts
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
|
||||
// Open Manage ACL
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
await waitFor(() => expect(screen.getByText('A1')).toBeTruthy())
|
||||
|
||||
// Select both ACLs
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox')
|
||||
const checkA1 = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('A1'))
|
||||
const checkA2 = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('A2'))
|
||||
if (checkA1) await userEvent.click(checkA1)
|
||||
if (checkA2) await userEvent.click(checkA2)
|
||||
|
||||
// Click Apply
|
||||
const applyBtn = screen.getByRole('button', { name: /Apply \(2\)/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Should call bulkUpdateACL twice and show success
|
||||
await waitFor(() => expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('select all / clear header selects and clears ACLs', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
baseHost({ uuid: 's2', name: 'S2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-2', name: 'List2', description: 'List 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
// Click Select All in modal
|
||||
const selectAllBtn = await screen.findByText('Select All')
|
||||
await userEvent.click(selectAllBtn)
|
||||
// All ACL checkboxes (Radix Checkbox) inside labels should be checked - check via aria-checked
|
||||
const labelEl1 = screen.getByText('List1').closest('label') as HTMLElement
|
||||
const labelEl2 = screen.getByText('List2').closest('label') as HTMLElement
|
||||
const checkbox1 = within(labelEl1).getByRole('checkbox')
|
||||
const checkbox2 = within(labelEl2).getByRole('checkbox')
|
||||
expect(checkbox1.getAttribute('aria-checked')).toBe('true')
|
||||
expect(checkbox2.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
// Click Clear
|
||||
const clearBtn = await screen.findByText('Clear')
|
||||
await userEvent.click(clearBtn)
|
||||
expect(checkbox1.getAttribute('aria-checked')).toBe('false')
|
||||
expect(checkbox2.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows no enabled access lists message when none are enabled', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' })
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-disable1', name: 'Disabled1', description: 'Disabled 1', type: 'blacklist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-disable2', name: 'Disabled2', description: 'Disabled 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
// Should show the 'No enabled access lists available' message
|
||||
await waitFor(() => expect(screen.getByText('No enabled access lists available')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('formatSettingLabel, settingHelpText and settingKeyToField return expected values and defaults', () => {
|
||||
expect(formatSettingLabel('ssl_forced')).toBe('Force SSL')
|
||||
expect(formatSettingLabel('http2_support')).toBe('HTTP/2 Support')
|
||||
expect(formatSettingLabel('hsts_enabled')).toBe('HSTS Enabled')
|
||||
expect(formatSettingLabel('hsts_subdomains')).toBe('HSTS Subdomains')
|
||||
expect(formatSettingLabel('block_exploits')).toBe('Block Exploits')
|
||||
expect(formatSettingLabel('websocket_support')).toBe('Websockets Support')
|
||||
expect(formatSettingLabel('unknown_key')).toBe('unknown_key')
|
||||
|
||||
expect(settingHelpText('ssl_forced')).toContain('Redirect all HTTP traffic')
|
||||
expect(settingHelpText('http2_support')).toContain('Enable HTTP/2')
|
||||
expect(settingHelpText('hsts_enabled')).toContain('Send HSTS header')
|
||||
expect(settingHelpText('hsts_subdomains')).toContain('Include subdomains')
|
||||
expect(settingHelpText('block_exploits')).toContain('Add common exploit-mitigation')
|
||||
expect(settingHelpText('websocket_support')).toContain('Enable websocket proxying')
|
||||
expect(settingHelpText('unknown_key')).toBe('')
|
||||
|
||||
expect(settingKeyToField('ssl_forced')).toBe('ssl_forced')
|
||||
expect(settingKeyToField('http2_support')).toBe('http2_support')
|
||||
expect(settingKeyToField('hsts_enabled')).toBe('hsts_enabled')
|
||||
expect(settingKeyToField('hsts_subdomains')).toBe('hsts_subdomains')
|
||||
expect(settingKeyToField('block_exploits')).toBe('block_exploits')
|
||||
expect(settingKeyToField('websocket_support')).toBe('websocket_support')
|
||||
expect(settingKeyToField('unknown_key')).toBe('unknown_key')
|
||||
})
|
||||
|
||||
it('closes bulk apply modal when clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
// click backdrop
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await user.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull())
|
||||
})
|
||||
|
||||
it('shows toast error when updateHost rejects during bulk apply', async () => {
|
||||
const h1 = baseHost({ uuid: 'host-1', name: 'H1' })
|
||||
const h2 = baseHost({ uuid: 'host-2', name: 'H2' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// mock updateProxyHost to fail for host-2
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockImplementation(async (uuid: string) => {
|
||||
if (uuid === 'host-2') throw new Error('update fail')
|
||||
const result = baseHost({ uuid })
|
||||
return result
|
||||
})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
// select both
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
// Open Bulk Apply
|
||||
await user.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
// enable Force SSL apply + set switch
|
||||
const forceLabel = screen.getByText('Force SSL')
|
||||
// The row has class p-3 not p-2, and we need to get the parent flex container
|
||||
const rowEl = forceLabel.closest('.p-3') as HTMLElement || forceLabel.closest('div')?.parentElement as HTMLElement
|
||||
// Find the Radix checkbox (has role="checkbox" and is a button) and the switch (label with input)
|
||||
const allCheckboxes = within(rowEl).getAllByRole('checkbox')
|
||||
// First checkbox is the Radix Checkbox for "apply", second is the switch's internal checkbox
|
||||
const applyCheckbox = allCheckboxes[0]
|
||||
await user.click(applyCheckbox)
|
||||
// Toggle the switch - click the label containing the checkbox
|
||||
const switchLabel = rowEl.querySelector('label.relative') as HTMLElement
|
||||
if (switchLabel) await user.click(switchLabel)
|
||||
// click Apply - find button within the dialog
|
||||
const applyBtn = screen.getByRole('button', { name: /^Apply$/i })
|
||||
await user.click(applyBtn)
|
||||
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('applyBulkSettingsToHosts returns error when host is not found and reports progress', async () => {
|
||||
const hosts: ProxyHost[] = [] // no hosts
|
||||
const hostUUIDs = ['missing-1']
|
||||
const keysToApply = ['ssl_forced']
|
||||
const bulkApplySettings: Record<string, { apply: boolean; value: boolean }> = { ssl_forced: { apply: true, value: true } }
|
||||
const updateHost = vi.fn().mockResolvedValue({})
|
||||
const setApplyProgress = vi.fn()
|
||||
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
expect(result.errors).toBe(1)
|
||||
expect(setApplyProgress).toHaveBeenCalled()
|
||||
expect(updateHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applyBulkSettingsToHosts handles updateHost rejection and counts errors', async () => {
|
||||
const h1 = baseHost({ uuid: 'h1', name: 'H1' })
|
||||
const hosts = [h1]
|
||||
const hostUUIDs = ['h1']
|
||||
const keysToApply = ['ssl_forced']
|
||||
const bulkApplySettings: Record<string, { apply: boolean; value: boolean }> = { ssl_forced: { apply: true, value: true } }
|
||||
const updateHost = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
const setApplyProgress = vi.fn()
|
||||
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
expect(result.errors).toBe(1)
|
||||
expect(updateHost).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import type { UptimeMonitor } from '../../api/uptime'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import { useProxyHosts } from '../../hooks/useProxyHosts'
|
||||
import { useCertificates } from '../../hooks/useCertificates'
|
||||
import { useAccessLists } from '../../hooks/useAccessLists'
|
||||
import { getSettings } from '../../api/settings'
|
||||
import { getMonitors } from '../../api/uptime'
|
||||
import { createBackup } from '../../api/backups'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn() }))
|
||||
vi.mock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn() }))
|
||||
vi.mock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn() }))
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
|
||||
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
// Helper to create QueryClient provider wrapper
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
type ProxyHostsHookValue = ReturnType<typeof useProxyHosts>
|
||||
type CertificatesHookValue = ReturnType<typeof useCertificates>
|
||||
type AccessListsHookValue = ReturnType<typeof useAccessLists>
|
||||
|
||||
const createProxyHostsHookValue = (overrides: Partial<ProxyHostsHookValue> = {}): ProxyHostsHookValue => ({
|
||||
hosts: [],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn() as unknown as ProxyHostsHookValue['createHost'],
|
||||
updateHost: vi.fn() as unknown as ProxyHostsHookValue['updateHost'],
|
||||
deleteHost: vi.fn() as unknown as ProxyHostsHookValue['deleteHost'],
|
||||
bulkUpdateACL: vi.fn() as unknown as ProxyHostsHookValue['bulkUpdateACL'],
|
||||
bulkUpdateSecurityHeaders: vi.fn() as unknown as ProxyHostsHookValue['bulkUpdateSecurityHeaders'],
|
||||
isCreating: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
isBulkUpdating: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCertificatesHookValue = (overrides: Partial<CertificatesHookValue> = {}): CertificatesHookValue => ({
|
||||
certificates: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn() as unknown as CertificatesHookValue['refetch'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createAccessListsHookValue = (data: unknown = [], overrides: Partial<AccessListsHookValue> = {}): AccessListsHookValue =>
|
||||
({
|
||||
data,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
} as unknown as AccessListsHookValue)
|
||||
|
||||
const sampleHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'h1',
|
||||
name: 'A Name',
|
||||
domain_names: 'a.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
enabled: true,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProxyHosts page extra tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue())
|
||||
vi.mocked(useCertificates).mockReturnValue(createCertificatesHookValue())
|
||||
vi.mocked(useAccessLists).mockReturnValue(createAccessListsHookValue([]))
|
||||
vi.mocked(getSettings).mockResolvedValue({})
|
||||
vi.mocked(getMonitors).mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('shows "No proxy hosts configured" when no hosts', async () => {
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
// Translation mock returns English text; tolerate fallback key string too.
|
||||
expect(await screen.findByText(/Create your first proxy host|proxyHosts\.noHostsDescription/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sort toggles by header click', async () => {
|
||||
const h1 = sampleHost({ uuid: 'a', name: 'Alpha' })
|
||||
const h2 = sampleHost({ uuid: 'b', name: 'Beta' })
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [h2, h1] }))
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
// hosts are sorted by name by default (Alpha before Beta) by the component
|
||||
await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument())
|
||||
|
||||
const table = screen.getAllByRole('table')[0]
|
||||
const nameHeader = within(table).getAllByRole('button', { name: 'Name' })[0]
|
||||
// Click header - this only toggles the sort indicator icon, not actual data order
|
||||
// since the component pre-sorts data before passing to DataTable
|
||||
await userEvent.click(nameHeader)
|
||||
|
||||
// Verify that both hosts are still displayed (basic sanity check)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
|
||||
// Verify the sort indicator changes (chevron icon should toggle)
|
||||
expect(table).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('delete with associated monitors prompts and deletes with deleteUptime true', async () => {
|
||||
const host = sampleHost({ uuid: 'delete-1', name: 'DelHost', forward_host: 'upstream-1' })
|
||||
const deleteHostMock = vi.fn().mockResolvedValue(undefined) as unknown as ProxyHostsHookValue['deleteHost']
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host], deleteHost: deleteHostMock }))
|
||||
vi.mocked(getMonitors).mockResolvedValue([
|
||||
{
|
||||
id: 'm1',
|
||||
upstream_host: 'upstream-1',
|
||||
name: 'm1',
|
||||
type: 'http',
|
||||
url: 'http://upstream-1',
|
||||
interval: 60,
|
||||
enabled: true,
|
||||
status: 'up',
|
||||
latency: 0,
|
||||
max_retries: 3,
|
||||
} satisfies UptimeMonitor,
|
||||
])
|
||||
|
||||
const confirmMock = vi.spyOn(window, 'confirm')
|
||||
// first confirm 'Are you sure' -> true, second confirm 'Delete monitors as well' -> true
|
||||
confirmMock.mockImplementation(() => true)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DelHost')).toBeInTheDocument())
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete proxy host DelHost' })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Confirm deletion in the dialog
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete Proxy Host/i })).toBeInTheDocument())
|
||||
const confirmDeleteBtn = screen.getByRole('button', { name: /^Delete$/ })
|
||||
await userEvent.click(confirmDeleteBtn)
|
||||
|
||||
await waitFor(() => expect(deleteHostMock).toHaveBeenCalled())
|
||||
|
||||
// Should have been called with both uuid and deleteUptime true (because monitors exist and second confirm true)
|
||||
expect(deleteHostMock).toHaveBeenCalledWith('delete-1', true)
|
||||
confirmMock.mockRestore()
|
||||
})
|
||||
|
||||
it('renders SSL badges for SSL-enabled hosts', async () => {
|
||||
const hostValid = sampleHost({ uuid: 'v1', name: 'ValidHost', domain_names: 'valid.example.com', ssl_forced: true })
|
||||
const hostAuto = sampleHost({ uuid: 'a1', name: 'AutoHost', domain_names: 'auto.example.com', ssl_forced: true })
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [hostValid, hostAuto] }))
|
||||
vi.mocked(useCertificates).mockReturnValue(
|
||||
createCertificatesHookValue({
|
||||
certificates: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'LE',
|
||||
domain: 'valid.example.com',
|
||||
issuer: 'letsencrypt',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ValidHost')).toBeInTheDocument())
|
||||
// Check that SSL badges are rendered (text removed for better spacing)
|
||||
const sslBadges = screen.getAllByText('SSL')
|
||||
expect(sslBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows error banner when hook returns an error', async () => {
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ error: 'Failed to load' }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Failed to load')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('select all shows (all) selected in summary', async () => {
|
||||
const h1 = sampleHost({ uuid: 'x', name: 'XHost' })
|
||||
const h2 = sampleHost({ uuid: 'y', name: 'YHost' })
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [h1, h2] }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('XHost')).toBeInTheDocument())
|
||||
const selectAllBtn = screen.getByRole('checkbox', { name: /Select all/i })
|
||||
// fallback, find by title
|
||||
if (!selectAllBtn) {
|
||||
await userEvent.click(screen.getByTitle('Select all'))
|
||||
} else {
|
||||
await userEvent.click(selectAllBtn)
|
||||
}
|
||||
|
||||
// Text is split across elements: "<strong>2</strong> host(s) selected (all)"
|
||||
// Check for presence of both parts separately
|
||||
await waitFor(() => expect(screen.getByText(/host\(s\) selected/)).toBeInTheDocument())
|
||||
expect(screen.getByText(/\(all\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loader when fetching', async () => {
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [sampleHost()], isFetching: true }))
|
||||
const { container } = renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(container.querySelector('.animate-spin')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('handles domain link behavior new_window', async () => {
|
||||
const host = sampleHost({ uuid: 'link-h1', domain_names: 'link.example.com', ssl_forced: true })
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host] }))
|
||||
vi.mocked(getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'new_window' })
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('link.example.com')).toBeInTheDocument())
|
||||
// Use exact string match to avoid incomplete hostname regex (CodeQL js/incomplete-hostname-regexp)
|
||||
const link = screen.getByRole('link', { name: 'link.example.com' })
|
||||
await userEvent.click(link)
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows WS and ACL badges when appropriate', async () => {
|
||||
const host = sampleHost({ uuid: 'x2', name: 'XHost2', websocket_support: true, access_list_id: 5 })
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host] }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('XHost2')).toBeInTheDocument())
|
||||
expect(screen.getByText('WS')).toBeInTheDocument()
|
||||
expect(screen.getByText('ACL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk ACL remove shows the confirmation card and Apply label updates when selecting ACLs', async () => {
|
||||
const host = sampleHost({ uuid: 'acl-1', name: 'AclHost' })
|
||||
const acl = { id: 1, name: 'MyACL', enabled: true }
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(
|
||||
createProxyHostsHookValue({
|
||||
hosts: [host],
|
||||
bulkUpdateACL: vi.fn(() => Promise.resolve({ updated: 1, errors: [] })) as unknown as ProxyHostsHookValue['bulkUpdateACL'],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useAccessLists).mockReturnValue(createAccessListsHookValue([acl]))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('AclHost')).toBeInTheDocument())
|
||||
// Select host using checkbox - find row first, then first checkbox (selection) within
|
||||
const row = screen.getByText('AclHost').closest('tr') as HTMLTableRowElement
|
||||
const selectBtn = within(row).getAllByRole('checkbox')[0]
|
||||
await userEvent.click(selectBtn)
|
||||
|
||||
// Open Manage ACL modal
|
||||
const manageBtn = screen.getByText('Manage ACL')
|
||||
await userEvent.click(manageBtn)
|
||||
|
||||
// Switch to Remove ACL action
|
||||
const removeBtn = screen.getByText('Remove ACL')
|
||||
await userEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/This will remove the access list from all 1 selected host/i)).toBeInTheDocument())
|
||||
|
||||
// Switch back to Apply ACL and select the ACL
|
||||
const applyBtn = screen.getByText('Apply ACL')
|
||||
await userEvent.click(applyBtn)
|
||||
const selectAll = screen.getByText('Select All')
|
||||
await userEvent.click(selectAll)
|
||||
await waitFor(() => expect(screen.getByText('Apply (1)')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('bulk ACL remove action calls bulkUpdateACL with null and shows removed toast', async () => {
|
||||
const host = sampleHost({ uuid: 'acl-2', name: 'AclHost2' })
|
||||
const bulkUpdateACLMock = vi.fn(async () => ({ updated: 1, errors: [] })) as unknown as ProxyHostsHookValue['bulkUpdateACL']
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host], bulkUpdateACL: bulkUpdateACLMock }))
|
||||
vi.mocked(useAccessLists).mockReturnValue(createAccessListsHookValue([{ id: 1, name: 'MyACL', enabled: true }]))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('AclHost2')).toBeInTheDocument())
|
||||
const row = screen.getByText('AclHost2').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
await userEvent.click(screen.getByText('Remove ACL'))
|
||||
// Click Remove ACL confirm button (bottom) - choose the confirmation button rather than the header action
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove ACL' })
|
||||
await userEvent.click(removeButtons[removeButtons.length - 1])
|
||||
|
||||
await waitFor(() => expect(bulkUpdateACLMock).toHaveBeenCalledWith(['acl-2'], null))
|
||||
expect(toast.success as unknown as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(expect.stringContaining('removed'))
|
||||
})
|
||||
|
||||
it('shows no enabled access lists available when none exist', async () => {
|
||||
const host = sampleHost({ uuid: 'acl-3', name: 'AclHost3' })
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host] }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('AclHost3')).toBeInTheDocument())
|
||||
const row = screen.getByText('AclHost3').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No enabled access lists available')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('bulk delete modal lists hosts to be deleted', async () => {
|
||||
const host = sampleHost({ uuid: 'd2', name: 'DeleteMe2' })
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host] }))
|
||||
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup-2' })
|
||||
const confirmMock = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DeleteMe2')).toBeInTheDocument())
|
||||
const row = screen.getByText('DeleteMe2').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
const deleteButtons = screen.getAllByText('Delete')
|
||||
const toolbarBtn = deleteButtons.map((btn: Element) => btn.closest('button') as HTMLButtonElement | null).find((b) => b && b.className.includes('bg-error')) as HTMLButtonElement | undefined
|
||||
if (!toolbarBtn) throw new Error('Toolbar delete button not found')
|
||||
await userEvent.click(toolbarBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete 1 Proxy Host/i })).toBeInTheDocument())
|
||||
// Ensure the modal lists the host by scoping to the modal content
|
||||
const listHeader = screen.getByText('Hosts to be deleted:')
|
||||
const modalRoot = listHeader.closest('div')
|
||||
expect(modalRoot).toBeTruthy()
|
||||
if (modalRoot) {
|
||||
const { getByText: getByTextWithin } = within(modalRoot)
|
||||
expect(getByTextWithin('DeleteMe2')).toBeInTheDocument()
|
||||
expect(getByTextWithin('(a.example.com)')).toBeInTheDocument()
|
||||
}
|
||||
// Confirm delete
|
||||
await userEvent.click(screen.getByRole('button', { name: /Delete Permanently/i }))
|
||||
await waitFor(() => expect(vi.mocked(toast.success)).toHaveBeenCalledWith(expect.stringContaining('Backup created')))
|
||||
confirmMock.mockRestore()
|
||||
})
|
||||
|
||||
it('bulk apply modal returns early when no keys selected (no-op)', async () => {
|
||||
const host = sampleHost({ uuid: 'b1', name: 'BlankHost' })
|
||||
const updateHost = vi.fn() as unknown as ProxyHostsHookValue['updateHost']
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host], updateHost }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('BlankHost')).toBeInTheDocument())
|
||||
// Select host
|
||||
const row = screen.getByText('BlankHost').closest('tr') as HTMLTableRowElement
|
||||
await userEvent.click(within(row).getAllByRole('checkbox')[0])
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
const applyBtn = screen.getByRole('button', { name: 'Apply' })
|
||||
// Remove disabled to trigger the no-op branch
|
||||
applyBtn.removeAttribute('disabled')
|
||||
await userEvent.click(applyBtn)
|
||||
// No calls to updateHost should be made
|
||||
expect(updateHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bulk delete creates backup and shows toast success', async () => {
|
||||
const host = sampleHost({ uuid: 'd1', name: 'DeleteMe' })
|
||||
const deleteHostMock = vi.fn().mockResolvedValue(undefined) as unknown as ProxyHostsHookValue['deleteHost']
|
||||
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsHookValue({ hosts: [host], deleteHost: deleteHostMock }))
|
||||
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup-1' })
|
||||
|
||||
const confirmMock = vi.spyOn(window, 'confirm')
|
||||
// First confirm to delete overall, returned true for deletion
|
||||
confirmMock.mockImplementation(() => true)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DeleteMe')).toBeInTheDocument())
|
||||
// Select host
|
||||
const row = screen.getByText('DeleteMe').closest('tr') as HTMLTableRowElement
|
||||
const selectBtn = within(row).getAllByRole('checkbox')[0]
|
||||
await userEvent.click(selectBtn)
|
||||
|
||||
// Open Bulk Delete modal - find the toolbar Delete button near the header
|
||||
const deleteButtons = screen.getAllByText('Delete')
|
||||
const toolbarBtn = deleteButtons.map((btn: Element) => btn.closest('button') as HTMLButtonElement | null).find((b) => b && b.className.includes('bg-error')) as HTMLButtonElement | undefined
|
||||
if (!toolbarBtn) throw new Error('Toolbar delete button not found')
|
||||
await userEvent.click(toolbarBtn)
|
||||
|
||||
// Confirm Delete in modal
|
||||
await userEvent.click(screen.getByRole('button', { name: /Delete Permanently/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.success as unknown as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(expect.stringContaining('Backup created')),
|
||||
)
|
||||
confirmMock.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost, BulkUpdateACLResponse } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'host-1',
|
||||
name: 'Host',
|
||||
domain_names: 'example.com',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 8080,
|
||||
forward_scheme: 'http' as const,
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProxyHosts progress apply', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows progress when applying multiple ACLs', async () => {
|
||||
const host1 = baseHost({ uuid: 'h1', name: 'H1' })
|
||||
const host2 = baseHost({ uuid: 'h2', name: 'H2' })
|
||||
const acls = [
|
||||
{ id: 1, uuid: 'acl-1', name: 'ACL1', description: 'Test ACL1', enabled: true, type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-2', name: 'ACL2', description: 'Test ACL2', enabled: true, type: 'blacklist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
]
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue(acls)
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
// Create controllable promises for bulkUpdateACL invocations
|
||||
const resolvers: Array<(value: BulkUpdateACLResponse) => void> = []
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation((...args: unknown[]) => {
|
||||
const [_hostUUIDs, _aclId] = args
|
||||
void _hostUUIDs; void _aclId
|
||||
return new Promise((resolve: (v: BulkUpdateACLResponse) => void) => { resolvers.push(resolve); })
|
||||
})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select both hosts via select-all
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await userEvent.click(checkboxes[0])
|
||||
|
||||
// Open bulk ACL modal
|
||||
await waitFor(() => expect(screen.getByText('Manage ACL')).toBeTruthy())
|
||||
await userEvent.click(screen.getByText('Manage ACL'))
|
||||
|
||||
// Wait for ACL list
|
||||
await waitFor(() => expect(screen.getByText('ACL1')).toBeTruthy())
|
||||
|
||||
// Select both ACLs
|
||||
const aclCheckboxes = screen.getAllByRole('checkbox')
|
||||
const adminCheckbox = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('ACL1'))
|
||||
const localCheckbox = aclCheckboxes.find(cb => cb.closest('label')?.textContent?.includes('ACL2'))
|
||||
if (adminCheckbox) await userEvent.click(adminCheckbox)
|
||||
if (localCheckbox) await userEvent.click(localCheckbox)
|
||||
|
||||
// Click Apply; should start progress (total 2)
|
||||
const applyBtn = await screen.findByRole('button', { name: /Apply\s*\(2\)/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Progress indicator should appear
|
||||
await waitFor(() => expect(screen.getByText(/Applying ACLs/)).toBeTruthy())
|
||||
// After the first bulk operation starts, we should have a resolver
|
||||
await waitFor(() => expect(resolvers.length).toBeGreaterThanOrEqual(1))
|
||||
|
||||
// Resolve first bulk operation to allow the sequential loop to continue
|
||||
resolvers[0]({ updated: 2, errors: [] })
|
||||
|
||||
// Wait for the second bulk operation to start and create its resolver
|
||||
await waitFor(() => expect(resolvers.length).toBeGreaterThanOrEqual(2))
|
||||
// Resolve second operation
|
||||
resolvers[1]({ updated: 2, errors: [] })
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('does not open window for same_tab link behavior', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([baseHost({ uuid: '1', name: 'One' })])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({ 'ui.domain_link_behavior': 'same_tab' })
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
|
||||
const anchor = screen.getByRole('link', { name: /^example\.com$/i })
|
||||
expect(anchor.getAttribute('target')).toBe('_self')
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,455 @@
|
||||
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';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { Certificate } from '../../api/certificates';
|
||||
import type { ProxyHost } from '../../api/proxyHosts';
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists';
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import * as securityHeadersApi from '../../api/securityHeaders';
|
||||
import type { SecurityHeaderProfile } from '../../api/securityHeaders';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
bulkUpdateSecurityHeaders: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
vi.mock('../../api/securityHeaders', () => ({
|
||||
securityHeadersApi: {
|
||||
listProfiles: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({
|
||||
uuid: 'host-1',
|
||||
name: 'Test Host 1',
|
||||
domain_names: 'test1.example.com',
|
||||
forward_host: '192.168.1.10',
|
||||
}),
|
||||
createMockProxyHost({
|
||||
uuid: 'host-2',
|
||||
name: 'Test Host 2',
|
||||
domain_names: 'test2.example.com',
|
||||
forward_host: '192.168.1.20',
|
||||
}),
|
||||
];
|
||||
|
||||
const mockSecurityProfiles: SecurityHeaderProfile[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'profile-1',
|
||||
name: 'Strict Security',
|
||||
description: 'Maximum security headers',
|
||||
security_score: 95,
|
||||
is_preset: true,
|
||||
preset_type: 'strict',
|
||||
hsts_enabled: true,
|
||||
hsts_max_age: 31536000,
|
||||
hsts_include_subdomains: true,
|
||||
hsts_preload: true,
|
||||
x_frame_options: 'DENY',
|
||||
x_content_type_options: true,
|
||||
xss_protection: true,
|
||||
referrer_policy: 'no-referrer',
|
||||
permissions_policy: '',
|
||||
csp_enabled: false,
|
||||
csp_directives: '',
|
||||
csp_report_only: false,
|
||||
csp_report_uri: '',
|
||||
cross_origin_opener_policy: 'same-origin',
|
||||
cross_origin_resource_policy: 'same-origin',
|
||||
cross_origin_embedder_policy: '',
|
||||
cache_control_no_store: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'profile-2',
|
||||
name: 'Moderate Security',
|
||||
description: 'Balanced security headers',
|
||||
security_score: 75,
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
hsts_enabled: true,
|
||||
hsts_max_age: 31536000,
|
||||
hsts_include_subdomains: false,
|
||||
hsts_preload: false,
|
||||
x_frame_options: 'SAMEORIGIN',
|
||||
x_content_type_options: true,
|
||||
xss_protection: true,
|
||||
referrer_policy: 'strict-origin-when-cross-origin',
|
||||
permissions_policy: '',
|
||||
csp_enabled: false,
|
||||
csp_directives: '',
|
||||
csp_report_only: false,
|
||||
csp_report_uri: '',
|
||||
cross_origin_opener_policy: 'same-origin',
|
||||
cross_origin_resource_policy: 'same-origin',
|
||||
cross_origin_embedder_policy: '',
|
||||
cache_control_no_store: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'profile-3',
|
||||
name: 'Custom Profile',
|
||||
description: 'My custom headers',
|
||||
security_score: 60,
|
||||
is_preset: false,
|
||||
preset_type: '',
|
||||
hsts_enabled: false,
|
||||
hsts_max_age: 0,
|
||||
hsts_include_subdomains: false,
|
||||
hsts_preload: false,
|
||||
x_frame_options: 'SAMEORIGIN',
|
||||
x_content_type_options: true,
|
||||
xss_protection: true,
|
||||
referrer_policy: 'same-origin',
|
||||
permissions_policy: '',
|
||||
csp_enabled: false,
|
||||
csp_directives: '',
|
||||
csp_report_only: false,
|
||||
csp_report_uri: '',
|
||||
cross_origin_opener_policy: 'same-origin',
|
||||
cross_origin_resource_policy: 'same-origin',
|
||||
cross_origin_embedder_policy: '',
|
||||
cache_control_no_store: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply Security Headers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
vi.mocked(securityHeadersApi.securityHeadersApi.listProfiles).mockResolvedValue(
|
||||
mockSecurityProfiles
|
||||
);
|
||||
});
|
||||
|
||||
it('shows security header profile option in bulk apply modal', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Open Bulk Apply modal
|
||||
const bulkApplyButton = screen.getByText('Bulk Apply');
|
||||
await userEvent.click(bulkApplyButton);
|
||||
|
||||
// Check for security header profile section
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Header Profile')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('Apply a security header profile to all selected hosts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables profile selection when checkbox is checked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Find security header checkbox
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
|
||||
// Dropdown should not be visible initially
|
||||
expect(screen.queryByRole('combobox')).toBeNull();
|
||||
|
||||
// Click checkbox to enable
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Dropdown should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('lists all available profiles in dropdown grouped correctly', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Check dropdown options
|
||||
await waitFor(() => {
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(dropdown).toBeTruthy();
|
||||
|
||||
// Check for "None" option
|
||||
const noneOption = within(dropdown).getByText(/None \(Remove Profile\)/i);
|
||||
expect(noneOption).toBeTruthy();
|
||||
|
||||
// Check for preset profiles
|
||||
expect(within(dropdown).getByText(/Strict Security/)).toBeTruthy();
|
||||
expect(within(dropdown).getByText(/Moderate Security/)).toBeTruthy();
|
||||
|
||||
// Check for custom profiles
|
||||
expect(within(dropdown).getByText(/Custom Profile/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies security header profile to selected hosts using bulk endpoint', async () => {
|
||||
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
|
||||
bulkUpdateMock.mockResolvedValue({ updated: 2, errors: [] });
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Select a profile
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1'); // Select profile ID 1
|
||||
|
||||
// Click Apply
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Verify bulk endpoint was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateMock).toHaveBeenCalledWith(['host-1', 'host-2'], 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes security header profile when "None" selected', async () => {
|
||||
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
|
||||
bulkUpdateMock.mockResolvedValue({ updated: 2, errors: [] });
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Select "None" (value 0)
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '0');
|
||||
|
||||
// Verify warning is shown
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This will remove the security header profile from all selected hosts/
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Apply
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Verify null was sent to API (remove profile)
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateMock).toHaveBeenCalledWith(['host-1', 'host-2'], null);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Apply button when no options selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Apply button should be disabled when nothing is selected
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
expect(applyButton).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('handles partial failure with appropriate toast', async () => {
|
||||
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
|
||||
bulkUpdateMock.mockResolvedValue({
|
||||
updated: 1,
|
||||
errors: [{ uuid: 'host-2', error: 'Profile not found' }],
|
||||
});
|
||||
|
||||
const toast = await import('react-hot-toast');
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option and select a profile
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1');
|
||||
|
||||
// Click Apply
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Verify error toast was called
|
||||
await waitFor(() => {
|
||||
expect(toast.toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets state on modal close', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option and select a profile
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1');
|
||||
|
||||
// Close modal
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const cancelButton = within(dialog).getByRole('button', { name: /Cancel/i });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
// Re-open modal
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Security header checkbox should be unchecked (state was reset)
|
||||
await waitFor(() => {
|
||||
const securityHeaderLabel2 = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow2 = securityHeaderLabel2.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox2 = within(securityHeaderRow2).getByRole('checkbox');
|
||||
expect(securityHeaderCheckbox2).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows profile description when profile is selected', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts and open modal
|
||||
const selectAll = screen.getByLabelText('Select all rows');
|
||||
await userEvent.click(selectAll);
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
|
||||
// Enable security header option
|
||||
const securityHeaderLabel = screen.getByText('Security Header Profile');
|
||||
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
|
||||
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
|
||||
await userEvent.click(securityHeaderCheckbox);
|
||||
|
||||
// Select a profile
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
await userEvent.selectOptions(dropdown, '1'); // Strict Security
|
||||
|
||||
// Verify description is shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Maximum security headers')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import RateLimiting from '../RateLimiting'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import type { SecurityStatus } from '../../api/security'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>{ui}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockStatusEnabled: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled', api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' },
|
||||
rate_limit: { enabled: true, mode: 'enabled' },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockStatusDisabled: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled', api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' },
|
||||
rate_limit: { enabled: false, mode: 'disabled' },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockSecurityConfig = {
|
||||
config: {
|
||||
name: 'default',
|
||||
rate_limit_requests: 10,
|
||||
rate_limit_burst: 5,
|
||||
rate_limit_window_sec: 60,
|
||||
},
|
||||
}
|
||||
|
||||
describe('RateLimiting page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('shows loading state while fetching status', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
vi.mocked(securityApi.getSecurityConfig).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders rate limiting page with toggle disabled when rate_limit is off', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('rate-limit-toggle')
|
||||
expect(toggle).toBeInTheDocument()
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('renders rate limiting page with toggle enabled when rate_limit is on', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('rate-limit-toggle')
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('shows configuration inputs when enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('rate-limit-burst')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rate-limit-window')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls updateSetting when toggle is clicked', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('rate-limit-toggle')
|
||||
await userEvent.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.rate_limit.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls updateSecurityConfig when save button is clicked', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(securityApi.updateSecurityConfig).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for initial values to be set from config
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toHaveValue(10)
|
||||
})
|
||||
|
||||
// Change RPS value using tripleClick to select all then type
|
||||
const rpsInput = screen.getByTestId('rate-limit-rps')
|
||||
await userEvent.tripleClick(rpsInput)
|
||||
await userEvent.keyboard('25')
|
||||
|
||||
// Click save
|
||||
const saveBtn = screen.getByTestId('save-rate-limit-btn')
|
||||
await userEvent.click(saveBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.updateSecurityConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rate_limit_requests: 25,
|
||||
rate_limit_burst: 5,
|
||||
rate_limit_window_sec: 60,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays default values from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('rate-limit-rps')).toHaveValue(10)
|
||||
expect(screen.getByTestId('rate-limit-burst')).toHaveValue(5)
|
||||
expect(screen.getByTestId('rate-limit-window')).toHaveValue(60)
|
||||
})
|
||||
|
||||
it('hides configuration inputs when disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('rate-limit-rps')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rate-limit-burst')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rate-limit-window')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows info banner about rate limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled)
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
|
||||
renderWithProviders(<RateLimiting />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Rate limiting helps protect/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import SMTPSettings from '../SMTPSettings'
|
||||
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(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConnection: vi.fn(),
|
||||
sendTestEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SMTPSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
})
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
// Should show loading skeletons (Skeleton components don't use animate-spin)
|
||||
expect(document.querySelectorAll('[class*="animate-pulse"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders SMTP form with existing config', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
// Wait for the form to populate with data
|
||||
await waitFor(() => {
|
||||
const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
|
||||
return hostInput.value === 'smtp.example.com'
|
||||
})
|
||||
|
||||
const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement
|
||||
expect(hostInput.value).toBe('smtp.example.com')
|
||||
|
||||
const portInput = screen.getByPlaceholderText('587') as HTMLInputElement
|
||||
expect(portInput.value).toBe('587')
|
||||
|
||||
expect(screen.getByText(t('smtp.configured'))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows not configured state when SMTP is not set up', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(t('smtp.notConfigured'))).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves SMTP settings successfully', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
vi.mocked(smtpApi.updateSMTPConfig).mockResolvedValue({
|
||||
message: 'SMTP configuration saved successfully',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.gmail.com')
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Charon <no-reply@example.com>'),
|
||||
'test@example.com'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: t('smtp.saveSettings') }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(smtpApi.updateSMTPConfig).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('tests SMTP connection', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
vi.mocked(smtpApi.testSMTPConnection).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const testButton = await screen.findByRole('button', { name: t('smtp.testConnection') })
|
||||
await waitFor(() => expect(testButton).toBeEnabled())
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(testButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(smtpApi.testSMTPConnection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows test email form when SMTP is configured', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(t('smtp.sendTestEmail'))).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends test email', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
vi.mocked(smtpApi.sendTestEmail).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Email sent',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(t('smtp.sendTestEmail'))).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('recipient@example.com'),
|
||||
'test@test.com'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: t('smtp.sendTest') }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' })
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces backend validation errors on save', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
vi.mocked(smtpApi.updateSMTPConfig).mockRejectedValue({ response: { data: { error: 'invalid host' } } })
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeInTheDocument())
|
||||
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: t('smtp.saveSettings') }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('invalid host')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables test connection until required fields are set and shows error toast on failure', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
encryption: 'starttls',
|
||||
configured: false,
|
||||
})
|
||||
vi.mocked(smtpApi.testSMTPConnection).mockRejectedValue({ response: { data: { error: 'cannot connect' } } })
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText(t('smtp.testConnection'))).toBeInTheDocument())
|
||||
|
||||
// Button should start disabled until host and from address are provided
|
||||
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 waitFor(() => expect(testButton).toBeEnabled())
|
||||
await user.click(testButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('cannot connect')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test email failures and keeps input value intact', async () => {
|
||||
vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '********',
|
||||
from_address: 'noreply@example.com',
|
||||
encryption: 'starttls',
|
||||
configured: true,
|
||||
})
|
||||
vi.mocked(smtpApi.sendTestEmail).mockRejectedValue({ response: { data: { error: 'smtp unreachable' } } })
|
||||
|
||||
renderWithQueryClient(<SMTPSettings />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
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: t('smtp.sendTest') }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('smtp unreachable')
|
||||
expect(input.value).toBe('keepme@example.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Security Page - QA Security Audit Tests
|
||||
*
|
||||
* Tests edge cases, input validation, error states, and security concerns
|
||||
* for the Cerberus Dashboard implementation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({ data: { rulesets: [] } })),
|
||||
}
|
||||
})
|
||||
|
||||
// BLOCKER 3: Temporarily skipped due to undici InvalidArgumentError in WebSocket mocks
|
||||
describe.skip('Security Page - QA Security Audit', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('React escapes XSS in rendered text - validation check', async () => {
|
||||
// Note: React automatically escapes text content, so XSS in input values
|
||||
// won't execute. This test verifies that property.
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// DOM should not contain any actual script elements from user input
|
||||
expect(document.querySelectorAll('script[src*="alert"]').length).toBe(0)
|
||||
|
||||
// Verify React is escaping properly - any text rendered should be text, not HTML
|
||||
expect(screen.queryByText('<script>')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles empty admin whitelist gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Find the admin whitelist input by placeholder
|
||||
const whitelistInput = screen.getByPlaceholderText(/192.168.1.0\/24/i)
|
||||
expect(whitelistInput).toBeInTheDocument()
|
||||
expect(whitelistInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error toast when toggle mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// CrowdSec is not running, so toggle will try to START it
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start CrowdSec'))
|
||||
})
|
||||
})
|
||||
|
||||
it('handles CrowdSec start failure gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles CrowdSec stop failure gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Failed to stop'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('handles CrowdSec status check failure gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockRejectedValue(new Error('Status check failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Page should still render even if status check fails
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Concurrent Operations', () => {
|
||||
it('disables controls during pending mutations', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// Never resolving promise to simulate pending state
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
// Overlay should appear indicating operation in progress
|
||||
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('prevents double toggle when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
let callCount = 0
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(async () => {
|
||||
callCount++
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return { status: 'started', pid: 123, lapi_ready: true }
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
|
||||
// First click
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for toggle to become disabled (mutation in progress)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toBeDisabled()
|
||||
})
|
||||
|
||||
// Second click attempt while disabled should be ignored
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for potential multiple calls
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
})
|
||||
|
||||
// Should only be called once due to disabled state
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI Consistency', () => {
|
||||
it('maintains card order when services are toggled', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get initial card order
|
||||
const initialCards = screen.getAllByRole('heading', { level: 3 })
|
||||
const initialOrder = initialCards.map(card => card.textContent)
|
||||
|
||||
// Toggle a service
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for mutation to settle
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalled())
|
||||
|
||||
// Cards should still be in same order
|
||||
const finalCards = screen.getAllByRole('heading', { level: 3 })
|
||||
const finalOrder = finalCards.map(card => card.textContent)
|
||||
|
||||
expect(finalOrder).toEqual(initialOrder)
|
||||
})
|
||||
|
||||
it('shows correct layer indicator badges', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Each layer should have a Badge with layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all four security cards even when all disabled', async () => {
|
||||
const disabledStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: '', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: false },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false }
|
||||
}
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// All 4 cards should be present - check for h3 headings
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
expect(cardNames).toContain('CrowdSec')
|
||||
expect(cardNames).toContain('Access Control')
|
||||
expect(cardNames).toContain('Coraza WAF')
|
||||
expect(cardNames).toContain('Rate Limiting')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('all toggles have proper test IDs for automation', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CrowdSec controls surface primary actions when enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
// CrowdSec card should have Configure button now
|
||||
const configButtons = screen.getAllByRole('button', { name: /Configure/i })
|
||||
expect(configButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Contract Verification (Spec Compliance)', () => {
|
||||
it('pipeline order matches spec: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
|
||||
// Spec requirement: Admin Whitelist + security cards + Security Access Logs
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
|
||||
it('layer indicators match spec descriptions', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Layer indicators are now Badges with just the layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('threat summaries match spec when services enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// CrowdSec must be running to show threat protection descriptions
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// From spec:
|
||||
// CrowdSec: "Known attackers, botnets, brute-force attempts"
|
||||
// ACL: "Unauthorized IPs, geo-based attacks, insider threats"
|
||||
// WAF: "SQL injection, XSS, RCE, zero-day exploits*"
|
||||
// Rate Limiting: "DDoS attacks, credential stuffing, API abuse"
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles rapid toggle clicks without crashing', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(resolve, 50))
|
||||
)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
|
||||
// Rapid clicks
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await user.click(toggle)
|
||||
}
|
||||
|
||||
// Page should still be functional
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('handles undefined crowdsec status gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue(null as never)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Should not crash
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Security Dashboard Card Status Verification Tests
|
||||
* Test IDs: SD-01 through SD-10
|
||||
*
|
||||
* Tests all 4 security cards display correct status, Cerberus disabled banner,
|
||||
* and toggle switches disabled when Cerberus is off.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// Test Data Fixtures
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCerberusDisabled = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { mode: 'disabled' as const, api_url: '', enabled: false },
|
||||
waf: { mode: 'disabled' as const, enabled: false },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockSecurityStatusMixed = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'disabled' as const, enabled: false },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
// BLOCKER 3: Temporarily skipped due to undici InvalidArgumentError in WebSocket mocks
|
||||
describe.skip('Security Dashboard - Card Status Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('SD-01: Cerberus Disabled Banner', () => {
|
||||
it('should show "Security Features Unavailable" banner when cerberus.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show documentation link in disabled banner', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
// Documentation link uses "Learn More" text in current UI
|
||||
const docButtons = screen.getAllByRole('button', { name: /Learn More/i })
|
||||
expect(docButtons.length).toBeGreaterThanOrEqual(1)
|
||||
expect(docButtons[0]).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show banner when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/^Cerberus Disabled$/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-02: CrowdSec Card Active Status', () => {
|
||||
it('should show "Enabled" when crowdsec.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
// Status badges now show 'Enabled' text
|
||||
const enabledBadges = screen.getAllByText('Enabled')
|
||||
expect(enabledBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).toBeChecked()
|
||||
})
|
||||
|
||||
it('should show running PID when CrowdSec is running', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Running \(pid 1234\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-03: CrowdSec Card Disabled Status', () => {
|
||||
it('should show "Disabled" when crowdsec.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
crowdsec: { mode: 'disabled', api_url: '', enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-04: WAF (Coraza) Card Status', () => {
|
||||
it('should show "Active" when waf.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show "Disabled" when waf.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusMixed)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-05: Rate Limiting Card Status', () => {
|
||||
it('should show badge and text when rate_limit.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeChecked()
|
||||
const enabledBadges = screen.getAllByText('Enabled')
|
||||
expect(enabledBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show "Disabled" badge when rate_limit.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
rate_limit: { enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-rate-limit')).not.toBeChecked()
|
||||
const disabledBadges = screen.getAllByText('Disabled')
|
||||
expect(disabledBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-06: ACL Card Status', () => {
|
||||
it('should show "Active" when acl.enabled=true', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-acl')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show "Disabled" when acl.enabled=false', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusMixed)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-acl')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-07: Layer Indicators', () => {
|
||||
it('should display all layer indicators in correct order', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Layer indicators are now Badges with just the layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-08: Threat Protection Summaries', () => {
|
||||
it('should display threat protection descriptions for each card', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
// CrowdSec must be running to show threat protection descriptions
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-09: Card Order (Pipeline Sequence)', () => {
|
||||
it('should maintain card order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Get all card headings (includes Admin Whitelist when Cerberus is enabled)
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map((card: HTMLElement) => card.textContent)
|
||||
|
||||
// Verify pipeline order with Admin Whitelist first (when Cerberus enabled)
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
|
||||
it('should maintain card order even after toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Toggle WAF off
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
// Cards should still be in order
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map((card: HTMLElement) => card.textContent)
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SD-10: Toggle Switches Disabled When Cerberus Off', () => {
|
||||
it('should disable all service toggles when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// All toggles should be disabled
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-waf')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable toggles when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// All toggles should be enabled
|
||||
expect(screen.getByTestId('toggle-crowdsec')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-waf')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-acl')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Security Error Handling Tests
|
||||
* Test IDs: EH-01 through EH-10
|
||||
*
|
||||
* Tests error messages on API failures, toast notifications on mutation errors,
|
||||
* and optimistic update rollback.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// Test Data Fixtures
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCrowdsecDisabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
// BLOCKER 3: Temporarily skipped due to undici InvalidArgumentError in WebSocket mocks
|
||||
describe.skip('Security Error Handling Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('EH-01: Failed Security Status Fetch Shows Error', () => {
|
||||
it('should show "Failed to load security configuration" when API fails', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load security configuration/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-02: Toggle Mutation Failure Shows Toast', () => {
|
||||
it('should call toast.error() when toggle mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Permission denied'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-03: CrowdSec Start Failure Shows Specific Toast', () => {
|
||||
it('should show "Failed to start CrowdSec: [message]" on start failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Service unavailable'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
await user.click(screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start CrowdSec'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Service unavailable'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-04: CrowdSec Stop Failure Shows Specific Toast', () => {
|
||||
it('should show "Failed to stop CrowdSec: [message]" on stop failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Process locked'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
await user.click(screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Process locked'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-05: WAF Toggle Failure Shows Error', () => {
|
||||
it('should show error toast when WAF toggle fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('WAF configuration error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-06: Rate Limiting Update Failure Shows Toast', () => {
|
||||
it('should show error toast when rate limiting toggle fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Rate limit config error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
await user.click(screen.getByTestId('toggle-rate-limit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-07: Network Error Shows Generic Message', () => {
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network request failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Network request failed'))
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle non-Error objects gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue('Unknown error string')
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-08: ACL Toggle Failure Shows Error', () => {
|
||||
it('should show error when ACL toggle fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('ACL update failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('ACL update failed'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-09: Multiple Consecutive Failures Show Multiple Toasts', () => {
|
||||
it('should show separate toast for each failed operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Server error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// First failure
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Second failure
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EH-10: Optimistic Update Reverts on Error', () => {
|
||||
it('should revert toggle state when mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Update failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
// WAF is initially enabled
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
expect(toggle).toBeChecked()
|
||||
|
||||
// Click to disable - optimistic update will uncheck it
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for error and rollback
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// After rollback, the toggle should be back to checked (enabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should revert CrowdSec state on start failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Start failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
// CrowdSec is initially disabled
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).not.toBeChecked()
|
||||
|
||||
// Click to enable
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for error
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start CrowdSec'))
|
||||
})
|
||||
|
||||
// After rollback, toggle should be back to unchecked (disabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-crowdsec')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should revert CrowdSec state on stop failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Stop failed'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
// CrowdSec is initially enabled
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(toggle).toBeChecked()
|
||||
|
||||
// Click to disable
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for error
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
|
||||
})
|
||||
|
||||
// After rollback, toggle should be back to checked (enabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* Security Page Functional Tests - LiveLogViewer Mocked
|
||||
*
|
||||
* These tests mock the LiveLogViewer component to avoid WebSocket issues
|
||||
* and focus on testing Security.tsx core functionality.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useNotifications', () => ({
|
||||
useSecurityNotificationSettings: vi.fn(() => ({
|
||||
data: {
|
||||
enabled: false,
|
||||
min_log_level: 'warn',
|
||||
security_waf_enabled: true,
|
||||
security_acl_enabled: true,
|
||||
security_rate_limit_enabled: true,
|
||||
webhook_url: '',
|
||||
},
|
||||
isLoading: false,
|
||||
})),
|
||||
useUpdateSecurityNotificationSettings: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
const securityTranslations: Record<string, string> = {
|
||||
'security.title': 'Security',
|
||||
'security.description': 'Configure security layers for your reverse proxy',
|
||||
'security.cerberusDashboard': 'Cerberus Dashboard',
|
||||
'security.cerberusActive': 'Active',
|
||||
'security.cerberusDisabled': 'Disabled',
|
||||
'security.cerberusReadyMessage': 'Cerberus is ready to protect your services',
|
||||
'security.cerberusDisabledMessage': 'Enable Cerberus in System Settings to activate security features',
|
||||
'security.featuresUnavailable': 'Security Features Unavailable',
|
||||
'security.featuresUnavailableMessage': 'Enable Cerberus in System Settings to use security features',
|
||||
'security.learnMore': 'Learn More',
|
||||
'security.adminWhitelist': 'Admin Whitelist',
|
||||
'security.adminWhitelistDescription': 'CIDRs that bypass security checks for admin access',
|
||||
'security.commaSeparatedCIDR': 'Comma-separated CIDRs (e.g., 192.168.1.0/24)',
|
||||
'security.generateToken': 'Generate Token',
|
||||
'security.generateTokenTooltip': 'Generate a one-time break-glass token for emergency access',
|
||||
'security.layer1': 'Layer 1',
|
||||
'security.layer2': 'Layer 2',
|
||||
'security.layer3': 'Layer 3',
|
||||
'security.layer4': 'Layer 4',
|
||||
'security.ids': 'IDS',
|
||||
'security.acl': 'ACL',
|
||||
'security.waf': 'WAF',
|
||||
'security.rate': 'Rate',
|
||||
'security.crowdsec': 'CrowdSec',
|
||||
'security.crowdsecDescription': 'IP Reputation',
|
||||
'security.crowdsecProtects': 'Blocks known attackers, botnets, and malicious IPs',
|
||||
'security.crowdsecDisabledDescription': 'Enable to block known malicious IPs',
|
||||
'security.accessControl': 'Access Control',
|
||||
'security.aclDescription': 'IP Allowlists/Blocklists',
|
||||
'security.aclProtects': 'Unauthorized IPs, geo-based attacks',
|
||||
'security.corazaWaf': 'Coraza WAF',
|
||||
'security.wafDescription': 'Request Inspection',
|
||||
'security.wafProtects': 'SQL injection, XSS, RCE',
|
||||
'security.wafDisabledDescription': 'Enable to inspect requests for threats',
|
||||
'security.rateLimiting': 'Rate Limiting',
|
||||
'security.rateLimitDescription': 'Volume Control',
|
||||
'security.rateLimitProtects': 'DDoS attacks, credential stuffing',
|
||||
'security.processStopped': 'Process stopped',
|
||||
'security.enableCerberusFirst': 'Enable Cerberus first',
|
||||
'security.toggleCrowdsec': 'Toggle CrowdSec',
|
||||
'security.toggleAcl': 'Toggle Access Control',
|
||||
'security.toggleWaf': 'Toggle WAF',
|
||||
'security.toggleRateLimit': 'Toggle Rate Limiting',
|
||||
'security.manageLists': 'Manage Lists',
|
||||
'security.auditLogs': 'Audit Logs',
|
||||
'security.notifications': 'Notifications',
|
||||
'security.threeHeadsTurn': 'Three heads turn',
|
||||
'security.cerberusConfigUpdating': 'Cerberus configuration updating',
|
||||
'security.summoningGuardian': 'Summoning the guardian',
|
||||
'security.crowdsecStarting': 'CrowdSec is starting',
|
||||
'security.guardianRests': 'Guardian rests',
|
||||
'security.crowdsecStopping': 'CrowdSec is stopping',
|
||||
'security.strengtheningGuard': 'Strengthening guard',
|
||||
'security.wardsActivating': 'Wards activating',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'common.save': 'Save',
|
||||
'common.configure': 'Configure',
|
||||
'common.docs': 'Docs',
|
||||
'common.error': 'Error',
|
||||
'security.failedToLoadConfiguration': 'Failed to load security configuration',
|
||||
}
|
||||
|
||||
// Mock i18n translation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { pid?: number }) => {
|
||||
// Handle interpolation for runningPid
|
||||
if (key === 'security.runningPid' && options?.pid !== undefined) {
|
||||
return `Running (pid ${options.pid})`
|
||||
}
|
||||
return securityTranslations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock LiveLogViewer to avoid WebSocket issues
|
||||
vi.mock('../../components/LiveLogViewer', () => ({
|
||||
LiveLogViewer: () => <div data-testid="live-log-viewer">Mocked Live Log Viewer</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/SecurityNotificationSettingsModal', () => ({
|
||||
SecurityNotificationSettingsModal: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/CrowdSecKeyWarning', () => ({
|
||||
CrowdSecKeyWarning: () => null,
|
||||
}))
|
||||
|
||||
// NOTE: CrowdSecBouncerKeyDisplay mock removed (moved to CrowdSecConfig page)
|
||||
|
||||
vi.mock('../../hooks/useSecurity', () => ({
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
}))
|
||||
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true, mode: 'enabled' as const },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCerberusDisabled = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { mode: 'disabled' as const, api_url: '', enabled: false },
|
||||
waf: { mode: 'disabled' as const, enabled: false },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCrowdsecDisabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
describe('Security Page - Functional Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
mockNavigate.mockReset()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('Page Loading States', () => {
|
||||
it('should show skeleton loading state initially', async () => {
|
||||
const deferredStatus: { resolve: (value: typeof mockSecurityStatusAllEnabled) => void } = {
|
||||
resolve: () => {
|
||||
throw new Error('Test setup failed: pending status resolver was not initialized')
|
||||
},
|
||||
}
|
||||
const pendingStatus = new Promise<typeof mockSecurityStatusAllEnabled>((resolve) => {
|
||||
deferredStatus.resolve = resolve
|
||||
})
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(pendingStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
|
||||
deferredStatus.resolve(mockSecurityStatusAllEnabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display error message when security status fails to load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('API Error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load security configuration/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cerberus Dashboard Header', () => {
|
||||
it('should display Cerberus Dashboard title when loaded', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Active badge when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
// Translation key: cerberusActive = 'Active'
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Disabled badge when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
// Multiple badges show 'Disabled'
|
||||
const disabledBadges = screen.getAllByText('Disabled')
|
||||
expect(disabledBadges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cerberus Disabled Warning', () => {
|
||||
it('should show warning banner when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Learn More button in warning banner', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
const learnMoreButton = screen.getByRole('button', { name: /Learn More/i })
|
||||
expect(learnMoreButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show warning banner when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/Security Features Unavailable/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Security Layer Cards', () => {
|
||||
it('should display all 4 security layer cards', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display CrowdSec card title', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display Access Control card title', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access Control')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display Coraza WAF card title', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Coraza WAF')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display Rate Limiting card title', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rate Limiting')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Toggle Switches Disabled State', () => {
|
||||
it('should disable all toggles when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-waf')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should enable toggles when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-acl')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-rate-limit')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service Toggle Badges', () => {
|
||||
it('should show Enabled badges for enabled services', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
const enabledBadges = screen.getAllByText('Enabled')
|
||||
expect(enabledBadges.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Disabled badge for disabled CrowdSec', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
const disabledBadges = screen.getAllByText('Disabled')
|
||||
expect(disabledBadges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Header Actions', () => {
|
||||
it('should render Audit Logs button', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Audit Logs/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Notifications button', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Notifications/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Docs button', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Docs/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep Notifications button enabled when Cerberus is disabled (navigation-only)', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Notifications/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to notifications settings when Notifications button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Notifications/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Notifications/i }))
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/notifications')
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: CrowdSec Bouncer Key Display moved to CrowdSecConfig page (Sprint 3)
|
||||
// Tests for bouncer key display are now in CrowdSecConfig tests
|
||||
|
||||
describe('Live Log Viewer', () => {
|
||||
it('should show live log viewer when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('live-log-viewer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show live log viewer when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('live-log-viewer')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Whitelist', () => {
|
||||
it('should display admin whitelist section when Cerberus is enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Admin Whitelist')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should load admin whitelist value from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show admin whitelist when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCerberusDisabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Admin Whitelist')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have Save and Generate Token buttons', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Generate Token/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdSec Status Display', () => {
|
||||
it('should show running status with PID when CrowdSec is running', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 5678, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Running \(pid 5678\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show process stopped when CrowdSec is not running', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Process stopped/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service Toggle Interactions', () => {
|
||||
it('should toggle ACL when switch is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
acl: { enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.acl.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle WAF when switch is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
waf: { mode: 'enabled' as const, enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.waf.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle Rate Limiting when switch is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatusAllEnabled,
|
||||
rate_limit: { enabled: false },
|
||||
})
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
await user.click(screen.getByTestId('toggle-rate-limit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.rate_limit.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdSec Power Toggle', () => {
|
||||
it('should start CrowdSec when toggle is turned on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ status: 'started', pid: 123, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
await user.click(screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.crowdsec.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop CrowdSec when toggle is turned off', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
await user.click(screen.getByTestId('toggle-crowdsec'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.crowdsec.enabled',
|
||||
'false',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Notification Settings Modal', () => {
|
||||
// Skip: Modal component uses WebSocket connections internally
|
||||
it.skip('should open notification settings modal when button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Notifications/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Notifications/i }))
|
||||
|
||||
// Modal should open - look for modal content
|
||||
await waitFor(() => {
|
||||
// The modal has a title "Notification Settings"
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Documentation Link', () => {
|
||||
it('should open docs link when Docs button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Docs/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Docs/i }))
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith('https://wikid82.github.io/charon/security', '_blank')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Security Loading Overlay Tests
|
||||
* Test IDs: LS-01 through LS-10
|
||||
*
|
||||
* Tests ConfigReloadOverlay appears during operations, specific loading messages,
|
||||
* and overlay blocks interactions.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// Test Data Fixtures
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
const mockSecurityStatusCrowdsecDisabled = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: false },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
// BLOCKER 3: Temporarily skipped due to undici InvalidArgumentError in WebSocket mocks
|
||||
describe.skip('Security Loading Overlay Tests', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('LS-01: Initial Page Load Shows Loading Text', () => {
|
||||
it('should show Skeleton components during initial load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Loading state now uses Skeleton components instead of text
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-02: Toggling Service Shows CerberusLoader Overlay', () => {
|
||||
it('should show ConfigReloadOverlay with type="cerberus" when toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
// Never-resolving promise to keep loading state
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cerberus configuration updating/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-03: Starting CrowdSec Shows "Summoning the guardian..."', () => {
|
||||
it('should show specific message for CrowdSec start operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusCrowdsecDisabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
// Never-resolving promise to keep loading state
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/CrowdSec is starting/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-04: Stopping CrowdSec Shows "Guardian rests..."', () => {
|
||||
it('should show specific message for CrowdSec stop operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
// Never-resolving promise to keep loading state
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/CrowdSec is stopping/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-05: WAF Config Operations Show Overlay', () => {
|
||||
it('should show overlay when toggling WAF', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-06: Rate Limiting Toggle Shows Overlay', () => {
|
||||
it('should show overlay when toggling rate limiting', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
await user.click(screen.getByTestId('toggle-rate-limit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cerberus configuration updating/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-07: ACL Toggle Shows Overlay', () => {
|
||||
it('should show overlay when toggling ACL', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
await user.click(screen.getByTestId('toggle-acl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-08: Overlay Contains CerberusLoader Component', () => {
|
||||
it('should render CerberusLoader animation within overlay', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
// The CerberusLoader has role="status" with aria-label="Security Loading"
|
||||
expect(screen.getByRole('status', { name: /Security Loading/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-09: Overlay Blocks Interactions', () => {
|
||||
it('should show overlay during toggle operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the fixed overlay is present (it has class "fixed inset-0")
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have z-50 overlay that covers content', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
await waitFor(() => {
|
||||
const overlay = document.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LS-10: Overlay Disappears on Mutation Success', () => {
|
||||
it('should remove overlay after toggle completes successfully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
|
||||
// First call - resolves quickly to simulate successful toggle
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
// The overlay might flash briefly and disappear, so we verify no overlay after completion
|
||||
await user.click(screen.getByTestId('toggle-waf'))
|
||||
|
||||
// Wait for mutation to complete and overlay to disappear
|
||||
await waitFor(() => {
|
||||
const overlay = document.querySelector('.fixed.inset-0.bg-slate-900\\/70')
|
||||
// After successful mutation, overlay should be gone
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should not show overlay when mutation completes instantly', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// After successful load, no overlay should be present
|
||||
const overlay = document.querySelector('.fixed.inset-0.bg-slate-900\\/70')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as api from '../../api/security'
|
||||
import type { SecurityStatus, RuleSetsResponse } from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
import * as logsApi from '../../api/logs'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/crowdsec')
|
||||
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',
|
||||
security_waf_enabled: true,
|
||||
security_acl_enabled: true,
|
||||
security_rate_limit_enabled: true,
|
||||
webhook_url: '',
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useUpdateSecurityNotificationSettings: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultFeatureFlags = {
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
}
|
||||
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const createQueryClient = (initialData = []) => createTestQueryClient([
|
||||
{ key: ['securityConfig'], data: mockSecurityConfig },
|
||||
{ key: ['securityRulesets'], data: mockRuleSets },
|
||||
{ key: ['feature-flags'], data: defaultFeatureFlags },
|
||||
...initialData,
|
||||
])
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode, initialData = []) => {
|
||||
const qc = createQueryClient(initialData)
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockSecurityConfig = {
|
||||
config: {
|
||||
name: 'default',
|
||||
waf_mode: 'block',
|
||||
waf_rules_source: '',
|
||||
admin_whitelist: '',
|
||||
},
|
||||
}
|
||||
|
||||
const mockRuleSets: RuleSetsResponse = {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'uuid-1', name: 'OWASP CRS', source_url: '', mode: 'blocking', last_updated: '', content: '' },
|
||||
{ id: 2, uuid: 'uuid-2', name: 'Custom Rules', source_url: '', mode: 'detection', last_updated: '', content: '' },
|
||||
],
|
||||
}
|
||||
// Types already imported at top-level; avoid duplicate declarations
|
||||
describe('Security page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(api.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
vi.mocked(api.updateSecurityConfig).mockResolvedValue({})
|
||||
// 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',
|
||||
current_key_preview: '...',
|
||||
message: 'OK'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows banner when all services are disabled and links to docs', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValueOnce(status as SecurityStatus)
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValueOnce({
|
||||
...status,
|
||||
crowdsec: { ...status.crowdsec, enabled: true }
|
||||
} as SecurityStatus)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
expect(await screen.findByText('Security Features Unavailable')).toBeInTheDocument()
|
||||
const docBtns = screen.getAllByText('Learn More')
|
||||
expect(docBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders per-service toggles and calls updateSetting on change', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement
|
||||
expect(crowdsecToggle.disabled).toBe(false)
|
||||
// Ensure enable-all controls were removed
|
||||
expect(screen.queryByTestId('enable-all-btn')).toBeNull()
|
||||
})
|
||||
|
||||
it('calls updateSetting when toggling ACL', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const updateSpy = vi.mocked(settingsApi.updateSetting)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const aclToggle = screen.getByTestId('toggle-acl')
|
||||
await userEvent.click(aclToggle)
|
||||
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
// Export button is in CrowdSecConfig component, not Security page
|
||||
|
||||
it('calls start/stop endpoints for CrowdSec via toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ status: 'started', pid: 123, lapi_ready: true })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
|
||||
|
||||
cleanup()
|
||||
|
||||
const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const stopToggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(stopToggle)
|
||||
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('disables service toggles when cerberus is off', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Features Unavailable')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeDisabled()
|
||||
})
|
||||
|
||||
it('displays correct WAF threat protection summary when enabled', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: true, mode: 'enabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
vi.mocked(api.getSecurityConfig).mockResolvedValue({
|
||||
config: { ...mockSecurityConfig.config, waf_mode: 'monitor' },
|
||||
})
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
// WAF now shows threat protection summary instead of mode text
|
||||
await waitFor(() => expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,455 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// BLOCKER 3: Temporarily skipped due to undici InvalidArgumentError in WebSocket mocks
|
||||
describe.skip('Security', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true }
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading state initially', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
// Loading state now uses Skeleton components instead of text
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show error if security status fails to load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Failed to load security configuration/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should render Cerberus Dashboard when status loads', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show banner when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Security Features Unavailable/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service Toggles', () => {
|
||||
it('should toggle CrowdSec on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle WAF on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle ACL on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
const toggle = screen.getByTestId('toggle-acl')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle Rate Limiting on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
const toggle = screen.getByTestId('toggle-rate-limit')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.rate_limit.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Whitelist', () => {
|
||||
it('should load admin whitelist from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update admin whitelist on save', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockMutate = vi.fn()
|
||||
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /Save/i })
|
||||
await user.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledWith({ name: 'default', admin_whitelist: '10.0.0.0/8' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdSec Controls', () => {
|
||||
it('should start CrowdSec when toggling on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ status: 'started', pid: 123, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool')
|
||||
expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop CrowdSec when toggling off', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool')
|
||||
expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
// Note: WAF Controls tests removed - dropdowns moved to dedicated WAF config page (/security/waf)
|
||||
|
||||
describe('Card Order (Pipeline Sequence)', () => {
|
||||
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get all card headings (CardTitle uses text-base class)
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
|
||||
// Verify pipeline order: Admin Whitelist + CrowdSec (Layer 1) → ACL (Layer 2) → Coraza (Layer 3) → Rate Limiting (Layer 4) + Security Access Logs
|
||||
expect(cardNames).toEqual(['Admin Whitelist', 'CrowdSec', 'Access Control', 'Coraza WAF', 'Rate Limiting', 'Security Access Logs'])
|
||||
})
|
||||
|
||||
it('should display layer indicators on each card', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Layer indicators are now Badges with just the layer number
|
||||
expect(screen.getByText('Layer 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Layer 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display threat protection summaries', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
// CrowdSec must be running to show threat protection descriptions
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading Overlay', () => {
|
||||
it('should show overlay when service is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when stopping CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234, lapi_ready: true })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Optimistic Update Mode Preservation', () => {
|
||||
it('should preserve waf.mode field when toggling WAF enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
// WAF status includes mode field that must be preserved
|
||||
const statusWithWafMode = {
|
||||
...mockSecurityStatus,
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
}
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(statusWithWafMode)
|
||||
// Make mutation take time so we can check optimistic update state
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
// Verify that updateSetting was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.waf.enabled',
|
||||
'false', // toggling from true to false
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
|
||||
// The query client's cached data should still have mode field preserved
|
||||
// Note: We verify that the mutation was called correctly, and the implementation
|
||||
// uses spread operator to preserve mode field during optimistic update
|
||||
})
|
||||
|
||||
it('should preserve rate_limit.mode field when toggling Rate Limit enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Rate limit status includes mode field that must be preserved
|
||||
const statusWithRateLimitMode = {
|
||||
...mockSecurityStatus,
|
||||
rate_limit: { mode: 'enabled' as const, enabled: true },
|
||||
}
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(statusWithRateLimitMode)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 100))
|
||||
)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
|
||||
const toggle = screen.getByTestId('toggle-rate-limit')
|
||||
await user.click(toggle)
|
||||
|
||||
// Verify that updateSetting was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.rate_limit.enabled',
|
||||
'false', // toggling from true to false
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should rollback to previous state on mutation error', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
waf: { mode: 'enabled' as const, enabled: false },
|
||||
})
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
expect(toggle).not.toBeChecked() // initially disabled
|
||||
|
||||
await user.click(toggle)
|
||||
|
||||
// Verify updateSetting was called (mutation was triggered)
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.waf.enabled',
|
||||
'true',
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
|
||||
// After error, the toggle should rollback to initial state (unchecked)
|
||||
// The optimistic update should be reverted by the onError handler
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toggle-waf')).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle ACL toggle without mode field', async () => {
|
||||
const user = userEvent.setup()
|
||||
// ACL doesn't have mode field (only enabled)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
acl: { enabled: false },
|
||||
})
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
|
||||
const toggle = screen.getByTestId('toggle-acl')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.acl.enabled',
|
||||
'true', // toggling from false to true
|
||||
'security',
|
||||
'bool'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,934 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
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,
|
||||
type ScoreBreakdown,
|
||||
} from '../../api/securityHeaders';
|
||||
import { createBackup } from '../../api/backups';
|
||||
|
||||
vi.mock('../../api/securityHeaders');
|
||||
vi.mock('../../api/backups');
|
||||
vi.mock('react-hot-toast');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Security Headers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render empty state', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No custom profiles yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render list of profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Profile 1',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Profile 2',
|
||||
is_preset: false,
|
||||
security_score: 90,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Profile 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profile 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render presets', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Basic Security',
|
||||
description: 'Essential headers',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Strict Security',
|
||||
description: 'Strong security',
|
||||
is_preset: true,
|
||||
preset_type: 'strict',
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Basic Security')).toBeInTheDocument();
|
||||
expect(screen.getByText('Strict Security')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should open create form dialog', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Create Profile/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButton = screen.getAllByRole('button', { name: /Create Profile/ })[0];
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Create Security Header Profile/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should open edit dialog', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
|
||||
score: 85,
|
||||
max_score: 100,
|
||||
breakdown: {},
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole('button', { name: /Edit/ });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Edit Security Header Profile/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone profile', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Original Profile',
|
||||
description: 'Test description',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
hsts_enabled: true,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'Original Profile (Copy)',
|
||||
security_score: 85,
|
||||
} as SecurityHeaderProfile);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Original Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const cloneButton = buttons.find(btn => btn.querySelector('.lucide-copy'));
|
||||
if (cloneButton) {
|
||||
fireEvent.click(cloneButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityHeadersApi.createProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const createCall = vi.mocked(securityHeadersApi.createProfile).mock.calls[0][0];
|
||||
expect(createCall.name).toBe('Original Profile (Copy)');
|
||||
});
|
||||
|
||||
it('should delete profile with backup', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup.tar.gz' });
|
||||
vi.mocked(securityHeadersApi.deleteProfile).mockResolvedValue(undefined);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
}
|
||||
|
||||
// Confirm deletion - wait for the dialog to appear
|
||||
await waitFor(() => {
|
||||
const headings = screen.getAllByText(/Confirm Deletion/i);
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
}, { timeout: 2000 });
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Delete/i });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createBackup).toHaveBeenCalled();
|
||||
expect(securityHeadersApi.deleteProfile).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should separate quick presets from custom profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Custom Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Basic Security',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Profiles (Read-Only)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Profiles')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// System profiles should have View and Clone buttons
|
||||
const presetCard = screen.getByText('Basic Security').closest('div');
|
||||
expect(presetCard).toBeInTheDocument();
|
||||
|
||||
// Custom profile should have Edit button
|
||||
const customCard = screen.getByText('Custom Profile').closest('div');
|
||||
expect(customCard?.textContent).toContain('Custom Profile');
|
||||
});
|
||||
|
||||
it('should display security scores', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'High Score Profile',
|
||||
is_preset: false,
|
||||
security_score: 95,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('95')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Additional coverage tests for Phase 3
|
||||
|
||||
it('should display preset tooltip information', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Basic Security',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Basic Security')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find info icon and hover
|
||||
const infoButtons = screen.getAllByRole('button').filter(btn => {
|
||||
const svg = btn.querySelector('svg');
|
||||
return svg?.classList.contains('lucide-info');
|
||||
});
|
||||
|
||||
if (infoButtons.length > 0) {
|
||||
await user.hover(infoButtons[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should show view button for preset profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Strict Security',
|
||||
is_preset: true,
|
||||
preset_type: 'strict',
|
||||
security_score: 95,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /View/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close form when dialog is dismissed', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Create Profile/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButton = screen.getAllByRole('button', { name: /Create Profile/ })[0];
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Create Security Header Profile/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close dialog by pressing escape or clicking outside
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should sort preset profiles by security score', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Paranoid Security',
|
||||
is_preset: true,
|
||||
preset_type: 'paranoid',
|
||||
security_score: 100,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Basic Security',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'API Friendly',
|
||||
is_preset: true,
|
||||
preset_type: 'api-friendly',
|
||||
security_score: 75,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Basic Security')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify all presets are displayed
|
||||
expect(screen.getByText('Paranoid Security')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Friendly')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display updated date for profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-01-20T10:30:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Updated/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle clone button for custom profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Custom Profile',
|
||||
description: 'My custom config',
|
||||
is_preset: false,
|
||||
security_score: 80,
|
||||
hsts_enabled: true,
|
||||
hsts_max_age: 31536000,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'Custom Profile (Copy)',
|
||||
security_score: 80,
|
||||
} as SecurityHeaderProfile);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const cloneButton = buttons.find(btn => btn.querySelector('.lucide-copy'));
|
||||
if (cloneButton) {
|
||||
fireEvent.click(cloneButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityHeadersApi.createProfile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display profile descriptions', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
description: 'This is a test profile description',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('This is a test profile description')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle delete confirmation cancellation', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
}
|
||||
|
||||
// Wait for confirmation dialog
|
||||
await waitFor(() => {
|
||||
const headings = screen.getAllByText(/Confirm Deletion/i);
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Click cancel instead of delete
|
||||
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityHeadersApi.deleteProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show info alert with security configuration message', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Secure Your Applications/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Security headers protect against common web vulnerabilities/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display all three action buttons for custom profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Custom Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should have Edit button
|
||||
expect(screen.getByRole('button', { name: /Edit/i })).toBeInTheDocument();
|
||||
|
||||
// Should have Clone button (icon only)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const cloneButton = buttons.find(btn => btn.querySelector('.lucide-copy'));
|
||||
expect(cloneButton).toBeDefined();
|
||||
|
||||
// Should have Delete button (icon only)
|
||||
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle profile update submission', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Profile',
|
||||
is_preset: false,
|
||||
security_score: 85,
|
||||
hsts_enabled: true,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Updated Profile',
|
||||
security_score: 90,
|
||||
} as SecurityHeaderProfile);
|
||||
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
|
||||
score: 85,
|
||||
max_score: 100,
|
||||
breakdown: {},
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole('button', { name: /Edit/i });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Edit Security Header Profile/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display system profiles section title', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Basic Security',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as SecurityHeaderProfile[]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Profiles (Read-Only)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render empty state action in custom profiles section', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No custom profiles yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButtons = screen.getAllByRole('button', { name: /Create Profile/i });
|
||||
expect(createButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should close create dialog on success', async () => {
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue(
|
||||
createProfile({
|
||||
id: 1,
|
||||
name: 'New Profile',
|
||||
security_score: 50,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
})
|
||||
);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
const openBtn = screen.getAllByRole('button', { name: /Create Profile/i })[0];
|
||||
fireEvent.click(openBtn);
|
||||
|
||||
await waitFor(() => screen.getByText(/Create Security Header Profile/i));
|
||||
|
||||
// Fill required fields to enable submit
|
||||
const nameInput = screen.getByPlaceholderText(/e.g., Production Security Headers/i);
|
||||
fireEvent.change(nameInput, { target: { value: 'New Profile' } });
|
||||
|
||||
// Find submit button in dialog (it might have 'Create Profile' text or just 'Create')
|
||||
// Looking at SecurityHeaderProfileForm, it likely has a submit button.
|
||||
// We can assume it's the one with type="submit" or appropriate text.
|
||||
// Let's search for "Create Profile" button inside the dialog or just "Create".
|
||||
const submitBtn = screen.getByRole('button', { name: /Save Profile/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close edit dialog on success', async () => {
|
||||
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(createScoreBreakdown());
|
||||
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(mockProfiles[0]);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => screen.getByText('Edit Me'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
|
||||
|
||||
await waitFor(() => screen.getByText(/Edit Security Header Profile/i));
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Save Profile/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle delete failure', async () => {
|
||||
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'));
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => screen.getByText('Delete Me'));
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
|
||||
fireEvent.click(deleteButton!);
|
||||
|
||||
await waitFor(() => screen.getByText(/Confirm Deletion/i));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createBackup).toHaveBeenCalled();
|
||||
expect(securityHeadersApi.deleteProfile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle backup failure during delete', async () => {
|
||||
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'));
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => screen.getByText('Delete Me'));
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
|
||||
fireEvent.click(deleteButton!);
|
||||
|
||||
await waitFor(() => screen.getByText(/Confirm Deletion/i));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createBackup).toHaveBeenCalled();
|
||||
expect(securityHeadersApi.deleteProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown preset types', async () => {
|
||||
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() });
|
||||
|
||||
await waitFor(() => screen.getByText('Weird Preset'));
|
||||
// Just ensuring render doesn't crash
|
||||
});
|
||||
|
||||
it('should handle cancel in edit dialog', async () => {
|
||||
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(
|
||||
createScoreBreakdown({ score: 50, max_score: 50 })
|
||||
);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => screen.getByText('Edit Me'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
|
||||
|
||||
await waitFor(() => screen.getByText(/Edit Security Header Profile/i));
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle delete from edit dialog', async () => {
|
||||
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(
|
||||
createScoreBreakdown({ score: 50, max_score: 50 })
|
||||
);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => screen.getByText('Delete Me from Edit'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
|
||||
|
||||
await waitFor(() => screen.getByText(/Edit Security Header Profile/i));
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: /Delete Profile/i });
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => screen.getByText(/Confirm Deletion/i));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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)',
|
||||
'navigation.users': 'Users',
|
||||
}
|
||||
|
||||
const t = (key: string) => translations[key] ?? key
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({ user: { user_id: 1, role: 'admin', name: 'Admin' } }),
|
||||
}))
|
||||
|
||||
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="users" element={<div>Users 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 for admin', () => {
|
||||
renderWithRoute('/settings/notifications')
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
const labels = links.map(link => link.textContent)
|
||||
|
||||
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Users'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import Setup from '../Setup';
|
||||
import * as setupApi from '../../api/setup';
|
||||
|
||||
// Mock AuthContext so useAuth works in tests
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
performSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Setup Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders setup form when setup is required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify logo is present
|
||||
expect(screen.getAllByAltText('Charon').length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.getByLabelText('Name')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Email Address')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Password')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render form when setup is not required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Welcome to Charon')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form successfully', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText('Name'), 'Admin')
|
||||
await user.type(screen.getByLabelText('Email Address'), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText('Password'), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: 'Create Admin Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setupApi.performSetup).toHaveBeenCalledWith({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error on submission failure', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockRejectedValue({
|
||||
response: { data: { error: 'Setup failed' } }
|
||||
});
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText('Name'), 'Admin')
|
||||
await user.type(screen.getByLabelText('Email Address'), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText('Password'), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: 'Create Admin Account' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Setup failed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('has proper autocomplete attributes for password managers', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to Charon')).toBeTruthy();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText('Email Address')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
|
||||
expect(emailInput).toHaveAttribute('autocomplete', 'email')
|
||||
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,742 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import SystemSettings from '../SystemSettings'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
import client from '../../api/client'
|
||||
import { LanguageProvider } from '../../context/LanguageContext'
|
||||
|
||||
// Note: react-i18next mock is provided globally by src/test/setup.ts
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSetting: vi.fn(),
|
||||
validatePublicURL: vi.fn(),
|
||||
testPublicURL: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/featureFlags', () => ({
|
||||
getFeatureFlags: vi.fn(),
|
||||
updateFeatureFlags: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SystemSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mock responses
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
'caddy.keepalive_idle': '',
|
||||
'caddy.keepalive_count': '',
|
||||
'ui.domain_link_behavior': 'new_tab',
|
||||
'security.cerberus.enabled': 'false',
|
||||
})
|
||||
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
status: 'healthy',
|
||||
service: 'charon',
|
||||
version: '0.1.0',
|
||||
git_commit: 'abc123',
|
||||
build_time: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSL Provider Selection', () => {
|
||||
it('renders SSL Provider label', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays the correct help text for SSL provider', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Choose the Certificate Authority/i)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the SSL provider select trigger', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Radix UI Select uses a button as the trigger
|
||||
const selectTrigger = screen.getByRole('combobox', { name: /ssl provider/i })
|
||||
expect(selectTrigger).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays Auto as default selection', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Auto (Recommended)')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves SSL provider setting when save button is clicked', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const saveButtons = screen.getAllByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.ssl_provider',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Settings', () => {
|
||||
it('renders the page title', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Settings')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('loads and displays Caddy Admin API setting', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://custom:2019',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('http://localhost:2019') as HTMLInputElement
|
||||
expect(input.value).toBe('http://custom:2019')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads keepalive settings when present', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
'caddy.keepalive_idle': '2m',
|
||||
'caddy.keepalive_count': '5',
|
||||
'ui.domain_link_behavior': 'new_tab',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const keepaliveIdleInput = screen.getByLabelText('Keepalive Idle (Optional)') as HTMLInputElement
|
||||
const keepaliveCountInput = screen.getByLabelText('Keepalive Count (Optional)') as HTMLInputElement
|
||||
expect(keepaliveIdleInput.value).toBe('2m')
|
||||
expect(keepaliveCountInput.value).toBe('5')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders keepalive controls in General settings', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Keepalive Idle (Optional)')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Keepalive Count (Optional)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves all settings when save button is clicked', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Save Settings')).toHaveLength(2)
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const saveButtons = screen.getAllByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledTimes(6)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.admin_api',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.ssl_provider',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.keepalive_idle',
|
||||
'',
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.keepalive_count',
|
||||
'',
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'ui.domain_link_behavior',
|
||||
expect.any(String),
|
||||
'ui',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('saves keepalive settings when valid values are provided', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Keepalive Idle (Optional)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const keepaliveIdleInput = screen.getByLabelText('Keepalive Idle (Optional)')
|
||||
const keepaliveCountInput = screen.getByLabelText('Keepalive Count (Optional)')
|
||||
await user.clear(keepaliveIdleInput)
|
||||
await user.type(keepaliveIdleInput, '30s')
|
||||
await user.clear(keepaliveCountInput)
|
||||
await user.type(keepaliveCountInput, '3')
|
||||
|
||||
const saveButtons = screen.getAllByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.keepalive_idle',
|
||||
'30s',
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.keepalive_count',
|
||||
'3',
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('disables save when keepalive values are invalid', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Keepalive Idle (Optional)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const keepaliveIdleInput = screen.getByLabelText('Keepalive Idle (Optional)')
|
||||
await user.clear(keepaliveIdleInput)
|
||||
await user.type(keepaliveIdleInput, 'invalid-duration')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enter a valid duration (for example: 30s, 2m, 1h).')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const saveButtons = screen.getAllByRole('button', { name: /Save Settings/i })
|
||||
expect(saveButtons[0]).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('System Status', () => {
|
||||
it('displays system health information', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
status: 'healthy',
|
||||
service: 'charon',
|
||||
version: '1.0.0',
|
||||
git_commit: 'abc123def',
|
||||
build_time: '2025-12-06T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('charon')).toBeTruthy()
|
||||
expect(screen.getByText('1.0.0')).toBeTruthy()
|
||||
expect(screen.getByText('abc123def')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays System Status section', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Status')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Features', () => {
|
||||
it('renders the Features section', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Features')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays all feature flag toggles', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
expect(screen.getByText('CrowdSec Console Enrollment')).toBeTruthy()
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Cerberus toggle as checked when enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
expect(switchInput).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows Uptime toggle as checked when enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('checkbox', { name: /uptime monitoring toggle/i })
|
||||
expect(switchInput).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows Cerberus toggle as unchecked when disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
expect(switchInput).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('toggles Cerberus feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({
|
||||
'feature.cerberus.enabled': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles CrowdSec Console Enrollment feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CrowdSec Console Enrollment')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /crowdsec console enrollment toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({
|
||||
'feature.crowdsec.console_enrollment': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles Uptime feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /uptime monitoring toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(featureFlagsApi.updateFeatureFlags).toHaveBeenCalledWith({
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading skeleton when feature flags are not loaded', async () => {
|
||||
// Set settings to resolve but feature flags to never resolve (pending state)
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
'ui.domain_link_behavior': 'new_tab',
|
||||
})
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
// When featureFlags is undefined but settings is loaded, it shows skeleton in the Features card
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Features')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Verify skeleton elements are rendered (Skeleton component uses animate-pulse class)
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows loading overlay while toggling a feature flag', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.crowdsec.console_enrollment': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const switchInput = screen.getByRole('checkbox', { name: /cerberus security suite toggle/i })
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Updating features...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Application URL Card', () => {
|
||||
it('renders public URL input field', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows green border and checkmark when URL is valid', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
// Mock validation response for valid URL
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: { valid: true, normalized: 'https://example.com' },
|
||||
})
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
// Wait for debounced validation
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', {
|
||||
url: 'https://example.com',
|
||||
})
|
||||
}, { timeout: 1000 })
|
||||
|
||||
await waitFor(() => {
|
||||
const checkIcon = document.querySelector('.text-green-500')
|
||||
expect(checkIcon).toBeTruthy()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.className).toContain('border-green-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows red border and X icon when URL is invalid', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
// Mock validation response for invalid URL
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: { valid: false, error: 'Invalid URL format' },
|
||||
})
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'invalid-url')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', {
|
||||
url: 'invalid-url',
|
||||
})
|
||||
}, { timeout: 1000 })
|
||||
|
||||
await waitFor(() => {
|
||||
const xIcon = document.querySelector('.text-red-500')
|
||||
expect(xIcon).toBeTruthy()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.className).toContain('border-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows invalid URL error message when validation fails', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: { valid: false, error: 'Invalid URL format' },
|
||||
})
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'bad-url')
|
||||
|
||||
// Wait for debounce and validation
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for red border class indicating invalid state
|
||||
const inputElement = screen.getByPlaceholderText('https://charon.example.com')
|
||||
expect(inputElement.className).toContain('border-red')
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('clears validation state when URL is cleared', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'app.public_url': 'https://example.com',
|
||||
})
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: { status: 'healthy', service: 'charon', version: '1.0.0' },
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com') as HTMLInputElement
|
||||
expect(input.value).toBe('https://example.com')
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
await user.clear(input)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.className).not.toContain('border-green-500')
|
||||
expect(input.className).not.toContain('border-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders test button and verifies functionality', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'app.public_url': 'https://example.com',
|
||||
})
|
||||
vi.mocked(settingsApi.testPublicURL).mockResolvedValue({
|
||||
reachable: true,
|
||||
latency: 42,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find test button by looking for buttons with External Link icon
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const testButton = buttons.find((btn) => btn.querySelector('.lucide-external-link'))
|
||||
expect(testButton).toBeTruthy()
|
||||
expect(testButton).not.toBeDisabled()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(testButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.testPublicURL).toHaveBeenCalledWith('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables test button when URL is empty', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'app.public_url': '',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com') as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const testButton = buttons.find((btn) => btn.querySelector('.lucide-external-link'))
|
||||
expect(testButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('handles validation API error gracefully', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('https://charon.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByPlaceholderText('https://charon.example.com')
|
||||
|
||||
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
const xIcon = document.querySelector('.text-red-500')
|
||||
expect(xIcon).toBeTruthy()
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Uptime from '../Uptime'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
vi.mock('../../api/uptime')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Uptime page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders no monitors message', async () => {
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([])
|
||||
renderWithProviders(<Uptime />)
|
||||
expect(await screen.findByText(/No monitors found/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls updateMonitor when toggling monitoring', async () => {
|
||||
const monitor = {
|
||||
id: 'm1', name: 'Test Monitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, enabled: false })
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('Test Monitor')).toBeInTheDocument())
|
||||
const card = screen.getByText('Test Monitor').closest('div') as HTMLElement
|
||||
const settingsBtn = within(card).getByTitle('Monitor settings')
|
||||
await userEvent.click(settingsBtn)
|
||||
const toggleBtn = within(card).getByText('Pause')
|
||||
await userEvent.click(toggleBtn)
|
||||
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m1', { enabled: false }))
|
||||
})
|
||||
|
||||
it('shows Never when last_check is missing', async () => {
|
||||
const monitor = {
|
||||
id: 'm2', name: 'NoLastCheck', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: null, latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('NoLastCheck')).toBeInTheDocument())
|
||||
const lastCheck = screen.getByText('Never')
|
||||
expect(lastCheck).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows PAUSED state when monitor is disabled', async () => {
|
||||
const monitor = {
|
||||
id: 'm3', name: 'PausedMonitor', url: 'http://example.com', type: 'http', interval: 60, enabled: false,
|
||||
status: 'down', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('PausedMonitor')).toBeInTheDocument())
|
||||
expect(screen.getByText('PAUSED')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders heartbeat bars from history and displays status in bar titles', async () => {
|
||||
const monitor = {
|
||||
id: 'm4', name: 'WithHistory', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
const now = new Date()
|
||||
const history = [
|
||||
{ id: 1, monitor_id: 'm4', status: 'up', latency: 10, message: 'OK', created_at: new Date(now.getTime() - 30000).toISOString() },
|
||||
{ id: 2, monitor_id: 'm4', status: 'down', latency: 20, message: 'Fail', created_at: new Date(now.getTime() - 20000).toISOString() },
|
||||
{ id: 3, monitor_id: 'm4', status: 'up', latency: 5, message: 'OK', created_at: new Date(now.getTime() - 10000).toISOString() },
|
||||
]
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue(history)
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('WithHistory')).toBeInTheDocument())
|
||||
|
||||
// Bar titles include 'Status:' and the status should be capitalized
|
||||
await waitFor(() => expect(document.querySelectorAll('[title*="Status:"]').length).toBeGreaterThanOrEqual(history.length))
|
||||
const barTitles = Array.from(document.querySelectorAll('[title*="Status:"]'))
|
||||
expect(barTitles.some(el => (el.getAttribute('title') || '').includes('Status: UP'))).toBeTruthy()
|
||||
expect(barTitles.some(el => (el.getAttribute('title') || '').includes('Status: DOWN'))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pause button is yellow and appears before delete in settings menu', async () => {
|
||||
const monitor = {
|
||||
id: 'm12', name: 'OrderTest', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('OrderTest')).toBeInTheDocument())
|
||||
const card = screen.getByText('OrderTest').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
|
||||
const configureBtn = within(card).getByText('Configure')
|
||||
// Find the menu container by traversing up until the absolute positioned menu is found
|
||||
let menuContainer: HTMLElement | null = configureBtn.parentElement
|
||||
while (menuContainer && !menuContainer.className.includes('absolute')) {
|
||||
menuContainer = menuContainer.parentElement
|
||||
}
|
||||
expect(menuContainer).toBeTruthy()
|
||||
const buttons = Array.from(menuContainer!.querySelectorAll('button'))
|
||||
const pauseBtn = buttons.find(b => b.textContent?.trim() === 'Pause')
|
||||
const deleteBtn = buttons.find(b => b.textContent?.trim() === 'Delete')
|
||||
expect(pauseBtn).toBeTruthy()
|
||||
expect(deleteBtn).toBeTruthy()
|
||||
// Ensure Pause appears before Delete
|
||||
expect(buttons.indexOf(pauseBtn!)).toBeLessThan(buttons.indexOf(deleteBtn!))
|
||||
// Ensure Pause has yellow styling class
|
||||
expect(pauseBtn!.className).toContain('text-yellow-600')
|
||||
})
|
||||
|
||||
it('deletes monitor when delete confirmed and shows toast', async () => {
|
||||
const monitor = {
|
||||
id: 'm5', name: 'DeleteMe', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.deleteMonitor).mockResolvedValue(undefined)
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('DeleteMe')).toBeInTheDocument())
|
||||
const card = screen.getByText('DeleteMe').closest('div') as HTMLElement
|
||||
const settingsBtn = within(card).getByTitle('Monitor settings')
|
||||
await userEvent.click(settingsBtn)
|
||||
const deleteBtn = within(card).getByText('Delete')
|
||||
await userEvent.click(deleteBtn)
|
||||
await waitFor(() => expect(uptimeApi.deleteMonitor).toHaveBeenCalledWith('m5'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens configure modal and saves changes via updateMonitor', async () => {
|
||||
const monitor = {
|
||||
id: 'm6', name: 'ConfigMe', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, max_retries: 6 })
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('ConfigMe')).toBeInTheDocument())
|
||||
const card = screen.getByText('ConfigMe').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
await userEvent.click(within(card).getByText('Configure'))
|
||||
// Modal should open
|
||||
await waitFor(() => expect(screen.getByText('Configure Monitor')).toBeInTheDocument())
|
||||
const spinbuttons = screen.getAllByRole('spinbutton')
|
||||
const maxRetriesInput = spinbuttons.find(el => el.getAttribute('value') === '3') as HTMLInputElement
|
||||
await userEvent.clear(maxRetriesInput)
|
||||
await userEvent.type(maxRetriesInput, '6')
|
||||
await userEvent.clear(screen.getByLabelText('Name'))
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Renamed Monitor')
|
||||
await userEvent.click(screen.getByText('Save Changes'))
|
||||
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m6', { name: 'Renamed Monitor', max_retries: 6, interval: 60 }))
|
||||
})
|
||||
|
||||
it('does not call deleteMonitor when canceling delete', async () => {
|
||||
const monitor = {
|
||||
id: 'm7', name: 'DoNotDelete', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.deleteMonitor).mockResolvedValue(undefined)
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => false)
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('DoNotDelete')).toBeInTheDocument())
|
||||
const card = screen.getByText('DoNotDelete').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
await userEvent.click(within(card).getByText('Delete'))
|
||||
expect(uptimeApi.deleteMonitor).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows toast error when toggle update fails', async () => {
|
||||
const monitor = {
|
||||
id: 'm8', name: 'ToggleFail', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockRejectedValue(new Error('Update failed'))
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('ToggleFail')).toBeInTheDocument())
|
||||
const card = screen.getByText('ToggleFail').closest('div') as HTMLElement
|
||||
await userEvent.click(within(card).getByTitle('Monitor settings'))
|
||||
await userEvent.click(within(card).getByText('Pause'))
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('separates monitors into Proxy Hosts, Remote Servers and Other sections', async () => {
|
||||
const proxyMonitor = { id: 'm9', name: 'ProxyMon', url: 'http://p', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 1, max_retries: 2, proxy_host_id: 1 }
|
||||
const remoteMonitor = { id: 'm10', name: 'RemoteMon', url: 'http://r', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 2, max_retries: 2, remote_server_id: 2 }
|
||||
const otherMonitor = { id: 'm11', name: 'OtherMon', url: 'http://o', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 3, max_retries: 2 }
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([proxyMonitor, remoteMonitor, otherMonitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('Proxy Hosts')).toBeInTheDocument())
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other Monitors')).toBeInTheDocument()
|
||||
expect(screen.getByText('ProxyMon')).toBeInTheDocument()
|
||||
expect(screen.getByText('RemoteMon')).toBeInTheDocument()
|
||||
expect(screen.getByText('OtherMon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows CHECKING... state for pending monitor with no history', async () => {
|
||||
const monitor = {
|
||||
id: 'm13', name: 'PendingMonitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'pending', last_check: null, latency: 0, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('PendingMonitor')).toBeInTheDocument())
|
||||
const badge = screen.getByTestId('status-badge')
|
||||
expect(badge).toHaveAttribute('data-status', 'pending')
|
||||
expect(badge).toHaveAttribute('role', 'status')
|
||||
expect(badge.textContent).toContain('CHECKING...')
|
||||
expect(badge.className).toContain('bg-amber-100')
|
||||
expect(badge.className).toContain('animate-pulse')
|
||||
expect(screen.getByText('Waiting for first check...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats pending monitor with heartbeat history as normal (not pending)', async () => {
|
||||
const monitor = {
|
||||
id: 'm14', name: 'PendingWithHistory', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'pending', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
const history = [
|
||||
{ id: 1, monitor_id: 'm14', status: 'up', latency: 10, message: 'OK', created_at: new Date().toISOString() },
|
||||
]
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue(history)
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('PendingWithHistory')).toBeInTheDocument())
|
||||
await waitFor(() => {
|
||||
const badge = screen.getByTestId('status-badge')
|
||||
expect(badge.textContent).not.toContain('CHECKING...')
|
||||
expect(badge.className).toContain('bg-green-100')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows DOWN indicator for down monitor (no regression)', async () => {
|
||||
const monitor = {
|
||||
id: 'm15', name: 'DownMonitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'down', last_check: new Date().toISOString(), latency: 0, max_retries: 3,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('DownMonitor')).toBeInTheDocument())
|
||||
const badge = screen.getByTestId('status-badge')
|
||||
expect(badge).toHaveAttribute('data-status', 'down')
|
||||
expect(badge.textContent).toContain('DOWN')
|
||||
expect(badge.className).toContain('bg-red-100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Uptime from '../Uptime'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import type { UptimeMonitor } from '../../api/uptime'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'uptime.title': 'Uptime Monitoring',
|
||||
'uptime.loadingMonitors': 'Loading monitors...',
|
||||
'uptime.noMonitorsFound': 'No monitors found',
|
||||
'uptime.syncWithHosts': 'Sync with Hosts',
|
||||
'uptime.syncing': 'Syncing...',
|
||||
'uptime.addMonitor': 'Add Monitor',
|
||||
'uptime.autoRefreshing': 'Auto-refreshing every 30s',
|
||||
'uptime.proxyHosts': 'Proxy Hosts',
|
||||
'uptime.remoteServers': 'Remote Servers',
|
||||
'uptime.otherMonitors': 'Other Monitors',
|
||||
'uptime.latency': 'Latency',
|
||||
'uptime.lastCheck': 'Last Check',
|
||||
'uptime.never': 'Never',
|
||||
'uptime.configureMonitor': 'Configure Monitor',
|
||||
'uptime.createMonitor': 'Create Monitor',
|
||||
'uptime.monitorSettings': 'Monitor Settings',
|
||||
'uptime.triggerHealthCheck': 'Trigger Health Check',
|
||||
'uptime.paused': 'Paused',
|
||||
'uptime.pause': 'Pause',
|
||||
'uptime.unpause': 'Resume',
|
||||
'uptime.maxRetries': 'Max Retries',
|
||||
'uptime.maxRetriesHelper': 'Number of retries before marking as down',
|
||||
'uptime.checkInterval': 'Check Interval',
|
||||
'uptime.saveChanges': 'Save Changes',
|
||||
'uptime.monitorUrl': 'Monitor URL',
|
||||
'uptime.urlPlaceholder': 'https://example.com or host:port',
|
||||
'uptime.monitorType': 'Monitor Type',
|
||||
'uptime.monitorTypeHttp': 'HTTP(S)',
|
||||
'uptime.monitorTypeTcp': 'TCP',
|
||||
'uptime.noHistoryAvailable': 'No history available',
|
||||
'uptime.last60Checks': 'Last 60 Checks',
|
||||
'common.configure': 'Configure',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.create': 'Create',
|
||||
'common.saving': 'Saving...',
|
||||
'common.name': 'Name',
|
||||
'common.close': 'Close',
|
||||
}
|
||||
if (options && typeof options === 'object') {
|
||||
let result = translations[key] || key
|
||||
Object.entries(options).forEach(([k, v]) => {
|
||||
result = result.replace(`{{${k}}}`, String(v))
|
||||
})
|
||||
return result
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock uptime API
|
||||
vi.mock('../../api/uptime', () => ({
|
||||
getMonitors: vi.fn(),
|
||||
getMonitorHistory: vi.fn(),
|
||||
updateMonitor: vi.fn(),
|
||||
deleteMonitor: vi.fn(),
|
||||
checkMonitor: vi.fn(),
|
||||
createMonitor: vi.fn(),
|
||||
syncMonitors: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockProxyHostMonitor: UptimeMonitor = {
|
||||
id: 'monitor-1',
|
||||
name: 'Example App',
|
||||
type: 'http',
|
||||
url: 'https://app.example.com',
|
||||
interval: 60,
|
||||
enabled: true,
|
||||
status: 'up',
|
||||
latency: 42,
|
||||
max_retries: 3,
|
||||
proxy_host_id: 1,
|
||||
last_check: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const mockRemoteServerMonitor: UptimeMonitor = {
|
||||
id: 'monitor-2',
|
||||
name: 'Database Server',
|
||||
type: 'tcp',
|
||||
url: 'db.example.com:5432',
|
||||
interval: 60,
|
||||
enabled: true,
|
||||
status: 'up',
|
||||
latency: 15,
|
||||
max_retries: 3,
|
||||
remote_server_id: 1,
|
||||
last_check: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const mockOtherMonitor: UptimeMonitor = {
|
||||
id: 'monitor-3',
|
||||
name: 'External API',
|
||||
type: 'http',
|
||||
url: 'https://api.external.com/health',
|
||||
interval: 120,
|
||||
enabled: true,
|
||||
status: 'down',
|
||||
latency: 0,
|
||||
max_retries: 5,
|
||||
last_check: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const mockPausedMonitor: UptimeMonitor = {
|
||||
id: 'monitor-4',
|
||||
name: 'Paused Service',
|
||||
type: 'http',
|
||||
url: 'https://paused.example.com',
|
||||
interval: 60,
|
||||
enabled: false,
|
||||
status: 'up',
|
||||
latency: 100,
|
||||
max_retries: 3,
|
||||
}
|
||||
|
||||
describe('Uptime page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders loading state', async () => {
|
||||
const { getMonitors } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
expect(screen.getByText('Loading monitors...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to DOWN status when monitor status is unknown', async () => {
|
||||
const { getMonitors, getMonitorHistory } = await import('../../api/uptime')
|
||||
const monitor = {
|
||||
id: 'm-unknown-status', name: 'UnknownStatusMonitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'mystery', last_check: new Date().toISOString(), latency: 10, max_retries: 3,
|
||||
}
|
||||
vi.mocked(getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('UnknownStatusMonitor')).toBeInTheDocument())
|
||||
|
||||
const badge = screen.getByTestId('status-badge')
|
||||
expect(badge).toHaveAttribute('data-status', 'down')
|
||||
expect(badge).toHaveTextContent('DOWN')
|
||||
})
|
||||
|
||||
it('renders empty state when no monitors exist', async () => {
|
||||
const { getMonitors } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No monitors found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders page title and header actions', async () => {
|
||||
const { getMonitors } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('sync-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-monitor-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders monitors grouped by type', async () => {
|
||||
const { getMonitors, getMonitorHistory } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([
|
||||
mockProxyHostMonitor,
|
||||
mockRemoteServerMonitor,
|
||||
mockOtherMonitor,
|
||||
])
|
||||
vi.mocked(getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other Monitors')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens create monitor modal when add button clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { getMonitors } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-monitor-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('add-monitor-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Create Monitor')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByLabelText(/Name/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Monitor URL/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Monitor Type/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays monitor cards with status badges', async () => {
|
||||
const { getMonitors, getMonitorHistory } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([mockProxyHostMonitor, mockOtherMonitor])
|
||||
vi.mocked(getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Example App')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('External API')).toBeInTheDocument()
|
||||
|
||||
// Check status badges exist
|
||||
const statusBadges = screen.getAllByTestId('status-badge')
|
||||
expect(statusBadges.length).toBe(2)
|
||||
})
|
||||
|
||||
it('displays paused status for disabled monitors', async () => {
|
||||
const { getMonitors, getMonitorHistory } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([mockPausedMonitor])
|
||||
vi.mocked(getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Paused Service')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('status-badge')).toHaveAttribute('data-status', 'paused')
|
||||
})
|
||||
|
||||
it('shows latency and last check information', async () => {
|
||||
const { getMonitors, getMonitorHistory } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([mockProxyHostMonitor])
|
||||
vi.mocked(getMonitorHistory).mockResolvedValue([])
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('42ms')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('last-check')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles sync button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { getMonitors, syncMonitors } = await import('../../api/uptime')
|
||||
vi.mocked(getMonitors).mockResolvedValue([])
|
||||
vi.mocked(syncMonitors).mockResolvedValue({ message: 'Synced 2 monitors' })
|
||||
|
||||
renderWithQueryClient(<Uptime />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sync-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('sync-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(syncMonitors).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,867 @@
|
||||
import { screen, waitFor, within, fireEvent } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import UsersPage from '../UsersPage'
|
||||
import * as usersApi from '../../api/users'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import client from '../../api/client'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { toast } from '../../utils/toast'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
|
||||
// Mock APIs
|
||||
vi.mock('../../api/users', () => ({
|
||||
listUsers: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
inviteUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
updateUserPermissions: vi.fn(),
|
||||
validateInvite: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
previewInviteURL: vi.fn(),
|
||||
resendInvite: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
regenerateApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: vi.fn().mockReturnValue({
|
||||
user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
|
||||
changePassword: vi.fn().mockResolvedValue(undefined),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: '123-456',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: 'admin' as const,
|
||||
enabled: true,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: '789-012',
|
||||
email: 'user@example.com',
|
||||
name: 'Regular User',
|
||||
role: 'user' as const,
|
||||
enabled: true,
|
||||
invite_status: 'accepted' as const,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: '345-678',
|
||||
email: 'pending@example.com',
|
||||
name: '',
|
||||
role: 'user' as const,
|
||||
enabled: false,
|
||||
invite_status: 'pending' as const,
|
||||
permission_mode: 'deny_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: '999-000',
|
||||
email: 'passthrough@example.com',
|
||||
name: 'Passthrough User',
|
||||
role: 'passthrough' as const,
|
||||
enabled: true,
|
||||
invite_status: 'accepted' as const,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const mockProxyHosts = [
|
||||
{
|
||||
uuid: '1',
|
||||
name: 'Test Host',
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('UsersPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts)
|
||||
vi.mocked(toast.success).mockClear()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
})
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(usersApi.listUsers).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
expect(document.querySelector('.animate-spin')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders user list', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User Management')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('Admin User').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('admin@example.com').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
expect(screen.getByText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows pending invite status', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pending Invite')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows active status for accepted users', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens invite modal when clicking invite button', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invite User')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows permission mode in user list', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Blacklist').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Whitelist')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles user enabled status', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'Updated' })
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find the switch for the non-admin user and toggle it
|
||||
const switches = screen.getAllByRole('checkbox')
|
||||
// The second switch should be for the regular user (admin switch is disabled)
|
||||
const userSwitch = switches.find(
|
||||
(sw) => !(sw as HTMLInputElement).disabled && (sw as HTMLInputElement).checked
|
||||
)
|
||||
|
||||
if (userSwitch) {
|
||||
const user = userEvent.setup()
|
||||
await user.click(userSwitch)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateUser).toHaveBeenCalledWith(2, { enabled: false })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('invites a new user', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.inviteUser).mockResolvedValue({
|
||||
id: 4,
|
||||
uuid: 'new-user',
|
||||
email: 'new@example.com',
|
||||
role: 'user',
|
||||
invite_token_masked: '********',
|
||||
invite_url: '[REDACTED]',
|
||||
email_sent: false,
|
||||
expires_at: '2024-01-03T00:00:00Z',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invite User')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
// Wait for modal to open - look for the modal's email input placeholder
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
await user.type(screen.getByPlaceholderText('user@example.com'), 'new@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /^Send Invite$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.inviteUser).toHaveBeenCalledWith({
|
||||
email: 'new@example.com',
|
||||
role: 'user',
|
||||
permission_mode: 'allow_all',
|
||||
permitted_hosts: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a user after confirmation', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.deleteUser).mockResolvedValue({ message: 'Deleted' })
|
||||
|
||||
// Mock window.confirm
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Find delete buttons (trash icons) - admin user's delete button is disabled
|
||||
const deleteButtons = screen.getAllByTitle('Delete User')
|
||||
// Find the first non-disabled delete button
|
||||
const enabledDeleteButton = deleteButtons.find((btn) => !(btn as HTMLButtonElement).disabled)
|
||||
|
||||
expect(enabledDeleteButton).toBeTruthy()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(enabledDeleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete this user?')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.deleteUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates user permissions from the modal', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUserPermissions).mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
|
||||
|
||||
const editButtons = screen.getAllByTitle('Edit Permissions')
|
||||
const firstEditable = editButtons.find((btn) => !(btn as HTMLButtonElement).disabled)
|
||||
expect(firstEditable).toBeTruthy()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(firstEditable!)
|
||||
|
||||
const modal = await screen.findByText(/Edit Permissions/i)
|
||||
const modalContainer = modal.closest('.bg-dark-card') as HTMLElement
|
||||
|
||||
// Switch to whitelist (deny_all) and toggle first host
|
||||
const modeSelect = within(modalContainer).getByDisplayValue('Allow All (Blacklist)')
|
||||
await user.selectOptions(modeSelect, 'deny_all')
|
||||
const checkbox = within(modalContainer).getByLabelText(/Test Host/) as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
await user.click(checkbox)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Permissions' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateUserPermissions).toHaveBeenCalledWith(2, {
|
||||
permission_mode: 'deny_all',
|
||||
permitted_hosts: expect.arrayContaining([expect.any(Number)]),
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Permissions updated')
|
||||
})
|
||||
})
|
||||
|
||||
it('hides invite link when backend returns a redacted URL', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.inviteUser).mockResolvedValue({
|
||||
id: 5,
|
||||
uuid: 'invitee',
|
||||
email: 'manual@example.com',
|
||||
role: 'user',
|
||||
invite_token_masked: '********',
|
||||
invite_url: '[REDACTED]',
|
||||
email_sent: false,
|
||||
expires_at: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await user.type(screen.getByPlaceholderText('user@example.com'), 'manual@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /^Send Invite$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /copy invite link/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('[REDACTED]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders passthrough role badge', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Passthrough')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders My Profile card for current user', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Profile')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows passthrough option in invite role select', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeTruthy())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const roleSelect = screen.getByLabelText('Role') as HTMLSelectElement
|
||||
const options = Array.from(roleSelect.options).map(o => o.value)
|
||||
expect(options).toContain('passthrough')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens detail modal when edit button is clicked', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeTruthy())
|
||||
|
||||
const user = userEvent.setup()
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: /Edit User/i })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Preview in InviteModal', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows URL preview when valid email is entered', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'https://charon.example.com',
|
||||
is_configured: true,
|
||||
warning: false,
|
||||
warning_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
|
||||
}, { timeout: 1000 })
|
||||
|
||||
// Look for the preview URL content with ellipsis replacing the token
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('https://charon.example.com/accept-invite?token=...')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('debounces URL preview for 500ms', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'https://example.com',
|
||||
is_configured: true,
|
||||
warning: false,
|
||||
warning_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
const user = userEvent.setup()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('user@example.com')).toBeInTheDocument())
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
|
||||
// Verify not called immediately
|
||||
expect(client.post).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(550)
|
||||
})
|
||||
|
||||
expect(client.post).toHaveBeenCalledTimes(1)
|
||||
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces sample token with ellipsis in preview', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'https://example.com',
|
||||
is_configured: true,
|
||||
warning: false,
|
||||
warning_message: '',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
const preview = screen.getByText('https://example.com/accept-invite?token=...')
|
||||
|
||||
expect(preview.textContent).toContain('...')
|
||||
expect(preview.textContent).not.toContain('SAMPLE_TOKEN_PREVIEW')
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('shows warning when not configured', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockResolvedValue({
|
||||
data: {
|
||||
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
|
||||
base_url: 'http://localhost:8080',
|
||||
is_configured: false,
|
||||
warning: true,
|
||||
warning_message: 'Application URL not configured',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
// Look for link to system settings
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toContain('/settings/system')
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('does not show preview when email is invalid', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'invalid')
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
})
|
||||
|
||||
// Preview should not be fetched or displayed
|
||||
expect(client.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles preview API error gracefully', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(client.post).mockRejectedValue(new Error('API error'))
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('user@example.com')
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
|
||||
// Wait for debounce
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
|
||||
}, { timeout: 1000 })
|
||||
|
||||
// Verify preview is not displayed after error
|
||||
const previewQuery = screen.queryByText(/accept-invite/)
|
||||
expect(previewQuery).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InviteModal role reset on close', () => {
|
||||
it('resets role to user when modal is closed', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument())
|
||||
|
||||
// Open invite modal
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument())
|
||||
|
||||
// Change role to passthrough
|
||||
await user.selectOptions(screen.getByLabelText(/Role/i), 'passthrough')
|
||||
expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('passthrough')
|
||||
|
||||
// Close via Cancel button (calls handleClose which resets role)
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }))
|
||||
|
||||
// Reopen modal — role should be reset to 'user'
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Role/i)).toBeInTheDocument())
|
||||
expect((screen.getByLabelText(/Role/i) as HTMLSelectElement).value).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDetailModal', () => {
|
||||
it('shows profile update error via toast', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUser).mockRejectedValue({
|
||||
response: { data: { error: 'Email already in use' } },
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
|
||||
|
||||
// Click Edit User for Regular User (second "Edit User" button in the table)
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1]) // index 1 = Regular User row
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Click Save
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles the password change section', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
// Click Edit User in My Profile card (opens with isSelf=true) — card button is first
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Password fields should not be visible until toggled
|
||||
expect(screen.queryByLabelText(/Current Password/i)).toBeNull()
|
||||
|
||||
// Click the Change Password toggle
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
|
||||
// Password fields should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits password change successfully', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Expand password section
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
|
||||
|
||||
// Fill matching passwords
|
||||
await user.type(screen.getByLabelText(/Current Password/i), 'oldpass123')
|
||||
await user.type(screen.getByLabelText(/^New Password/i), 'newpass456')
|
||||
await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456')
|
||||
|
||||
// Submit button (second "Change Password" button — the submit one)
|
||||
const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i })
|
||||
const submitButton = changePasswordButtons[changePasswordButtons.length - 1]
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast on password change failure', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
|
||||
changePassword: vi.fn().mockRejectedValue(new Error('Invalid current password')),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
})
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByLabelText(/Current Password/i), 'wrongpass')
|
||||
await user.type(screen.getByLabelText(/^New Password/i), 'newpass456')
|
||||
await user.type(screen.getByLabelText(/Confirm Password/i), 'newpass456')
|
||||
|
||||
const changePasswordButtons = screen.getAllByRole('button', { name: /Change Password/i })
|
||||
const submitButton = changePasswordButtons[changePasswordButtons.length - 1]
|
||||
await user.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Invalid current password')
|
||||
})
|
||||
})
|
||||
|
||||
it('regenerates API key when user confirms', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'old-****' } as never)
|
||||
vi.mocked(usersApi.regenerateApiKey).mockResolvedValue({ api_key_masked: 'new-****' } as never)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Regenerate API Key/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Regenerate API Key/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.regenerateApiKey).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('updates self profile and shows profile updated toast', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateProfile).mockResolvedValue({ message: 'ok' } as never)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'abc-****' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateProfile).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('Profile updated successfully')
|
||||
})
|
||||
})
|
||||
|
||||
it('updates non-self user profile and shows success toast', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'ok' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeInTheDocument())
|
||||
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /^Save$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersApi.updateUser).toHaveBeenCalledWith(2, expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
}))
|
||||
expect(toast.success).toHaveBeenCalledWith('Profile updated successfully')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays masked API key text when profile query resolves', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: 'SK-****-masktest' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SK-****-masktest')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows password mismatch alert when new and confirm passwords differ', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.getProfile).mockResolvedValue({ api_key_masked: '' } as never)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('My Profile')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Edit User/i })[0])
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: /Change Password/i })[0])
|
||||
await waitFor(() => expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByLabelText(/Current Password/i), 'current123')
|
||||
await user.type(screen.getByLabelText(/^New Password/i), 'newpass1')
|
||||
await user.type(screen.getByLabelText(/Confirm Password/i), 'different2')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Passwords do not match')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import WafConfig from '../WafConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import type { SecurityRuleSet, RuleSetsResponse } from '../../api/security'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>{ui}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockRuleSet: SecurityRuleSet = {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'OWASP CRS',
|
||||
source_url: '',
|
||||
mode: 'blocking',
|
||||
last_updated: '2024-01-15T10:00:00Z',
|
||||
content: 'SecRule REQUEST_URI "@contains /admin" "id:1000,phase:1,deny,status:403"',
|
||||
}
|
||||
|
||||
describe('WafConfig page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('shows loading state while fetching rulesets', async () => {
|
||||
// Keep the promise pending to test loading state
|
||||
vi.mocked(securityApi.getRuleSets).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
expect(screen.getByTestId('waf-loading')).toBeInTheDocument()
|
||||
expect(screen.getByText('Loading WAF configuration...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state when fetch fails', async () => {
|
||||
vi.mocked(securityApi.getRuleSets).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('waf-error')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Failed to load WAF configuration/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no rulesets exist', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('waf-empty-state')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('No Rule Sets')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Create your first WAF rule set/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders rulesets table when data exists', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('OWASP CRS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText('Inline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows create form when Add Rule Set button is clicked', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Create Rule Set' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ruleset-name-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ruleset-content-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits new ruleset and closes form on success', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Fill in the form
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test Rules')
|
||||
await userEvent.type(
|
||||
screen.getByTestId('ruleset-content-input'),
|
||||
'SecRule ARGS "@contains test" "id:1,phase:1,deny"'
|
||||
)
|
||||
|
||||
// Submit
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'Test Rules',
|
||||
source_url: undefined,
|
||||
content: 'SecRule ARGS "@contains test" "id:1,phase:1,deny"',
|
||||
mode: 'blocking',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit form when edit button is clicked', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
expect(screen.getByText('Edit Rule Set')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('OWASP CRS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens delete confirmation dialog and deletes on confirm', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.deleteRuleSet).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click delete button
|
||||
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
|
||||
|
||||
// Confirm dialog should appear
|
||||
expect(screen.getByText('Delete Rule Set')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Are you sure you want to delete "OWASP CRS"/)).toBeInTheDocument()
|
||||
|
||||
// Confirm deletion
|
||||
await userEvent.click(screen.getByTestId('confirm-delete-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.deleteRuleSet).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels delete when clicking cancel button', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click delete button
|
||||
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
|
||||
|
||||
// Click cancel
|
||||
await userEvent.click(screen.getByText('Cancel'))
|
||||
|
||||
// Dialog should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Rule Set')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(securityApi.deleteRuleSet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels delete when clicking backdrop', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click delete button
|
||||
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
|
||||
|
||||
// Click backdrop
|
||||
await userEvent.click(screen.getByTestId('confirm-dialog-backdrop'))
|
||||
|
||||
// Dialog should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Rule Set')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays mode correctly for detection-only rulesets', async () => {
|
||||
const detectionRuleset: SecurityRuleSet = {
|
||||
...mockRuleSet,
|
||||
mode: 'detection',
|
||||
}
|
||||
const response: RuleSetsResponse = { rulesets: [detectionRuleset] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Detection')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays URL link when source_url is provided', async () => {
|
||||
const urlRuleset: SecurityRuleSet = {
|
||||
...mockRuleSet,
|
||||
source_url: 'https://example.com/rules.conf',
|
||||
content: '',
|
||||
}
|
||||
const response: RuleSetsResponse = { rulesets: [urlRuleset] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const urlLink = screen.getByText('URL')
|
||||
expect(urlLink).toHaveAttribute('href', 'https://example.com/rules.conf')
|
||||
expect(urlLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('validates form - submit disabled without name', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Only add content, no name
|
||||
await userEvent.type(screen.getByTestId('ruleset-content-input'), 'SecRule test')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('validates form - submit disabled without content or URL', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Only add name, no content or URL
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('allows form submission with URL instead of content', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Add name and URL, no content
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Remote Rules')
|
||||
await userEvent.type(screen.getByTestId('ruleset-url-input'), 'https://example.com/rules.conf')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
|
||||
expect(submitBtn).not.toBeDisabled()
|
||||
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'Remote Rules',
|
||||
source_url: 'https://example.com/rules.conf',
|
||||
content: undefined,
|
||||
mode: 'blocking',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles between blocking and detection mode', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test')
|
||||
await userEvent.type(screen.getByTestId('ruleset-content-input'), 'SecRule test')
|
||||
|
||||
// Select detection mode
|
||||
await userEvent.click(screen.getByTestId('mode-detection'))
|
||||
|
||||
// Verify mode description changed
|
||||
expect(screen.getByText(/Malicious requests will be logged but not blocked/)).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Rule Set' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'detection' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('hides form when cancel is clicked', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
expect(screen.getByRole('heading', { name: 'Create Rule Set' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
// Form should be hidden, empty state visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('waf-empty-state')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('updates existing ruleset correctly', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open edit form
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
// Update name
|
||||
const nameInput = screen.getByTestId('ruleset-name-input')
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'Updated CRS')
|
||||
|
||||
// Submit
|
||||
await userEvent.click(screen.getByText('Update Rule Set'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Updated CRS',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens delete from edit form', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open edit form
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
// Click delete button in edit form header
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Confirm dialog should appear
|
||||
expect(screen.getByText('Delete Rule Set')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts rules correctly in table', async () => {
|
||||
const multiRuleSet: SecurityRuleSet = {
|
||||
...mockRuleSet,
|
||||
content: `SecRule ARGS "@contains test1" "id:1,phase:1,deny"
|
||||
SecRule ARGS "@contains test2" "id:2,phase:1,deny"
|
||||
SecRule ARGS "@contains test3" "id:3,phase:1,deny"`,
|
||||
}
|
||||
const response: RuleSetsResponse = { rulesets: [multiRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('3 rule(s)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows preset dropdown when creating new ruleset', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
expect(screen.getByTestId('preset-select')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose a preset...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-fills form when preset is selected', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Select OWASP CRS preset
|
||||
const presetSelect = screen.getByTestId('preset-select')
|
||||
await userEvent.selectOptions(presetSelect, 'OWASP Core Rule Set')
|
||||
|
||||
// Verify form is auto-filled
|
||||
expect(screen.getByTestId('ruleset-name-input')).toHaveValue('OWASP Core Rule Set')
|
||||
expect(screen.getByTestId('ruleset-url-input')).toHaveValue(
|
||||
'https://github.com/coreruleset/coreruleset/archive/refs/tags/v3.3.5.tar.gz'
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-fills content for inline preset', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
|
||||
|
||||
// Select SQL Injection preset (has inline content)
|
||||
const presetSelect = screen.getByTestId('preset-select')
|
||||
await userEvent.selectOptions(presetSelect, 'Basic SQL Injection Protection')
|
||||
|
||||
// Verify content is auto-filled
|
||||
const contentInput = screen.getByTestId('ruleset-content-input') as HTMLTextAreaElement
|
||||
expect(contentInput.value).toContain('SecRule')
|
||||
expect(contentInput.value).toContain('SQLi')
|
||||
})
|
||||
|
||||
it('does not show preset dropdown when editing', async () => {
|
||||
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
|
||||
|
||||
renderWithProviders(<WafConfig />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
|
||||
|
||||
// Preset dropdown should not be visible when editing
|
||||
expect(screen.queryByTestId('preset-select')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user