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:
GitHub Actions
2026-04-13 01:55:40 +00:00
parent 9d8d97e556
commit e1bc648dfc
9 changed files with 1514 additions and 2 deletions

View File

@@ -1,12 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getCertificates, uploadCertificate, deleteCertificate, type Certificate } from '../certificates';
import {
getCertificates,
getCertificateDetail,
uploadCertificate,
updateCertificate,
deleteCertificate,
exportCertificate,
validateCertificate,
type Certificate,
type CertificateDetail,
} from '../certificates';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
@@ -52,4 +63,73 @@ describe('certificates API', () => {
await deleteCertificate('abc-123');
expect(client.delete).toHaveBeenCalledWith('/certificates/abc-123');
});
it('getCertificateDetail calls client.get with uuid', async () => {
const detail: CertificateDetail = {
...mockCert,
assigned_hosts: [],
chain: [],
auto_renew: false,
created_at: '2023-01-01',
updated_at: '2023-01-01',
};
vi.mocked(client.get).mockResolvedValue({ data: detail });
const result = await getCertificateDetail('abc-123');
expect(client.get).toHaveBeenCalledWith('/certificates/abc-123');
expect(result).toEqual(detail);
});
it('updateCertificate calls client.put with name', async () => {
vi.mocked(client.put).mockResolvedValue({ data: mockCert });
const result = await updateCertificate('abc-123', 'New Name');
expect(client.put).toHaveBeenCalledWith('/certificates/abc-123', { name: 'New Name' });
expect(result).toEqual(mockCert);
});
it('exportCertificate calls client.post with blob response type', async () => {
const blob = new Blob(['data']);
vi.mocked(client.post).mockResolvedValue({ data: blob });
const result = await exportCertificate('abc-123', 'pem', true, 'pass', 'pfx-pass');
expect(client.post).toHaveBeenCalledWith(
'/certificates/abc-123/export',
{ format: 'pem', include_key: true, password: 'pass', pfx_password: 'pfx-pass' },
{ responseType: 'blob' },
);
expect(result).toEqual(blob);
});
it('validateCertificate calls client.post with FormData', async () => {
const validation = { valid: true, common_name: 'example.com', domains: ['example.com'], issuer_org: 'LE', expires_at: '2024-01-01', key_match: true, chain_valid: true, chain_depth: 1, warnings: [], errors: [] };
vi.mocked(client.post).mockResolvedValue({ data: validation });
const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' });
const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' });
const result = await validateCertificate(certFile, keyFile);
expect(client.post).toHaveBeenCalledWith('/certificates/validate', expect.any(FormData), {
headers: { 'Content-Type': 'multipart/form-data' },
});
expect(result).toEqual(validation);
});
it('uploadCertificate includes chain file when provided', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockCert });
const certFile = new File(['cert'], 'cert.pem');
const keyFile = new File(['key'], 'key.pem');
const chainFile = new File(['chain'], 'chain.pem');
await uploadCertificate('My Cert', certFile, keyFile, chainFile);
const formData = vi.mocked(client.post).mock.calls[0][1] as FormData;
expect(formData.get('chain_file')).toBeTruthy();
});
it('validateCertificate includes chain file when provided', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} });
const certFile = new File(['cert'], 'cert.pem');
const chainFile = new File(['chain'], 'chain.pem');
await validateCertificate(certFile, undefined, chainFile);
const formData = vi.mocked(client.post).mock.calls[0][1] as FormData;
expect(formData.get('chain_file')).toBeTruthy();
expect(formData.get('key_file')).toBeNull();
});
});

View File

@@ -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',
)
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})
})

View 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')
})
})

View File

