feat: add certificate export and upload dialogs

- Implemented CertificateExportDialog for exporting certificates in various formats (PEM, PFX, DER) with options to include private keys and set passwords.
- Created CertificateUploadDialog for uploading certificates, including validation and support for multiple file types (certificates, private keys, chain files).
- Updated DeleteCertificateDialog to use 'domains' instead of 'domain' for consistency.
- Refactored BulkDeleteCertificateDialog and DeleteCertificateDialog tests to accommodate changes in certificate structure.
- Added FileDropZone component for improved file upload experience.
- Enhanced translation files with new keys for certificate management features.
- Updated Certificates page to utilize the new CertificateUploadDialog and clean up the upload logic.
- Adjusted Dashboard and ProxyHosts pages to reflect changes in certificate data structure.
This commit is contained in:
GitHub Actions
2026-04-11 23:32:22 +00:00
parent e49ea7061a
commit 30c9d735aa
26 changed files with 1428 additions and 531 deletions
@@ -1,55 +1,27 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { uploadCertificate, type Certificate } from '../../api/certificates'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
import Certificates from '../Certificates'
const translations: Record<string, string> = {
'certificates.addCertificate': 'Add Certificate',
'certificates.uploadCertificate': 'Upload Certificate',
'certificates.friendlyName': 'Friendly Name',
'certificates.certificatePem': 'Certificate (PEM)',
'certificates.privateKeyPem': 'Private Key (PEM)',
'certificates.uploadSuccess': 'Certificate uploaded successfully',
'certificates.uploadFailed': 'Failed to upload certificate',
'common.upload': 'Upload',
'common.cancel': 'Cancel',
}
const t = (key: string, options?: Record<string, unknown>) => {
const template = translations[key] ?? key
if (!options) return template
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
return acc.replace(`{{${optionKey}}}`, String(optionValue))
}, template)
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t,
t: (key: string) => key,
}),
}))
vi.mock('../../components/CertificateList', () => ({
default: () => <div>CertificateList</div>,
default: () => <div data-testid="certificate-list">CertificateList</div>,
}))
vi.mock('../../api/certificates', () => ({
uploadCertificate: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
vi.mock('../../components/dialogs/CertificateUploadDialog', () => ({
default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) =>
open ? (
<div role="dialog" data-testid="upload-dialog">
<button onClick={() => onOpenChange(false)}>Close</button>
</div>
) : null,
}))
describe('Certificates', () => {
@@ -57,93 +29,35 @@ describe('Certificates', () => {
vi.clearAllMocks()
})
it('uploads certificate and closes dialog on success', async () => {
const certificate: Certificate = {
domain: 'example.com',
issuer: 'Test CA',
expires_at: '2026-03-01T00:00:00Z',
status: 'valid',
provider: 'custom',
}
vi.mocked(uploadCertificate).mockResolvedValue(certificate)
const user = userEvent.setup()
const { queryClient } = renderWithQueryClient(<Certificates />)
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] })
expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess'))
})
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument()
})
it('renders the page with certificate list and add button', () => {
renderWithQueryClient(<Certificates />)
expect(screen.getByText('certificates.addCertificate')).toBeInTheDocument()
expect(screen.getByTestId('certificate-list')).toBeInTheDocument()
})
it('surfaces upload errors', async () => {
vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed'))
it('opens upload dialog when add button is clicked', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Certificates />)
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
})
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
it('closes upload dialog via onOpenChange callback', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Certificates />)
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.click(screen.getByText('Close'))
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
})
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`)
})
it('renders info alert with note text', () => {
renderWithQueryClient(<Certificates />)
expect(screen.getByText('certificates.noteText')).toBeInTheDocument()
})
})
@@ -485,8 +485,8 @@ describe('ProxyHosts - Coverage enhancements', () => {
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([
{ domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
{ domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
{ uuid: 'cert-staging', domains: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true },
{ uuid: 'cert-lets', domains: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true },
])
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
@@ -190,12 +190,15 @@ describe('ProxyHosts page extra tests', () => {
certificates: [
{
id: 1,
uuid: 'cert-le-1',
name: 'LE',
domain: 'valid.example.com',
domains: 'valid.example.com',
issuer: 'letsencrypt',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
status: 'valid',
provider: 'letsencrypt',
has_key: false,
in_use: true,
},
],
}),