chore: Add tests for backup service, crowdsec startup, log service, and security headers
- Implement tests for BackupService to handle database extraction from backup archives with SHM and WAL entries. - Add tests for BackupService to validate behavior when creating backups for non-SQLite databases and handling oversized database entries. - Introduce tests for CrowdSec startup to ensure proper error handling during configuration creation. - Enhance LogService tests to cover scenarios for skipping dot and empty directories and handling read directory errors. - Add tests for SecurityHeadersService to ensure proper error handling during preset creation and updates. - Update ProxyHostForm tests to include HSTS subdomains toggle and validation for port input handling. - Enhance DNSProviders tests to validate manual challenge completion and error handling when no providers are available. - Extend UsersPage tests to ensure fallback mechanisms for clipboard operations when the clipboard API fails.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -731,6 +731,33 @@ describe('ProxyHostForm', () => {
|
||||
expect(blockExploitsCheckbox).toBeChecked()
|
||||
await userEvent.click(blockExploitsCheckbox)
|
||||
expect(blockExploitsCheckbox).not.toBeChecked()
|
||||
|
||||
// Toggle HSTS Subdomains (default is true)
|
||||
const hstsSubdomainsCheckbox = screen.getByLabelText('HSTS Subdomains')
|
||||
expect(hstsSubdomainsCheckbox).toBeChecked()
|
||||
await userEvent.click(hstsSubdomainsCheckbox)
|
||||
expect(hstsSubdomainsCheckbox).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('submits updated hsts_subdomains flag', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'HSTS Toggle')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'hsts.existing.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.10')
|
||||
|
||||
const hstsSubdomainsCheckbox = screen.getByLabelText('HSTS Subdomains')
|
||||
await userEvent.click(hstsSubdomainsCheckbox)
|
||||
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
hsts_subdomains: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -897,6 +924,25 @@ describe('ProxyHostForm', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('renders and selects non-preset security header profile options', async () => {
|
||||
const { useSecurityHeaderProfiles } = await import('../../hooks/useSecurityHeaders')
|
||||
vi.mocked(useSecurityHeaderProfiles).mockReturnValue({
|
||||
data: [
|
||||
{ id: 100, name: 'Strict Profile', description: 'Very strict', security_score: 90, is_preset: true, preset_type: 'strict' },
|
||||
{ id: 101, name: 'Custom Profile', description: 'Custom profile', security_score: 70, is_preset: false },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useSecurityHeaderProfiles>)
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await selectComboboxOption(/Security Headers/i, 'Custom Profile (Score: 70/100)')
|
||||
expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Custom Profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode vs Create Mode', () => {
|
||||
@@ -1072,6 +1118,34 @@ describe('ProxyHostForm', () => {
|
||||
})
|
||||
|
||||
describe('Port Input Handling', () => {
|
||||
it('shows required-port validation branch when submit is triggered with empty port', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Port Required')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'required.existing.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
|
||||
const portInput = screen.getByLabelText(/^Port$/) as HTMLInputElement
|
||||
await userEvent.clear(portInput)
|
||||
|
||||
const setCustomValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'setCustomValidity')
|
||||
const reportValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'reportValidity').mockReturnValue(false)
|
||||
|
||||
const form = document.querySelector('form') as HTMLFormElement
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setCustomValiditySpy).toHaveBeenCalledWith('Port is required')
|
||||
expect(reportValiditySpy).toHaveBeenCalled()
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
setCustomValiditySpy.mockRestore()
|
||||
reportValiditySpy.mockRestore()
|
||||
})
|
||||
|
||||
it('validates port number range', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
@@ -1092,6 +1166,87 @@ describe('ProxyHostForm', () => {
|
||||
expect(portInput).toBeInvalid()
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows out-of-range validation branch when submit is triggered with invalid port', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Port Range Branch')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'range.existing.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
|
||||
const portInput = screen.getByLabelText(/^Port$/) as HTMLInputElement
|
||||
await userEvent.clear(portInput)
|
||||
await userEvent.type(portInput, '70000')
|
||||
|
||||
const setCustomValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'setCustomValidity')
|
||||
const reportValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'reportValidity').mockReturnValue(false)
|
||||
|
||||
const form = document.querySelector('form') as HTMLFormElement
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setCustomValiditySpy).toHaveBeenCalledWith('Port must be between 1 and 65535')
|
||||
expect(reportValiditySpy).toHaveBeenCalled()
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
setCustomValiditySpy.mockRestore()
|
||||
reportValiditySpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Server Container Mapping', () => {
|
||||
it('allows selecting a remote docker source option', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await selectComboboxOption('Source', 'Local Docker Registry (localhost)')
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Source' })).toHaveTextContent('Local Docker Registry')
|
||||
})
|
||||
|
||||
it('maps remote docker container to remote host and public port', async () => {
|
||||
const { useDocker } = await import('../../hooks/useDocker')
|
||||
vi.mocked(useDocker).mockReturnValue({
|
||||
containers: [
|
||||
{
|
||||
id: 'remote-container-1',
|
||||
names: ['remote-app'],
|
||||
image: 'nginx:latest',
|
||||
state: 'running',
|
||||
status: 'Up 1 hour',
|
||||
network: 'bridge',
|
||||
ip: '172.18.0.10',
|
||||
ports: [{ private_port: 80, public_port: 18080, type: 'tcp' }],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^Name/), 'Remote Mapping')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'remote.existing.com')
|
||||
|
||||
await selectComboboxOption('Source', 'Local Docker Registry (localhost)')
|
||||
await selectComboboxOption('Containers', 'remote-app (nginx:latest)')
|
||||
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
forward_host: 'localhost',
|
||||
forward_port: 18080,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Host and Port Combination', () => {
|
||||
|
||||
@@ -374,6 +374,37 @@ describe('<AuditLogs />', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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: [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AxiosError } from 'axios'
|
||||
import { screen, waitFor, act, cleanup, within } from '@testing-library/react'
|
||||
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'
|
||||
@@ -288,6 +288,45 @@ describe('CrowdSecConfig coverage', () => {
|
||||
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'))
|
||||
@@ -460,6 +499,79 @@ describe('CrowdSecConfig coverage', () => {
|
||||
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: [
|
||||
|
||||
@@ -6,6 +6,7 @@ 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: () => ({
|
||||
@@ -55,8 +56,20 @@ vi.mock('../../components/DNSProviderForm', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../components/dns-providers', () => ({
|
||||
ManualDNSChallenge: ({ challenge }: { challenge: { fqdn: string } }) => (
|
||||
<section data-testid="manual-dns-challenge">{challenge.fqdn}</section>
|
||||
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>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -140,5 +153,59 @@ describe('DNSProviders page state behavior', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,4 +198,102 @@ describe('Notifications', () => {
|
||||
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('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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -363,6 +363,113 @@ describe('UsersPage', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses textarea fallback copy when clipboard API fails', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.inviteUser).mockResolvedValue({
|
||||
id: 6,
|
||||
uuid: 'invitee-fallback',
|
||||
email: 'fallback@example.com',
|
||||
role: 'user',
|
||||
invite_token: 'token-fallback',
|
||||
invite_url: 'https://charon.example.com/accept-invite?token=token-fallback',
|
||||
email_sent: false,
|
||||
expires_at: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const appendSpy = vi.spyOn(document.body, 'appendChild')
|
||||
const removeSpy = vi.spyOn(document.body, 'removeChild')
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
value: vi.fn(),
|
||||
configurable: true,
|
||||
writable: 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'), 'fallback@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /^Send Invite$/i }))
|
||||
|
||||
await screen.findByDisplayValue(/accept-invite\?token=token-fallback/)
|
||||
await user.click(screen.getByRole('button', { name: /copy invite link/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(appendSpy).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard')
|
||||
})
|
||||
|
||||
appendSpy.mockRestore()
|
||||
removeSpy.mockRestore()
|
||||
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalDescriptor)
|
||||
} else {
|
||||
delete (navigator as unknown as { clipboard?: unknown }).clipboard
|
||||
}
|
||||
})
|
||||
|
||||
it('uses textarea fallback copy when clipboard writeText rejects', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
vi.mocked(usersApi.inviteUser).mockResolvedValue({
|
||||
id: 7,
|
||||
uuid: 'invitee-reject',
|
||||
email: 'reject@example.com',
|
||||
role: 'user',
|
||||
invite_token: 'token-reject',
|
||||
invite_url: 'https://charon.example.com/accept-invite?token=token-reject',
|
||||
email_sent: false,
|
||||
expires_at: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('clipboard denied'))
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
get: () => ({ writeText }),
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const appendSpy = vi.spyOn(document.body, 'appendChild')
|
||||
const removeSpy = vi.spyOn(document.body, 'removeChild')
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
value: vi.fn().mockReturnValue(true),
|
||||
configurable: true,
|
||||
writable: 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'), 'reject@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /^Send Invite$/i }))
|
||||
|
||||
await screen.findByDisplayValue(/accept-invite\?token=token-reject/)
|
||||
await user.click(screen.getByRole('button', { name: /copy invite link/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(appendSpy).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard')
|
||||
})
|
||||
|
||||
appendSpy.mockRestore()
|
||||
removeSpy.mockRestore()
|
||||
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalDescriptor)
|
||||
} else {
|
||||
delete (navigator as unknown as { clipboard?: unknown }).clipboard
|
||||
}
|
||||
})
|
||||
|
||||
describe('URL Preview in InviteModal', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
|
||||
Reference in New Issue
Block a user