@@ -0,0 +1,238 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as api from '../../api/certificates';
import type { Certificate, CertificateDetail } from '../../api/certificates';
import {
useCertificates,
useCertificateDetail,
useUploadCertificate,
useUpdateCertificate,
useDeleteCertificate,
useExportCertificate,
useValidateCertificate,
useBulkDeleteCertificates,
} from '../useCertificates';
vi.mock('../../api/certificates');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
const mockCert: Certificate = {
uuid: 'abc-123',
domains: 'example.com',
issuer: "Let's Encrypt",
expires_at: '2025-01-01',
status: 'valid',
provider: 'letsencrypt',
has_key: true,
in_use: false,
};
const mockDetail: CertificateDetail = {
...mockCert,
assigned_hosts: [],
chain: [],
auto_renew: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
describe('useCertificates hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCertificates', () => {
it('fetches certificate list', async () => {
vi.mocked(api.getCertificates).mockResolvedValue([mockCert]);
const { result } = renderHook(() => useCertificates(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.certificates).toEqual([mockCert]);
});
it('returns empty array when no data', async () => {
vi.mocked(api.getCertificates).mockResolvedValue([]);
const { result } = renderHook(() => useCertificates(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.certificates).toEqual([]);
});
});
describe('useCertificateDetail', () => {
it('fetches certificate detail by uuid', async () => {
vi.mocked(api.getCertificateDetail).mockResolvedValue(mockDetail);
const { result } = renderHook(() => useCertificateDetail('abc-123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.detail).toEqual(mockDetail);
});
it('does not fetch when uuid is null', () => {
const { result } = renderHook(() => useCertificateDetail(null), {
wrapper: createWrapper(),
});
expect(api.getCertificateDetail).not.toHaveBeenCalled();
expect(result.current.detail).toBeUndefined();
});
});
describe('useUploadCertificate', () => {
it('uploads certificate and invalidates cache', async () => {
vi.mocked(api.uploadCertificate).mockResolvedValue(mockCert);
const { result } = renderHook(() => useUploadCertificate(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: 'My Cert',
certFile: new File(['cert'], 'cert.pem'),
keyFile: new File(['key'], 'key.pem'),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.uploadCertificate).toHaveBeenCalledWith(
'My Cert',
expect.any(File),
expect.any(File),
undefined,
);
});
});
describe('useUpdateCertificate', () => {
it('updates certificate name', async () => {
vi.mocked(api.updateCertificate).mockResolvedValue(mockCert);
const { result } = renderHook(() => useUpdateCertificate(), {
wrapper: createWrapper(),
});
result.current.mutate({ uuid: 'abc-123', name: 'Updated' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.updateCertificate).toHaveBeenCalledWith('abc-123', 'Updated');
});
});
describe('useDeleteCertificate', () => {
it('deletes certificate and invalidates cache', async () => {
vi.mocked(api.deleteCertificate).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteCertificate(), {
wrapper: createWrapper(),
});
result.current.mutate('abc-123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.deleteCertificate).toHaveBeenCalledWith('abc-123');
});
});
describe('useExportCertificate', () => {
it('exports certificate as blob', async () => {
const blob = new Blob(['data']);
vi.mocked(api.exportCertificate).mockResolvedValue(blob);
const { result } = renderHook(() => useExportCertificate(), {
wrapper: createWrapper(),
});
result.current.mutate({
uuid: 'abc-123',
format: 'pem',
includeKey: true,
password: 'pass',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.exportCertificate).toHaveBeenCalledWith('abc-123', 'pem', true, 'pass', undefined);
});
});
describe('useValidateCertificate', () => {
it('validates certificate files', async () => {
const validation = {
valid: true,
common_name: 'example.com',
domains: ['example.com'],
issuer_org: 'LE',
expires_at: '2025-01-01',
key_match: true,
chain_valid: true,
chain_depth: 1,
warnings: [],
errors: [],
};
vi.mocked(api.validateCertificate).mockResolvedValue(validation);
const { result } = renderHook(() => useValidateCertificate(), {
wrapper: createWrapper(),
});
result.current.mutate({
certFile: new File(['cert'], 'cert.pem'),
keyFile: new File(['key'], 'key.pem'),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.validateCertificate).toHaveBeenCalled();
});
});
describe('useBulkDeleteCertificates', () => {
it('deletes multiple certificates', async () => {
vi.mocked(api.deleteCertificate).mockResolvedValue(undefined);
const { result } = renderHook(() => useBulkDeleteCertificates(), {
wrapper: createWrapper(),
});
result.current.mutate(['uuid-1', 'uuid-2']);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.deleteCertificate).toHaveBeenCalledTimes(2);
expect(result.current.data).toEqual({ succeeded: 2, failed: 0 });
});
it('reports partial failures', async () => {
vi.mocked(api.deleteCertificate)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('fail'));
const { result } = renderHook(() => useBulkDeleteCertificates(), {
wrapper: createWrapper(),
});
result.current.mutate(['uuid-1', 'uuid-2']);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ succeeded: 1, failed: 1 });
});
});
});

View File

@@ -103,7 +103,7 @@ export default function ProxyHosts() {
const certStatusByDomain = useMemo(() => {
const map: Record<string, { status: string; provider: string }> = {}
for (const cert of certificates) {
const domains = cert.domains.split(',').map(d => d.trim().toLowerCase())
const domains = (cert.domains || '').split(',').map(d => d.trim().toLowerCase()).filter(Boolean)
for (const domain of domains) {
if (!map[domain]) {
map[domain] = { status: cert.status, provider: cert.provider }