feat(tests): enhance test coverage and error handling across various components

- Added a test case in CrowdSecConfig to show improved error message when preset is not cached.
- Introduced a new test suite for the Dashboard component, verifying counts and health status.
- Updated SMTPSettings tests to utilize a shared render function and added tests for backend validation errors.
- Modified Security.audit tests to improve input handling and removed redundant export failure test.
- Refactored Security tests to remove export functionality and ensure correct rendering of components.
- Enhanced UsersPage tests with new scenarios for updating user permissions and manual invite link flow.
- Created a new utility for rendering components with a QueryClient and MemoryRouter for better test isolation.
- Updated go-test-coverage script to improve error handling and coverage reporting.
This commit is contained in:
GitHub Actions
2025-12-11 00:26:07 +00:00
parent ca4cfc4e65
commit e299aa6b52
81 changed files with 8960 additions and 450 deletions
@@ -0,0 +1,555 @@
import { AxiosError } from 'axios'
import { screen, waitFor, act, cleanup, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient } from '@tanstack/react-query'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import CrowdSecConfig from '../CrowdSecConfig'
import * as securityApi from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as presetsApi from '../../api/presets'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
import { renderWithQueryClient, createTestQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
import * as exportUtils from '../../utils/crowdsecExport'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/presets')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../utils/crowdsecExport', () => ({
buildCrowdsecExportFilename: vi.fn(() => 'crowdsec-default.tar.gz'),
promptCrowdsecFilename: vi.fn(() => 'crowdsec.tar.gz'),
downloadCrowdsecExport: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
const baseStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
waf: { enabled: true, mode: 'enabled' as const },
rate_limit: { enabled: true },
acl: { enabled: true },
}
const disabledStatus = {
...baseStatus,
crowdsec: { ...baseStatus.crowdsec, enabled: true, mode: 'disabled' as const },
}
const presetFromCatalog = CROWDSEC_PRESETS[0]
const axiosError = (status: number, message: string, data?: Record<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.listCrowdsecFiles).mockResolvedValue({ files: defaultFileList })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'file-content' })
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
vi.mocked(crowdsecApi.banIP).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.unbanIP).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue(undefined)
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
presets: [
{
slug: presetFromCatalog.slug,
title: presetFromCatalog.title,
summary: presetFromCatalog.description,
source: 'hub',
requires_hub: false,
available: true,
cached: false,
cache_key: 'cache-123',
etag: 'etag-123',
retrieved_at: '2024-01-01T00:00:00Z',
},
],
})
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
status: 'pulled',
slug: presetFromCatalog.slug,
preview: presetFromCatalog.content,
cache_key: 'cache-123',
etag: 'etag-123',
retrieved_at: '2024-01-01T00:00:00Z',
source: 'hub',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
status: 'applied',
backup: '/tmp/backup.tar.gz',
reload_hint: true,
used_cscli: true,
cache_key: 'cache-123',
slug: presetFromCatalog.slug,
})
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({
preview: 'cached-preview',
cache_key: 'cache-123',
etag: 'etag-123',
})
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
})
it('renders loading and error boundaries', async () => {
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
renderWithQueryClient(<CrowdSecConfig />)
expect(await screen.findByText('Loading CrowdSec configuration...')).toBeInTheDocument()
cleanup()
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('boom'))
renderWithQueryClient(<CrowdSecConfig />)
expect(await screen.findByText(/Failed to load security 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(/Failed to load security 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('toggles mode success and error', async () => {
await renderPage()
const toggle = screen.getByTestId('crowdsec-mode-toggle')
await userEvent.click(toggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'disabled', 'security', 'string'))
expect(toast.success).toHaveBeenCalledWith('CrowdSec disabled')
vi.mocked(settingsApi.updateSetting).mockRejectedValueOnce(new Error('nope'))
await userEvent.click(toggle)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('nope'))
})
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()
const select = screen.getByTestId('preset-select') as HTMLSelectElement
expect(select.value).toBe(presetFromCatalog.slug)
await waitFor(() => expect(presetsApi.pullCrowdsecPreset).toHaveBeenCalledWith(presetFromCatalog.slug))
const previewText = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ')
expect(previewText).toContain('crowdsecurity/http-cve')
expect(screen.getByTestId('preset-meta')).toHaveTextContent('cache-123')
})
it('handles pull validation, hub unavailable, and generic errors', async () => {
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(400, 'slug invalid', { error: 'slug invalid' }))
await renderPage()
expect(await screen.findByTestId('preset-validation-error')).toHaveTextContent('slug invalid')
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub down', { error: 'hub down' }))
await userEvent.click(screen.getByText('Pull Preview'))
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'boom', { error: 'boom' }))
await userEvent.click(screen.getByText('Pull Preview'))
await waitFor(() => expect(screen.getByTestId('preset-status')).toHaveTextContent('boom'))
})
it('loads cached preview and reports cache errors', async () => {
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
presets: [
{
slug: presetFromCatalog.slug,
title: presetFromCatalog.title,
summary: presetFromCatalog.description,
source: 'hub',
requires_hub: false,
available: true,
cached: true,
cache_key: 'cache-123',
etag: 'etag-123',
retrieved_at: '2024-01-01T00:00:00Z',
},
],
})
await renderPage()
await userEvent.click(screen.getByText('Pull Preview'))
await waitFor(() => {
const preview = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ')
expect(preview).toContain('crowdsecurity/http-cve')
})
await userEvent.click(screen.getByText('Load cached preview'))
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
vi.mocked(presetsApi.getCrowdsecPresetCache).mockRejectedValueOnce(axiosError(500, 'cache-miss'))
await userEvent.click(screen.getByText('Load cached preview'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('cache-miss'))
})
it('sets apply info on backend success', async () => {
await renderPage()
await userEvent.click(screen.getByTestId('apply-preset-btn'))
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: /tmp/backup.tar.gz'))
})
it('falls back to local apply on 501 and covers validation/hub/offline branches', async () => {
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
await renderPage()
const applyBtn = screen.getByTestId('apply-preset-btn')
await userEvent.click(applyBtn)
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('Preset apply is not available on the server; applying locally instead'))
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalled())
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(400, 'bad', { error: 'validation failed' }))
await userEvent.click(applyBtn)
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('validation failed'))
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub'))
await userEvent.click(applyBtn)
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'not cached', { error: 'Pull the preset first' }))
await userEvent.click(applyBtn)
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('Preset must be pulled'))
})
it('records backup info on apply failure and generic errors', async () => {
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(500, 'failed', { error: 'boom', backup: '/tmp/backup' }))
await renderPage()
await userEvent.click(screen.getByTestId('apply-preset-btn'))
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/backup'))
cleanup()
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(new Error('unexpected'))
await renderPage()
await userEvent.click(screen.getByTestId('apply-preset-btn'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to apply preset'))
})
it('disables apply when hub is unavailable for hub-only preset', async () => {
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
presets: [
{
slug: 'hub-only',
title: 'Hub Only',
summary: 'needs hub',
source: 'hub',
requires_hub: true,
available: true,
cached: true,
cache_key: 'cache-hub',
etag: 'etag-hub',
},
],
})
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(axiosError(503, 'hub'))
await renderPage()
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
expect((screen.getByTestId('apply-preset-btn') as HTMLButtonElement).disabled).toBe(true)
})
it('guards local apply prerequisites and succeeds when content exists', async () => {
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValueOnce({ files: [] })
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
await renderPage()
await userEvent.click(screen.getByTestId('apply-preset-btn'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Select a configuration file to apply the preset'))
cleanup()
vi.mocked(toast.error).mockClear()
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
presets: [
{
slug: 'custom-empty',
title: 'Empty',
summary: 'empty preset',
source: 'hub',
requires_hub: false,
available: true,
cached: false,
cache_key: 'cache-empty',
etag: 'etag-empty',
},
],
})
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
status: 'pulled',
slug: 'custom-empty',
preview: '',
cache_key: 'cache-empty',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
await renderPage()
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
await userEvent.click(screen.getByTestId('apply-preset-btn'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Preset preview is unavailable; retry pulling before applying'))
cleanup()
vi.mocked(toast.error).mockClear()
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
status: 'pulled',
slug: presetFromCatalog.slug,
preview: 'content',
cache_key: 'cache-123',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented'))
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
await renderPage()
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
await userEvent.click(screen.getByTestId('apply-preset-btn'))
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalled())
})
it('reads, edits, saves, and closes files', async () => {
await renderPage()
await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml')
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('acquis.yaml'))
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.value).toBe('file-content')
await userEvent.clear(textarea)
await userEvent.type(textarea, 'updated')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', 'updated'))
await userEvent.click(screen.getByText('Close'))
expect((screen.getByTestId('crowdsec-file-select') as HTMLSelectElement).value).toBe('')
})
it('shows decisions table, handles loading/error/empty states, and unban errors', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
await renderPage()
expect(screen.getByText('Enable CrowdSec to manage banned IPs')).toBeInTheDocument()
cleanup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus)
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockReturnValue(new Promise(() => {}))
await renderPage()
expect(screen.getByText('Loading banned IPs...')).toBeInTheDocument()
cleanup()
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockRejectedValueOnce(new Error('decisions'))
await renderPage()
expect(await screen.findByText('Failed to load banned IPs')).toBeInTheDocument()
cleanup()
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({ decisions: [] })
await renderPage()
expect(await screen.findByText('No banned IPs')).toBeInTheDocument()
cleanup()
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({
decisions: [
{ id: '1', ip: '1.1.1.1', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' },
],
})
await renderPage()
expect(await screen.findByText('1.1.1.1')).toBeInTheDocument()
vi.mocked(crowdsecApi.unbanIP).mockRejectedValueOnce(new Error('unban fail'))
await userEvent.click(screen.getAllByText('Unban')[0])
const confirmModal = screen.getByText('Confirm Unban').closest('div') as HTMLElement
await userEvent.click(within(confirmModal).getByRole('button', { name: 'Unban' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('unban fail'))
})
it('bans and unbans IPs with overlay messaging', async () => {
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({
decisions: [
{ id: '1', ip: '1.1.1.1', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' },
],
})
await renderPage()
await userEvent.click(screen.getByRole('button', { name: /Ban IP/ }))
const banModal = screen.getByText('Ban IP Address').closest('div') as HTMLElement
const ipInput = within(banModal).getByPlaceholderText('192.168.1.100') as HTMLInputElement
await userEvent.type(ipInput, '2.2.2.2')
await userEvent.click(within(banModal).getByRole('button', { name: 'Ban IP' }))
await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('2.2.2.2', '24h', ''))
// keep ban pending to assert overlay message
let resolveBan: (() => void) | undefined
vi.mocked(crowdsecApi.banIP).mockImplementationOnce(
() =>
new Promise<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
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')
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
await userEvent.type(textarea, 'x')
await userEvent.click(screen.getByText('Save'))
expect(await screen.findByText('Guardian inscribes...')).toBeInTheDocument()
resolveWrite?.()
cleanup()
// mode update pending
vi.mocked(settingsApi.updateSetting).mockImplementationOnce(() => new Promise(() => {}))
await renderPage()
await userEvent.click(screen.getByTestId('crowdsec-mode-toggle'))
expect(await screen.findByText('Three heads turn...')).toBeInTheDocument()
})
})
@@ -250,4 +250,27 @@ describe('CrowdSecConfig', () => {
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,60 @@
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 },
{ id: 2, enabled: false },
],
}),
}))
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: () => ({
servers: [
{ id: 1, enabled: true },
{ id: 2, enabled: true },
],
}),
}))
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: () => ({
certificates: [
{ id: 1, status: 'valid' },
{ id: 2, status: 'expired' },
],
}),
}))
vi.mock('../../api/health', () => ({
checkHealth: vi.fn().mockResolvedValue({ status: 'ok', version: '1.0.0' }),
}))
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()
})
})
@@ -1,10 +1,10 @@
import { render, screen, waitFor } from '@testing-library/react'
import { 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 SMTPSettings from '../SMTPSettings'
import * as smtpApi from '../../api/smtp'
import { toast } from '../../utils/toast'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
// Mock API
vi.mock('../../api/smtp', () => ({
@@ -14,32 +14,24 @@ vi.mock('../../api/smtp', () => ({
sendTestEmail: 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}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
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(() => {}))
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
// Should show loading spinner
expect(document.querySelector('.animate-spin')).toBeTruthy()
@@ -56,7 +48,7 @@ describe('SMTPSettings', () => {
configured: true,
})
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
// Wait for the form to populate with data
await waitFor(() => {
@@ -84,7 +76,7 @@ describe('SMTPSettings', () => {
configured: false,
})
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('SMTP Not Configured')).toBeTruthy()
@@ -105,7 +97,7 @@ describe('SMTPSettings', () => {
message: 'SMTP configuration saved successfully',
})
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeTruthy()
@@ -140,7 +132,7 @@ describe('SMTPSettings', () => {
message: 'Connection successful',
})
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Test Connection')).toBeTruthy()
@@ -165,7 +157,7 @@ describe('SMTPSettings', () => {
configured: true,
})
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
@@ -189,7 +181,7 @@ describe('SMTPSettings', () => {
message: 'Email sent',
})
renderWithProviders(<SMTPSettings />)
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
@@ -206,4 +198,87 @@ describe('SMTPSettings', () => {
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: 'Save Settings' }))
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('Test Connection')).toBeInTheDocument())
// Button should start disabled until host and from address are provided
expect(screen.getByRole('button', { name: 'Test Connection' })).toBeDisabled()
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.acme.local')
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'from@acme.local')
await user.click(screen.getByRole('button', { name: 'Test Connection' }))
await waitFor(() => {
expect(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('Send Test Email')).toBeInTheDocument())
const input = screen.getByPlaceholderText('recipient@example.com') as HTMLInputElement
await user.type(input, 'keepme@example.com')
await user.click(screen.getByRole('button', { name: /Send Test/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('smtp unreachable')
expect(input.value).toBe('keepme@example.com')
})
})
})
@@ -98,9 +98,13 @@ describe('Security Page - QA Security Audit', () => {
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
// Empty whitelist input should exist and be empty
const whitelistInput = screen.getByDisplayValue('')
// Empty whitelist input should exist and be empty - use label to find it
const whitelistLabel = screen.getByText(/Admin whitelist \(comma-separated CIDR\/IPs\)/i)
expect(whitelistLabel).toBeInTheDocument()
// The input follows the label, get it by querying parent
const whitelistInput = whitelistLabel.parentElement?.querySelector('input')
expect(whitelistInput).toBeInTheDocument()
expect(whitelistInput?.value).toBe('')
})
})
@@ -158,21 +162,7 @@ describe('Security Page - QA Security Audit', () => {
})
})
it('handles CrowdSec export failure gracefully', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockRejectedValue(new Error('Export failed'))
await renderSecurityPage()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
await user.click(exportButton)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to export CrowdSec configuration')
})
})
it('handles CrowdSec status check failure gracefully', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
@@ -333,8 +323,7 @@ describe('Security Page - QA Security Audit', () => {
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Logs/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
// CrowdSec card should only have Config button now
const configButtons = screen.getAllByRole('button', { name: /Config/i })
expect(configButtons.some(btn => btn.textContent === 'Config')).toBe(true)
})
@@ -351,8 +340,8 @@ describe('Security Page - QA Security Audit', () => {
const cards = screen.getAllByRole('heading', { level: 3 })
const cardNames = cards.map(card => card.textContent)
// Spec requirement from current_spec.md
expect(cardNames).toEqual(['CrowdSec', 'Access Control', 'WAF (Coraza)', 'Rate Limiting'])
// Spec requirement from current_spec.md plus Live Security Logs feature
expect(cardNames).toEqual(['CrowdSec', 'Access Control', 'WAF (Coraza)', 'Rate Limiting', 'Live Security Logs'])
})
it('layer indicators match spec descriptions', async () => {
+1 -19
View File
@@ -134,25 +134,7 @@ describe('Security page', () => {
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
})
it('calls export endpoint when clicking Export', async () => {
const status: SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: true, mode: 'local' 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 blob = new Blob(['dummy'])
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
renderWithProviders(<Security />)
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
const exportBtn = screen.getByText('Export')
await userEvent.click(exportBtn)
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
})
// Export button is in CrowdSecConfig component, not Security page
it('calls start/stop endpoints for CrowdSec via toggle', async () => {
const user = userEvent.setup()
+2 -26
View File
@@ -7,17 +7,10 @@ import Security from '../Security'
import * as securityApi from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as settingsApi from '../../api/settings'
import { toast } from '../../utils/toast'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/settings')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
return {
@@ -236,24 +229,7 @@ describe('Security', () => {
})
})
it('should export CrowdSec config', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['config data']))
window.URL.createObjectURL = vi.fn(() => 'blob:url')
window.URL.revokeObjectURL = vi.fn()
await renderSecurityPage()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
await user.click(exportButton)
await waitFor(() => {
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
})
})
})
describe('WAF Controls', () => {
@@ -301,8 +277,8 @@ describe('Security', () => {
const cards = screen.getAllByRole('heading', { level: 3 })
const cardNames = cards.map(card => card.textContent)
// Verify pipeline order: CrowdSec (Layer 1) → ACL (Layer 2) → WAF (Layer 3) → Rate Limiting (Layer 4)
expect(cardNames).toEqual(['CrowdSec', 'Access Control', 'WAF (Coraza)', 'Rate Limiting'])
// Verify pipeline order: CrowdSec (Layer 1) → ACL (Layer 2) → WAF (Layer 3) → Rate Limiting (Layer 4) + Live Security Logs
expect(cardNames).toEqual(['CrowdSec', 'Access Control', 'WAF (Coraza)', 'Rate Limiting', 'Live Security Logs'])
})
it('should display layer indicators on each card', async () => {
+100 -29
View File
@@ -1,11 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react'
import { 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 UsersPage from '../UsersPage'
import * as usersApi from '../../api/users'
import * as proxyHostsApi from '../../api/proxyHosts'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
// Mock APIs
vi.mock('../../api/users', () => ({
@@ -24,22 +24,12 @@ vi.mock('../../api/proxyHosts', () => ({
getProxyHosts: 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}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
const mockUsers = [
{
@@ -81,7 +71,7 @@ const mockUsers = [
const mockProxyHosts = [
{
uuid: 'host-1',
uuid: '1',
name: 'Test Host',
domain_names: 'test.example.com',
forward_scheme: 'http',
@@ -105,12 +95,14 @@ 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(() => {}))
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
expect(document.querySelector('.animate-spin')).toBeTruthy()
})
@@ -118,7 +110,7 @@ describe('UsersPage', () => {
it('renders user list', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('User Management')).toBeTruthy()
@@ -133,7 +125,7 @@ describe('UsersPage', () => {
it('shows pending invite status', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Pending Invite')).toBeTruthy()
@@ -143,7 +135,7 @@ describe('UsersPage', () => {
it('shows active status for accepted users', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
@@ -153,7 +145,7 @@ describe('UsersPage', () => {
it('opens invite modal when clicking invite button', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Invite User')).toBeTruthy()
@@ -170,7 +162,7 @@ describe('UsersPage', () => {
it('shows permission mode in user list', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getAllByText('Blacklist').length).toBeGreaterThan(0)
@@ -183,7 +175,7 @@ describe('UsersPage', () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'Updated' })
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Regular User')).toBeTruthy()
@@ -218,7 +210,7 @@ describe('UsersPage', () => {
expires_at: '2024-01-03T00:00:00Z',
})
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Invite User')).toBeTruthy()
@@ -252,7 +244,7 @@ describe('UsersPage', () => {
// Mock window.confirm
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
renderWithProviders(<UsersPage />)
renderWithQueryClient(<UsersPage />)
await waitFor(() => {
expect(screen.getByText('Regular User')).toBeTruthy()
@@ -278,4 +270,83 @@ describe('UsersPage', () => {
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('shows manual invite link flow when email is not sent and allows copy', async () => {
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
vi.mocked(usersApi.inviteUser).mockResolvedValue({
id: 5,
uuid: 'invitee',
email: 'manual@example.com',
role: 'user',
invite_token: 'token-123',
email_sent: false,
expires_at: '2025-01-01T00:00:00Z',
})
const writeText = vi.fn().mockResolvedValue(undefined)
const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
get: () => ({ writeText }),
configurable: true,
})
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 screen.findByDisplayValue(/accept-invite\?token=token-123/)
const copyButton = await screen.findByRole('button', { name: /copy invite link/i })
await user.click(copyButton)
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard')
})
if (originalDescriptor) {
Object.defineProperty(navigator, 'clipboard', originalDescriptor)
} else {
delete (navigator as unknown as { clipboard?: unknown }).clipboard
}
})
})