diff --git a/frontend/src/api/__tests__/certificates.test.ts b/frontend/src/api/__tests__/certificates.test.ts
index 5777290f..140d7086 100644
--- a/frontend/src/api/__tests__/certificates.test.ts
+++ b/frontend/src/api/__tests__/certificates.test.ts
@@ -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();
+ });
});
diff --git a/frontend/src/components/__tests__/CertificateChainViewer.test.tsx b/frontend/src/components/__tests__/CertificateChainViewer.test.tsx
new file mode 100644
index 00000000..cb88c7fd
--- /dev/null
+++ b/frontend/src/components/__tests__/CertificateChainViewer.test.tsx
@@ -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()
+ expect(screen.getByText('certificates.noChainData')).toBeTruthy()
+ })
+
+ it('renders single entry as leaf', () => {
+ render()
+ expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
+ expect(screen.getByText('Subject 0')).toBeTruthy()
+ })
+
+ it('renders two entries as leaf + root', () => {
+ render()
+ expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
+ expect(screen.getByText('certificates.chainRoot')).toBeTruthy()
+ })
+
+ it('renders three entries as leaf + intermediate + root', () => {
+ render()
+ 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()
+ expect(screen.getByText(/Issuer 0/)).toBeTruthy()
+ expect(screen.getByText(/Issuer 1/)).toBeTruthy()
+ })
+
+ it('displays formatted expiration dates', () => {
+ render()
+ 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()
+ expect(screen.getByRole('list')).toBeTruthy()
+ expect(screen.getAllByRole('listitem')).toHaveLength(2)
+ })
+
+ it('has aria-label on list', () => {
+ render()
+ expect(screen.getByRole('list').getAttribute('aria-label')).toBe(
+ 'certificates.certificateChain',
+ )
+ })
+})
diff --git a/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx b/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx
new file mode 100644
index 00000000..5f97a288
--- /dev/null
+++ b/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx
@@ -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 {
+ 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()
+ expect(screen.getByText('certificates.validCertificate')).toBeTruthy()
+ expect(screen.getByTestId('certificate-validation-preview')).toBeTruthy()
+ })
+
+ it('renders invalid certificate state', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('certificates.invalidCertificate')).toBeTruthy()
+ })
+
+ it('displays common name', () => {
+ render()
+ expect(screen.getByText('example.com')).toBeTruthy()
+ })
+
+ it('displays domains joined by comma', () => {
+ render()
+ expect(screen.getByText('example.com, www.example.com')).toBeTruthy()
+ })
+
+ it('displays dash when no domains provided', () => {
+ render(
+ ,
+ )
+ const dashes = screen.getAllByText('-')
+ expect(dashes.length).toBeGreaterThan(0)
+ })
+
+ it('displays issuer organization', () => {
+ render()
+ expect(screen.getByText('Test CA')).toBeTruthy()
+ })
+
+ it('displays formatted expiration date', () => {
+ render()
+ const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString()
+ expect(screen.getByText(dateStr)).toBeTruthy()
+ })
+
+ it('shows Yes for key match', () => {
+ render()
+ expect(screen.getByText('Yes')).toBeTruthy()
+ })
+
+ it('shows No key provided when no key match', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('No key provided')).toBeTruthy()
+ })
+
+ it('shows chain depth when > 0', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('3')).toBeTruthy()
+ })
+
+ it('does not show chain depth when 0', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByText('certificates.chainDepth')).toBeFalsy()
+ })
+
+ it('renders warnings when present', () => {
+ render(
+ ,
+ )
+ 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()
+ expect(screen.queryByText('certificates.warnings')).toBeFalsy()
+ })
+
+ it('renders errors when present', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('certificates.errors')).toBeTruthy()
+ expect(screen.getByText('Certificate revoked')).toBeTruthy()
+ })
+
+ it('does not render errors section when empty', () => {
+ render()
+ expect(screen.queryByText('certificates.errors')).toBeFalsy()
+ })
+
+ it('has correct region role and aria-label', () => {
+ render()
+ const region = screen.getByRole('region')
+ expect(region.getAttribute('aria-label')).toBe('certificates.validationPreview')
+ })
+})
diff --git a/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx
new file mode 100644
index 00000000..c2ced156
--- /dev/null
+++ b/frontend/src/components/dialogs/__tests__/CertificateDetailDialog.test.tsx
@@ -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(
+
+
+ ,
+ ),
+ }
+}
+
+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()
+ })
+})
diff --git a/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx
new file mode 100644
index 00000000..dc6b140a
--- /dev/null
+++ b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx
@@ -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(
+
+
+ ,
+ ),
+ }
+}
+
+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')
+ })
+})
diff --git a/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx
new file mode 100644
index 00000000..80f1448a
--- /dev/null
+++ b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx
@@ -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(
+
+
+ ,
+ ),
+ }
+}
+
+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()
+ })
+ })
+})
diff --git a/frontend/src/components/ui/__tests__/FileDropZone.test.tsx b/frontend/src/components/ui/__tests__/FileDropZone.test.tsx
new file mode 100644
index 00000000..bbe99ae0
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/FileDropZone.test.tsx
@@ -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()
+ expect(screen.getByText('Certificate File')).toBeTruthy()
+ expect(screen.getByText('certificates.dropFileHere')).toBeTruthy()
+ })
+
+ it('shows required asterisk when required', () => {
+ render()
+ expect(screen.getByText('*')).toBeTruthy()
+ })
+
+ it('displays file name when a file is provided', () => {
+ const file = createFile('my-cert.pem')
+ render()
+ expect(screen.getByText('my-cert.pem')).toBeTruthy()
+ })
+
+ it('displays format badge when file is provided', () => {
+ const file = createFile('my-cert.pem')
+ render()
+ expect(screen.getByText('PEM')).toBeTruthy()
+ })
+
+ it('triggers file input on click', async () => {
+ render()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ const dropZone = screen.getByRole('button')
+ fireEvent.keyDown(dropZone, { key: ' ' })
+ })
+
+ it('does not activate via keyboard when disabled', () => {
+ render()
+ 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()
+ const dropZone = screen.getByRole('button')
+ expect(dropZone.getAttribute('aria-disabled')).toBe('true')
+ })
+
+ it('has tabIndex=-1 when disabled', () => {
+ render()
+ const dropZone = screen.getByRole('button')
+ expect(dropZone.tabIndex).toBe(-1)
+ })
+
+ it('has tabIndex=0 when not disabled', () => {
+ render()
+ 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()
+ const dropZone = screen.getByRole('button')
+ expect(dropZone.getAttribute('aria-label')).toBe('Certificate File: cert.pem')
+ })
+
+ it('handles dragLeave event', () => {
+ render()
+ 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()
+ const input = document.getElementById('cert-file') as HTMLInputElement
+ expect(input.getAttribute('accept')).toBe('.pem,.crt')
+ })
+
+ it('sets aria-required on input when required', () => {
+ render()
+ const input = document.getElementById('cert-file') as HTMLInputElement
+ expect(input.getAttribute('aria-required')).toBe('true')
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useCertificates.test.tsx b/frontend/src/hooks/__tests__/useCertificates.test.tsx
new file mode 100644
index 00000000..94bbe92d
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useCertificates.test.tsx
@@ -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 }) => (
+ {children}
+ );
+};
+
+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 });
+ });
+ });
+});
diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx
index 59d1873f..386455f4 100644
--- a/frontend/src/pages/ProxyHosts.tsx
+++ b/frontend/src/pages/ProxyHosts.tsx
@@ -103,7 +103,7 @@ export default function ProxyHosts() {
const certStatusByDomain = useMemo(() => {
const map: Record = {}
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 }