test: add certificate feature unit tests and null-safety fix
Add comprehensive unit tests for the certificate upload, export, and detail management feature: - CertificateExportDialog: 21 tests covering format selection, blob download, error handling, and password-protected exports - CertificateUploadDialog: 23 tests covering file validation, format detection, drag-and-drop, and upload flow - CertificateDetailDialog: 19 tests covering detail display, loading state, missing fields, and branch coverage - CertificateChainViewer: 8 tests covering chain visualization - CertificateValidationPreview: 16 tests covering validation display - FileDropZone: 18 tests covering drag-and-drop interactions - useCertificates hooks: 10 tests covering all React Query hooks - certificates API: 7 new tests for previously uncovered endpoints Fix null-safety issue in ProxyHosts where cert.domains could be undefined, causing a runtime error on split(). Frontend patch coverage: 90.6%, overall lines: 89.09%
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import type { ChainEntry } from '../../api/certificates'
|
||||
import CertificateChainViewer from '../CertificateChainViewer'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeChain(count: number): ChainEntry[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
subject: `Subject ${i}`,
|
||||
issuer: `Issuer ${i}`,
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
}))
|
||||
}
|
||||
|
||||
describe('CertificateChainViewer', () => {
|
||||
it('renders empty state when chain is empty', () => {
|
||||
render(<CertificateChainViewer chain={[]} />)
|
||||
expect(screen.getByText('certificates.noChainData')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders single entry as leaf', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(1)} />)
|
||||
expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
|
||||
expect(screen.getByText('Subject 0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders two entries as leaf + root', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(2)} />)
|
||||
expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.chainRoot')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders three entries as leaf + intermediate + root', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(3)} />)
|
||||
expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.chainIntermediate')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.chainRoot')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays issuer for each entry', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(2)} />)
|
||||
expect(screen.getByText(/Issuer 0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Issuer 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays formatted expiration dates', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(1)} />)
|
||||
const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString()
|
||||
expect(screen.getByText(new RegExp(dateStr))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses list role with list items', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(2)} />)
|
||||
expect(screen.getByRole('list')).toBeTruthy()
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has aria-label on list', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(1)} />)
|
||||
expect(screen.getByRole('list').getAttribute('aria-label')).toBe(
|
||||
'certificates.certificateChain',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import type { ValidationResult } from '../../api/certificates'
|
||||
import CertificateValidationPreview from '../CertificateValidationPreview'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeResult(overrides: Partial<ValidationResult> = {}): ValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
common_name: 'example.com',
|
||||
domains: ['example.com', 'www.example.com'],
|
||||
issuer_org: 'Test CA',
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
key_match: true,
|
||||
chain_valid: true,
|
||||
chain_depth: 2,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CertificateValidationPreview', () => {
|
||||
it('renders valid certificate state', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('certificates.validCertificate')).toBeTruthy()
|
||||
expect(screen.getByTestId('certificate-validation-preview')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders invalid certificate state', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ valid: false })} />,
|
||||
)
|
||||
expect(screen.getByText('certificates.invalidCertificate')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays common name', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays domains joined by comma', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('example.com, www.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays dash when no domains provided', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ domains: [] })} />,
|
||||
)
|
||||
const dashes = screen.getAllByText('-')
|
||||
expect(dashes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('displays issuer organization', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('Test CA')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays formatted expiration date', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString()
|
||||
expect(screen.getByText(dateStr)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Yes for key match', () => {
|
||||
render(<CertificateValidationPreview result={makeResult({ key_match: true, chain_valid: false })} />)
|
||||
expect(screen.getByText('Yes')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows No key provided when no key match', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ key_match: false })} />,
|
||||
)
|
||||
expect(screen.getByText('No key provided')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows chain depth when > 0', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ chain_depth: 3 })} />,
|
||||
)
|
||||
expect(screen.getByText('3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show chain depth when 0', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ chain_depth: 0 })} />,
|
||||
)
|
||||
expect(screen.queryByText('certificates.chainDepth')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('renders warnings when present', () => {
|
||||
render(
|
||||
<CertificateValidationPreview
|
||||
result={makeResult({ warnings: ['Expiring soon', 'Weak key'] })}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('certificates.warnings')).toBeTruthy()
|
||||
expect(screen.getByText('Expiring soon')).toBeTruthy()
|
||||
expect(screen.getByText('Weak key')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render warnings section when empty', () => {
|
||||
render(<CertificateValidationPreview result={makeResult({ warnings: [] })} />)
|
||||
expect(screen.queryByText('certificates.warnings')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('renders errors when present', () => {
|
||||
render(
|
||||
<CertificateValidationPreview
|
||||
result={makeResult({ errors: ['Certificate revoked'] })}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('certificates.errors')).toBeTruthy()
|
||||
expect(screen.getByText('Certificate revoked')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render errors section when empty', () => {
|
||||
render(<CertificateValidationPreview result={makeResult({ errors: [] })} />)
|
||||
expect(screen.queryByText('certificates.errors')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('has correct region role and aria-label', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
const region = screen.getByRole('region')
|
||||
expect(region.getAttribute('aria-label')).toBe('certificates.validationPreview')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import type { Certificate, CertificateDetail } from '../../../api/certificates'
|
||||
import { useCertificateDetail } from '../../../hooks/useCertificates'
|
||||
import { createTestQueryClient } from '../../../test/createTestQueryClient'
|
||||
import CertificateDetailDialog from '../CertificateDetailDialog'
|
||||
|
||||
const mockDetail: CertificateDetail = {
|
||||
uuid: 'cert-1',
|
||||
name: 'My Cert',
|
||||
common_name: 'app.example.com',
|
||||
domains: 'app.example.com, api.example.com',
|
||||
issuer: 'Test CA',
|
||||
issuer_org: 'Test Org',
|
||||
fingerprint: 'AA:BB:CC:DD',
|
||||
serial_number: '1234567890',
|
||||
key_type: 'RSA 2048',
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
not_before: '2024-03-15T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: true,
|
||||
auto_renew: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-08-20T00:00:00Z',
|
||||
assigned_hosts: [
|
||||
{ uuid: 'host-1', name: 'Web Server', domain_names: 'web.example.com' },
|
||||
],
|
||||
chain: [
|
||||
{ subject: 'app.example.com', issuer: 'Test CA', expires_at: '2026-06-01T00:00:00Z' },
|
||||
{ subject: 'Test CA', issuer: 'Root CA', expires_at: '2030-01-01T00:00:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
vi.mock('../../../hooks/useCertificates', () => ({
|
||||
useCertificateDetail: vi.fn((uuid: string | null) => {
|
||||
if (!uuid) return { detail: undefined, isLoading: false }
|
||||
return { detail: mockDetail, isLoading: false }
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
const baseCert: Certificate = {
|
||||
uuid: 'cert-1',
|
||||
name: 'My Cert',
|
||||
domains: 'example.com',
|
||||
issuer: 'Test CA',
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: true,
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
certificate: Certificate | null = baseCert,
|
||||
open = true,
|
||||
onOpenChange = vi.fn(),
|
||||
) {
|
||||
const qc = createTestQueryClient()
|
||||
return {
|
||||
onOpenChange,
|
||||
...render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CertificateDetailDialog
|
||||
certificate={certificate}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
describe('CertificateDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders dialog with title when open', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.detailTitle')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderDialog(baseCert, false)
|
||||
expect(screen.queryByTestId('certificate-detail-dialog')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('displays certificate name', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('My Cert')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays common name', () => {
|
||||
renderDialog()
|
||||
const matches = screen.getAllByText(/app\.example\.com/)
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('displays fingerprint', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('AA:BB:CC:DD')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays serial number', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('1234567890')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays key type', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('RSA 2048')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays status', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('valid')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays provider', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('custom')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays assigned hosts section', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.assignedHosts')).toBeTruthy()
|
||||
expect(screen.getByText('Web Server')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays certificate chain section', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.certificateChain')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows auto renew status', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('common.no')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows formatted dates', () => {
|
||||
renderDialog()
|
||||
const notBeforeDate = new Date('2024-03-15T00:00:00Z').toLocaleDateString()
|
||||
const updatedDate = new Date('2024-08-20T00:00:00Z').toLocaleDateString()
|
||||
expect(screen.getByText(notBeforeDate)).toBeTruthy()
|
||||
expect(screen.getByText(updatedDate)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows loading state', () => {
|
||||
vi.mocked(useCertificateDetail).mockReturnValue({
|
||||
detail: undefined as unknown as CertificateDetail,
|
||||
isLoading: true,
|
||||
})
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy()
|
||||
// Detail content should not be rendered while loading
|
||||
expect(screen.queryByText('My Cert')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('shows dash for missing optional fields', () => {
|
||||
const sparseDetail: CertificateDetail = {
|
||||
...mockDetail,
|
||||
name: '',
|
||||
common_name: '',
|
||||
domains: '',
|
||||
issuer_org: '',
|
||||
issuer: '',
|
||||
fingerprint: '',
|
||||
serial_number: '',
|
||||
key_type: '',
|
||||
not_before: '',
|
||||
expires_at: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
chain: [],
|
||||
assigned_hosts: [],
|
||||
}
|
||||
vi.mocked(useCertificateDetail).mockReturnValue({
|
||||
detail: sparseDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
renderDialog()
|
||||
const dashes = screen.getAllByText('-')
|
||||
// Many fields should fall back to '-' when empty
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('shows no assigned hosts message when empty', () => {
|
||||
const noHostDetail: CertificateDetail = {
|
||||
...mockDetail,
|
||||
assigned_hosts: [],
|
||||
}
|
||||
vi.mocked(useCertificateDetail).mockReturnValue({
|
||||
detail: noHostDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.noAssignedHosts')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows auto renew yes when enabled', () => {
|
||||
const autoRenewDetail: CertificateDetail = {
|
||||
...mockDetail,
|
||||
auto_renew: true,
|
||||
}
|
||||
vi.mocked(useCertificateDetail).mockReturnValue({
|
||||
detail: autoRenewDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
renderDialog()
|
||||
expect(screen.getByText('common.yes')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to issuer when issuer_org is missing', () => {
|
||||
const noOrgDetail: CertificateDetail = {
|
||||
...mockDetail,
|
||||
issuer_org: '',
|
||||
issuer: 'Fallback Issuer',
|
||||
}
|
||||
vi.mocked(useCertificateDetail).mockReturnValue({
|
||||
detail: noOrgDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
renderDialog()
|
||||
expect(screen.getByText('Fallback Issuer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing when certificate is null', () => {
|
||||
vi.mocked(useCertificateDetail).mockReturnValue({
|
||||
detail: undefined as unknown as CertificateDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
renderDialog(null)
|
||||
expect(screen.queryByText('My Cert')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import type { Certificate } from '../../../api/certificates'
|
||||
import { createTestQueryClient } from '../../../test/createTestQueryClient'
|
||||
import CertificateExportDialog from '../CertificateExportDialog'
|
||||
|
||||
const exportMutateFn = vi.fn()
|
||||
|
||||
vi.mock('../../../hooks/useCertificates', () => ({
|
||||
useExportCertificate: vi.fn(() => ({
|
||||
mutate: exportMutateFn,
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const baseCert: Certificate = {
|
||||
uuid: 'cert-1',
|
||||
name: 'Test Cert',
|
||||
domains: 'example.com',
|
||||
issuer: 'Test CA',
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
certificate: Certificate | null = baseCert,
|
||||
open = true,
|
||||
onOpenChange = vi.fn(),
|
||||
) {
|
||||
const qc = createTestQueryClient()
|
||||
return {
|
||||
onOpenChange,
|
||||
...render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CertificateExportDialog
|
||||
certificate={certificate}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
describe('CertificateExportDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders dialog when open', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('certificate-export-dialog')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.exportTitle')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderDialog(baseCert, false)
|
||||
expect(screen.queryByTestId('certificate-export-dialog')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('shows format radio options', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.exportFormatPem')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.exportFormatPfx')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.exportFormatDer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows include private key checkbox', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.includePrivateKey')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows export button', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('export-certificate-submit')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows cancel button', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('common.cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls onOpenChange(false) on cancel', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
renderDialog(baseCert, true, onOpenChange)
|
||||
await userEvent.click(screen.getByText('common.cancel'))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('selects PEM format by default', () => {
|
||||
renderDialog()
|
||||
const pemRadio = screen.getByRole('radio', { name: 'certificates.exportFormatPem' })
|
||||
expect(pemRadio).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('can select PFX format', async () => {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByText('certificates.exportFormatPfx'))
|
||||
const pfxRadio = screen.getByRole('radio', { name: 'certificates.exportFormatPfx' })
|
||||
expect(pfxRadio).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('shows PFX password when PFX format selected', async () => {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByText('certificates.exportFormatPfx'))
|
||||
expect(screen.getByText('certificates.exportPfxPassword')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows private key warning when include key is checked', async () => {
|
||||
renderDialog()
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
await userEvent.click(checkbox)
|
||||
expect(screen.getByText('certificates.includePrivateKeyWarning')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows password field when include key is checked', async () => {
|
||||
renderDialog()
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
await userEvent.click(checkbox)
|
||||
expect(screen.getByText('certificates.exportPassword')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls export mutation on submit', async () => {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
expect(exportMutateFn).toHaveBeenCalledTimes(1)
|
||||
expect(exportMutateFn.mock.calls[0][0]).toMatchObject({
|
||||
uuid: 'cert-1',
|
||||
format: 'pem',
|
||||
includeKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends include key and password when checked', async () => {
|
||||
renderDialog()
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
await userEvent.click(checkbox)
|
||||
|
||||
const pwInput = document.getElementById('export-password') as HTMLInputElement
|
||||
await userEvent.type(pwInput, 'secret123')
|
||||
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
expect(exportMutateFn.mock.calls[0][0]).toMatchObject({
|
||||
uuid: 'cert-1',
|
||||
format: 'pem',
|
||||
includeKey: true,
|
||||
password: 'secret123',
|
||||
})
|
||||
})
|
||||
|
||||
it('hides include key checkbox when cert has no key', () => {
|
||||
const certNoKey = { ...baseCert, has_key: false }
|
||||
renderDialog(certNoKey)
|
||||
expect(screen.queryByRole('checkbox')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('triggers blob download on export success', async () => {
|
||||
const fakeBlob = new Blob(['cert-data'], { type: 'application/x-pem-file' })
|
||||
const revokeURL = vi.fn()
|
||||
const createURL = vi.fn(() => 'blob:http://localhost/fake')
|
||||
global.URL.createObjectURL = createURL
|
||||
global.URL.revokeObjectURL = revokeURL
|
||||
|
||||
const appendSpy = vi.spyOn(document.body, 'appendChild')
|
||||
const removeSpy = vi.fn()
|
||||
|
||||
exportMutateFn.mockImplementation(
|
||||
(_params: unknown, opts: { onSuccess: (b: Blob) => void }) => {
|
||||
const origCreate = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => {
|
||||
const el = origCreate(tag) as HTMLAnchorElement
|
||||
el.remove = removeSpy
|
||||
return el
|
||||
})
|
||||
opts.onSuccess(fakeBlob)
|
||||
},
|
||||
)
|
||||
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
|
||||
expect(createURL).toHaveBeenCalledWith(fakeBlob)
|
||||
expect(appendSpy).toHaveBeenCalled()
|
||||
expect(revokeURL).toHaveBeenCalledWith('blob:http://localhost/fake')
|
||||
expect(removeSpy).toHaveBeenCalled()
|
||||
appendSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows toast error on export failure', async () => {
|
||||
const { toast: mockToast } = await import('../../../utils/toast')
|
||||
exportMutateFn.mockImplementation(
|
||||
(_params: unknown, opts: { onError: (e: Error) => void }) => {
|
||||
opts.onError(new Error('Export failed'))
|
||||
},
|
||||
)
|
||||
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
expect(mockToast.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects DER format and submits', async () => {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByText('certificates.exportFormatDer'))
|
||||
const derRadio = screen.getByRole('radio', { name: 'certificates.exportFormatDer' })
|
||||
expect(derRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
expect(exportMutateFn.mock.calls[0][0]).toMatchObject({
|
||||
format: 'der',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends pfxPassword when PFX format selected', async () => {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByText('certificates.exportFormatPfx'))
|
||||
|
||||
const pfxInput = document.getElementById('pfx-password') as HTMLInputElement
|
||||
await userEvent.type(pfxInput, 'pfx-secret')
|
||||
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
expect(exportMutateFn.mock.calls[0][0]).toMatchObject({
|
||||
format: 'pfx',
|
||||
pfxPassword: 'pfx-secret',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns early from submit when certificate is null', async () => {
|
||||
renderDialog(null)
|
||||
// Dialog doesn't render without open+cert, so no submit button to click
|
||||
// Just verify no calls
|
||||
expect(exportMutateFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses certificate name in download filename on success', async () => {
|
||||
const fakeBlob = new Blob(['data'])
|
||||
global.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
let capturedAnchor: HTMLAnchorElement | null = null
|
||||
exportMutateFn.mockImplementation(
|
||||
(_params: unknown, opts: { onSuccess: (b: Blob) => void }) => {
|
||||
const origCreate = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => {
|
||||
const el = origCreate(tag) as HTMLAnchorElement
|
||||
el.remove = vi.fn()
|
||||
capturedAnchor = el
|
||||
return el
|
||||
})
|
||||
opts.onSuccess(fakeBlob)
|
||||
},
|
||||
)
|
||||
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByTestId('export-certificate-submit'))
|
||||
expect(capturedAnchor!.download).toBe('Test Cert.pem')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { createTestQueryClient } from '../../../test/createTestQueryClient'
|
||||
import CertificateUploadDialog from '../CertificateUploadDialog'
|
||||
import { toast } from '../../../utils/toast'
|
||||
|
||||
const uploadMutateFn = vi.fn()
|
||||
const validateMutateFn = vi.fn()
|
||||
|
||||
vi.mock('../../../hooks/useCertificates', () => ({
|
||||
useUploadCertificate: vi.fn(() => ({
|
||||
mutate: uploadMutateFn,
|
||||
isPending: false,
|
||||
})),
|
||||
useValidateCertificate: vi.fn(() => ({
|
||||
mutate: validateMutateFn,
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
function renderDialog(open = true, onOpenChange = vi.fn()) {
|
||||
const qc = createTestQueryClient()
|
||||
return {
|
||||
onOpenChange,
|
||||
...render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CertificateUploadDialog open={open} onOpenChange={onOpenChange} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function createFile(name = 'test.pem'): File {
|
||||
return new File(['cert-content'], name, { type: 'application/x-pem-file' })
|
||||
}
|
||||
|
||||
describe('CertificateUploadDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders dialog when open', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByTestId('certificate-upload-dialog')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.uploadCertificate')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderDialog(false)
|
||||
expect(screen.queryByTestId('certificate-upload-dialog')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('shows certificate file drop zone', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.certificateFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows private key and chain file zones for non-PFX', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.chainFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows name input', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('certificates.friendlyName')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has cancel and submit buttons', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByText('common.cancel')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.uploadAndSave')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows validate button after cert file is selected', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = createFile()
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
expect(await screen.findByTestId('validate-certificate-btn')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls validate mutation on validate click', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = createFile()
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
|
||||
const validateBtn = await screen.findByTestId('validate-certificate-btn')
|
||||
await userEvent.click(validateBtn)
|
||||
|
||||
expect(validateMutateFn).toHaveBeenCalledTimes(1)
|
||||
expect(validateMutateFn.mock.calls[0][0]).toMatchObject({
|
||||
certFile: file,
|
||||
})
|
||||
})
|
||||
|
||||
it('calls upload mutation on form submit with name and cert', async () => {
|
||||
renderDialog()
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert')
|
||||
await userEvent.type(nameInput, 'My Cert')
|
||||
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = createFile()
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
|
||||
const submitBtn = screen.getByTestId('upload-certificate-submit')
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
expect(uploadMutateFn).toHaveBeenCalledTimes(1)
|
||||
expect(uploadMutateFn.mock.calls[0][0]).toMatchObject({
|
||||
name: 'My Cert',
|
||||
certFile: file,
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onOpenChange(false) on cancel click', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
renderDialog(true, onOpenChange)
|
||||
const cancelBtn = screen.getByText('common.cancel')
|
||||
await userEvent.click(cancelBtn)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('shows PFX message when PFX file is selected', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['pfx-content'], 'cert.pfx', { type: 'application/x-pkcs12' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
expect(await screen.findByText('certificates.pfxDetected')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides key and chain drop zones for PFX files', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['pfx-content'], 'cert.pfx', { type: 'application/x-pkcs12' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('certificates.privateKeyFile')).toBeFalsy()
|
||||
expect(screen.queryByText('certificates.chainFile')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows toast on upload success', async () => {
|
||||
uploadMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: () => void }) => {
|
||||
opts.onSuccess()
|
||||
})
|
||||
renderDialog()
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert')
|
||||
await userEvent.type(nameInput, 'Cert')
|
||||
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
fireEvent.change(certInput, { target: { files: [createFile()] } })
|
||||
|
||||
await userEvent.click(screen.getByTestId('upload-certificate-submit'))
|
||||
expect(toast.success).toHaveBeenCalledWith('certificates.uploadSuccess')
|
||||
})
|
||||
|
||||
it('shows toast on upload error', async () => {
|
||||
uploadMutateFn.mockImplementation((_params: unknown, opts: { onError: (e: Error) => void }) => {
|
||||
opts.onError(new Error('Upload failed'))
|
||||
})
|
||||
renderDialog()
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert')
|
||||
await userEvent.type(nameInput, 'Cert')
|
||||
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
fireEvent.change(certInput, { target: { files: [createFile()] } })
|
||||
|
||||
await userEvent.click(screen.getByTestId('upload-certificate-submit'))
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows validation preview after successful validation', async () => {
|
||||
const mockResult = {
|
||||
valid: true,
|
||||
common_name: 'test.com',
|
||||
domains: ['test.com'],
|
||||
issuer_org: 'CA',
|
||||
expires_at: '2026-01-01',
|
||||
key_match: false,
|
||||
chain_valid: false,
|
||||
chain_depth: 0,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
}
|
||||
validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => {
|
||||
opts.onSuccess(mockResult)
|
||||
})
|
||||
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
fireEvent.change(certInput, { target: { files: [createFile()] } })
|
||||
|
||||
await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
|
||||
expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows toast on validate error', async () => {
|
||||
validateMutateFn.mockImplementation((_params: unknown, opts: { onError: (e: Error) => void }) => {
|
||||
opts.onError(new Error('Validation failed'))
|
||||
})
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
fireEvent.change(certInput, { target: { files: [createFile()] } })
|
||||
|
||||
await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
|
||||
expect(toast.error).toHaveBeenCalledWith('Validation failed')
|
||||
})
|
||||
|
||||
it('detects .p12 as PFX format', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['pkcs12'], 'bundle.p12', { type: 'application/x-pkcs12' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
expect(await screen.findByText('certificates.pfxDetected')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('detects .crt as PEM format', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['cert'], 'my.crt', { type: 'application/x-x509' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
// PEM does not hide key/chain zones
|
||||
expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('detects .cer as PEM format', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['cert'], 'my.cer', { type: 'application/x-x509' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('detects .der format', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['der'], 'cert.der', { type: 'application/x-x509' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('detects .key format', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['key'], 'private.key', { type: 'application/x-pem-file' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles unknown file extension gracefully', async () => {
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = new File(['data'], 'cert.xyz', { type: 'application/octet-stream' })
|
||||
fireEvent.change(certInput, { target: { files: [file] } })
|
||||
// Should still show key/chain zones (not PFX)
|
||||
expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resets validation when cert file changes', async () => {
|
||||
const mockResult = {
|
||||
valid: true,
|
||||
common_name: 'test.com',
|
||||
domains: ['test.com'],
|
||||
issuer_org: 'CA',
|
||||
expires_at: '2026-01-01',
|
||||
key_match: false,
|
||||
chain_valid: false,
|
||||
chain_depth: 0,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
}
|
||||
validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => {
|
||||
opts.onSuccess(mockResult)
|
||||
})
|
||||
|
||||
renderDialog()
|
||||
const certInput = document.getElementById('cert-file') as HTMLInputElement
|
||||
fireEvent.change(certInput, { target: { files: [createFile()] } })
|
||||
await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
|
||||
expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy()
|
||||
|
||||
// Change cert file — validation result should disappear
|
||||
const newFile = new File(['new-cert'], 'new.pem', { type: 'application/x-pem-file' })
|
||||
fireEvent.change(certInput, { target: { files: [newFile] } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
157
frontend/src/components/ui/__tests__/FileDropZone.test.tsx
Normal file
157
frontend/src/components/ui/__tests__/FileDropZone.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { FileDropZone } from '../FileDropZone'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultProps = {
|
||||
id: 'cert-file',
|
||||
label: 'Certificate File',
|
||||
file: null as File | null,
|
||||
onFileChange: vi.fn(),
|
||||
}
|
||||
|
||||
function createFile(name = 'test.pem', type = 'application/x-pem-file'): File {
|
||||
return new File(['cert-content'], name, { type })
|
||||
}
|
||||
|
||||
describe('FileDropZone', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders label and empty drop zone', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
expect(screen.getByText('Certificate File')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.dropFileHere')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows required asterisk when required', () => {
|
||||
render(<FileDropZone {...defaultProps} required />)
|
||||
expect(screen.getByText('*')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays file name when a file is provided', () => {
|
||||
const file = createFile('my-cert.pem')
|
||||
render(<FileDropZone {...defaultProps} file={file} />)
|
||||
expect(screen.getByText('my-cert.pem')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays format badge when file is provided', () => {
|
||||
const file = createFile('my-cert.pem')
|
||||
render(<FileDropZone {...defaultProps} file={file} formatBadge="PEM" />)
|
||||
expect(screen.getByText('PEM')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('triggers file input on click', async () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
await userEvent.click(dropZone)
|
||||
// The hidden file input should exist
|
||||
const input = document.getElementById('cert-file') as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
expect(input.type).toBe('file')
|
||||
})
|
||||
|
||||
it('calls onFileChange when a file is selected via input', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const input = document.getElementById('cert-file') as HTMLInputElement
|
||||
const file = createFile()
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
expect(defaultProps.onFileChange).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('calls onFileChange on drop', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
const file = createFile()
|
||||
|
||||
fireEvent.dragOver(dropZone, { dataTransfer: { files: [file] } })
|
||||
fireEvent.drop(dropZone, { dataTransfer: { files: [file] } })
|
||||
|
||||
expect(defaultProps.onFileChange).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('does not call onFileChange on drop when disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} disabled />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
const file = createFile()
|
||||
|
||||
fireEvent.drop(dropZone, { dataTransfer: { files: [file] } })
|
||||
|
||||
expect(defaultProps.onFileChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates via keyboard Enter', async () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
fireEvent.keyDown(dropZone, { key: 'Enter' })
|
||||
// Should not throw; input ref click would be called
|
||||
})
|
||||
|
||||
it('activates via keyboard Space', async () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
fireEvent.keyDown(dropZone, { key: ' ' })
|
||||
})
|
||||
|
||||
it('does not activate via keyboard when disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} disabled />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
fireEvent.keyDown(dropZone, { key: 'Enter' })
|
||||
// No crash, no file change
|
||||
expect(defaultProps.onFileChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets aria-disabled when disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} disabled />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('has tabIndex=-1 when disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} disabled />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.tabIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('has tabIndex=0 when not disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.tabIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('has appropriate aria-label when file is selected', () => {
|
||||
const file = createFile('cert.pem')
|
||||
render(<FileDropZone {...defaultProps} file={file} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.getAttribute('aria-label')).toBe('Certificate File: cert.pem')
|
||||
})
|
||||
|
||||
it('handles dragLeave event', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } })
|
||||
fireEvent.dragLeave(dropZone)
|
||||
// No crash; drag state should reset
|
||||
})
|
||||
|
||||
it('sets accept attribute on input', () => {
|
||||
render(<FileDropZone {...defaultProps} accept=".pem,.crt" />)
|
||||
const input = document.getElementById('cert-file') as HTMLInputElement
|
||||
expect(input.getAttribute('accept')).toBe('.pem,.crt')
|
||||
})
|
||||
|
||||
it('sets aria-required on input when required', () => {
|
||||
render(<FileDropZone {...defaultProps} required />)
|
||||
const input = document.getElementById('cert-file') as HTMLInputElement
|
||||
expect(input.getAttribute('aria-required')).toBe('true')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user