fix: add DeleteCertificateDialog component with confirmation dialog for certificate deletion

- Implement DeleteCertificateDialog component to handle certificate deletion confirmation.
- Add tests for DeleteCertificateDialog covering various scenarios including rendering, confirmation, and cancellation.
- Update translation files for multiple languages to include new strings related to certificate deletion.
- Create end-to-end tests for certificate deletion UX, including button visibility, confirmation dialog, and success/failure scenarios.
This commit is contained in:
GitHub Actions
2026-03-22 13:25:15 +00:00
parent 2c9c791ae5
commit 441864be95
18 changed files with 1821 additions and 647 deletions
@@ -1,12 +1,12 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCertificates } from '../../hooks/useCertificates'
import { useProxyHosts } from '../../hooks/useProxyHosts'
import { createTestQueryClient } from '../../test/createTestQueryClient'
import CertificateList from '../CertificateList'
import CertificateList, { isDeletable, isInUse } from '../CertificateList'
import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
@@ -23,6 +23,13 @@ vi.mock('../../api/backups', () => ({
createBackup: vi.fn(async () => ({ filename: 'backup-cert' })),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}),
}))
vi.mock('../../hooks/useProxyHosts', () => ({
useProxyHosts: vi.fn(),
}))
@@ -42,6 +49,8 @@ const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertifi
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' },
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 5, name: 'ExpiredLE', domain: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt' },
{ id: 6, name: 'ValidLE', domain: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt' },
]
return {
@@ -107,58 +116,122 @@ beforeEach(() => {
})
describe('CertificateList', () => {
it('deletes custom certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
describe('isDeletable', () => {
const noHosts: ProxyHost[] = []
const withHost = (certId: number): ProxyHost[] => [createProxyHost({ certificate_id: certId })]
it('returns true for custom cert not in use', () => {
const cert: Certificate = { id: 1, name: 'C', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isDeletable(cert, noHosts)).toBe(true)
})
it('returns true for staging cert not in use', () => {
const cert: Certificate = { id: 2, name: 'S', domain: 'd', issuer: 'X', expires_at: '', status: 'untrusted', provider: 'letsencrypt-staging' }
expect(isDeletable(cert, noHosts)).toBe(true)
})
it('returns true for expired LE cert not in use', () => {
const cert: Certificate = { id: 3, name: 'E', domain: 'd', issuer: 'LE', expires_at: '', status: 'expired', provider: 'letsencrypt' }
expect(isDeletable(cert, noHosts)).toBe(true)
})
it('returns false for valid LE cert not in use', () => {
const cert: Certificate = { id: 4, name: 'V', domain: 'd', issuer: 'LE', expires_at: '', status: 'valid', provider: 'letsencrypt' }
expect(isDeletable(cert, noHosts)).toBe(false)
})
it('returns false for cert in use', () => {
const cert: Certificate = { id: 5, name: 'U', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isDeletable(cert, withHost(5))).toBe(false)
})
it('returns false for cert without id', () => {
const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isDeletable(cert, noHosts)).toBe(false)
})
it('returns false for expiring LE cert not in use', () => {
const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
expect(isDeletable(cert, noHosts)).toBe(false)
})
})
describe('isInUse', () => {
it('returns true when host references cert by certificate_id', () => {
const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isInUse(cert, [createProxyHost({ certificate_id: 10 })])).toBe(true)
})
it('returns true when host references cert via certificate.id', () => {
const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
const host = createProxyHost({ certificate_id: undefined, certificate: { id: 10, uuid: 'u', name: 'c', provider: 'custom', domains: 'd', expires_at: '' } })
expect(isInUse(cert, [host])).toBe(true)
})
it('returns false when no host references cert', () => {
const cert: Certificate = { id: 99, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
expect(isInUse(cert, [createProxyHost({ certificate_id: 3 })])).toBe(false)
})
})
it('renders delete button for deletable certs', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
expect(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })).toBeInTheDocument()
})
it('renders delete button for expired LE cert not in use', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const expiredLeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ExpiredLE'))!
expect(within(expiredLeRow).getByRole('button', { name: 'certificates.deleteTitle' })).toBeInTheDocument()
})
it('renders aria-disabled delete button for in-use cert', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const activeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ActiveCert'))!
const btn = within(activeRow).getByRole('button', { name: 'certificates.deleteTitle' })
expect(btn).toHaveAttribute('aria-disabled', 'true')
})
it('hides delete button for valid production LE cert', async () => {
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const validLeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ValidLE'))!
expect(within(validLeRow).queryByRole('button', { name: 'certificates.deleteTitle' })).not.toBeInTheDocument()
})
it('opens dialog and deletes cert on confirm', async () => {
const { deleteCertificate } = await import('../../api/certificates')
const { createBackup } = await import('../../api/backups')
const { toast } = await import('../../utils/toast')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert')) as HTMLElement
expect(customRow).toBeTruthy()
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
expect(customBtn).toBeTruthy()
await user.click(customBtn)
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
await waitFor(() => expect(createBackup).toHaveBeenCalled())
const dialog = await screen.findByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(within(dialog).getByText('certificates.deleteTitle')).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Certificate deleted'))
confirmSpy.mockRestore()
})
it('deletes staging certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
expect(stagingButtons.length).toBeGreaterThan(0)
await user.click(stagingButtons[0])
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
confirmSpy.mockRestore()
})
it('deletes valid custom certificate when not in use', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
it('does not call createBackup on delete (server handles it)', async () => {
const { createBackup } = await import('../../api/backups')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const rows = await screen.findAllByRole('row')
const unusedRow = rows.find(r => r.querySelector('td')?.textContent?.includes('UnusedValidCert')) as HTMLElement
expect(unusedRow).toBeTruthy()
const unusedButton = unusedRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
expect(unusedButton).toBeTruthy()
await user.click(unusedButton)
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
await waitFor(() => expect(createBackup).toHaveBeenCalled())
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(4))
confirmSpy.mockRestore()
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
await waitFor(() => expect(createBackup).not.toHaveBeenCalled())
})
it('renders empty state when no certificates exist', async () => {