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%
276 lines
8.8 KiB
TypeScript
276 lines
8.8 KiB
TypeScript
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')
|
|
})
|
|
})
|