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