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:
GitHub Actions
2026-02-17 19:13:28 +00:00
parent 9713908887
commit 2cad49de85
41 changed files with 4071 additions and 4 deletions
@@ -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()