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 }