feat: add BulkDeleteCertificateDialog component for bulk certificate deletion
- Implemented BulkDeleteCertificateDialog with confirmation and listing of certificates to be deleted. - Added translations for bulk delete functionality in English, German, Spanish, French, and Chinese. - Created unit tests for BulkDeleteCertificateDialog to ensure proper rendering and functionality. - Developed end-to-end tests for bulk certificate deletion, covering selection, confirmation, and cancellation scenarios.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@ import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
|
||||
import BulkDeleteCertificateDialog from './dialogs/BulkDeleteCertificateDialog'
|
||||
import DeleteCertificateDialog from './dialogs/DeleteCertificateDialog'
|
||||
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
|
||||
import { Button } from './ui/Button'
|
||||
import { Checkbox } from './ui/Checkbox'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/Tooltip'
|
||||
import { deleteCertificate, type Certificate } from '../api/certificates'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
@@ -38,6 +41,8 @@ export default function CertificateList() {
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false)
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
@@ -55,6 +60,30 @@ export default function CertificateList() {
|
||||
},
|
||||
})
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const results = await Promise.allSettled(ids.map(id => deleteCertificate(id)))
|
||||
const failed = results.filter(r => r.status === 'rejected').length
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
||||
return { succeeded, failed }
|
||||
},
|
||||
onSuccess: ({ succeeded, failed }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteDialog(false)
|
||||
if (failed > 0) {
|
||||
toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
|
||||
} else {
|
||||
toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('certificates.bulkDeleteFailed'))
|
||||
setShowBulkDeleteDialog(false)
|
||||
},
|
||||
})
|
||||
|
||||
const sortedCertificates = useMemo(() => {
|
||||
return [...certificates].sort((a, b) => {
|
||||
let comparison = 0
|
||||
@@ -78,6 +107,39 @@ export default function CertificateList() {
|
||||
})
|
||||
}, [certificates, sortColumn, sortDirection])
|
||||
|
||||
const selectableCertIds = useMemo<Set<number>>(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const cert of sortedCertificates) {
|
||||
if (isDeletable(cert, hosts) && cert.id) {
|
||||
ids.add(cert.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [sortedCertificates, hosts])
|
||||
|
||||
const allSelectableSelected =
|
||||
selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size
|
||||
const someSelected =
|
||||
selectedIds.size > 0 && selectedIds.size < selectableCertIds.size
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedIds.size === selectableCertIds.size) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(selectableCertIds))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectRow = (id: number) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
setSelectedIds(next)
|
||||
}
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
@@ -97,18 +159,46 @@ export default function CertificateList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{deleteMutation.isPending && (
|
||||
{(deleteMutation.isPending || bulkDeleteMutation.isPending) && (
|
||||
<ConfigReloadOverlay
|
||||
message="Returning to shore..."
|
||||
submessage="Certificate departure in progress"
|
||||
type="charon"
|
||||
/>
|
||||
)}
|
||||
{selectedIds.size > 0 && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-center justify-between rounded-lg border border-brand-500/30 bg-brand-500/10 px-4 py-2 mb-3"
|
||||
>
|
||||
<span className="text-sm text-gray-300">
|
||||
{t('certificates.bulkSelectedCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
leftIcon={Trash2}
|
||||
onClick={() => setShowBulkDeleteDialog(true)}
|
||||
>
|
||||
{t('certificates.bulkDeleteButton', { count: selectedIds.size })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="w-12 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={allSelectableSelected}
|
||||
indeterminate={someSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label={t('certificates.bulkSelectAll')}
|
||||
disabled={selectableCertIds.size === 0}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
onClick={() => handleSort('name')}
|
||||
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
|
||||
@@ -136,13 +226,47 @@ export default function CertificateList() {
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{certificates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
||||
No certificates found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedCertificates.map((cert) => (
|
||||
sortedCertificates.map((cert) => {
|
||||
const inUse = isInUse(cert, hosts)
|
||||
const deletable = isDeletable(cert, hosts)
|
||||
const isInUseDeletableCategory = inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')
|
||||
|
||||
return (
|
||||
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
{deletable && !inUse ? (
|
||||
<td className="w-12 px-4 py-4">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(cert.id!)}
|
||||
onCheckedChange={() => handleSelectRow(cert.id!)}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
|
||||
/>
|
||||
</td>
|
||||
) : isInUseDeletableCategory ? (
|
||||
<td className="w-12 px-4 py-4">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('certificates.deleteInUse')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</td>
|
||||
) : (
|
||||
<td className="w-12 px-4 py-4" aria-hidden="true" />
|
||||
)}
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -163,9 +287,6 @@ export default function CertificateList() {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{(() => {
|
||||
const inUse = isInUse(cert, hosts)
|
||||
const deletable = isDeletable(cert, hosts)
|
||||
|
||||
if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -204,7 +325,8 @@ export default function CertificateList() {
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -221,6 +343,13 @@ export default function CertificateList() {
|
||||
onCancel={() => setCertToDelete(null)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={sortedCertificates.filter(c => c.id && selectedIds.has(c.id))}
|
||||
open={showBulkDeleteDialog}
|
||||
onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))}
|
||||
onCancel={() => setShowBulkDeleteDialog(false)}
|
||||
isDeleting={bulkDeleteMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const getRowNames = () =>
|
||||
screen
|
||||
.getAllByRole('row')
|
||||
.slice(1)
|
||||
.map(row => row.querySelector('td')?.textContent?.trim() ?? '')
|
||||
.map(row => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -177,21 +177,21 @@ describe('CertificateList', () => {
|
||||
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'))!
|
||||
const customRow = rows.find(r => r.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'))!
|
||||
const expiredLeRow = rows.find(r => r.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 activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
|
||||
const btn = within(activeRow).getByRole('button', { name: 'certificates.deleteTitle' })
|
||||
expect(btn).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
@@ -199,7 +199,7 @@ describe('CertificateList', () => {
|
||||
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'))!
|
||||
const validLeRow = rows.find(r => r.textContent?.includes('ValidLE'))!
|
||||
expect(within(validLeRow).queryByRole('button', { name: 'certificates.deleteTitle' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('CertificateList', () => {
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
@@ -226,7 +226,7 @@ describe('CertificateList', () => {
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
@@ -254,7 +254,7 @@ describe('CertificateList', () => {
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
@@ -267,7 +267,7 @@ describe('CertificateList', () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const activeRow = rows.find(r => r.querySelector('td')?.textContent?.includes('ActiveCert'))!
|
||||
const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
|
||||
const btn = within(activeRow).getByRole('button', { name: 'certificates.deleteTitle' })
|
||||
|
||||
await user.click(btn)
|
||||
@@ -278,7 +278,7 @@ describe('CertificateList', () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))!
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
@@ -288,6 +288,115 @@ describe('CertificateList', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('renders enabled checkboxes for deletable not-in-use certs (ids 1, 2, 4, 5)', async () => {
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
for (const name of ['CustomCert', 'LE Staging', 'UnusedValidCert', 'ExpiredLE']) {
|
||||
const row = rows.find(r => r.textContent?.includes(name))!
|
||||
const checkbox = within(row).getByRole('checkbox')
|
||||
expect(checkbox).toBeEnabled()
|
||||
expect(checkbox).not.toHaveAttribute('aria-disabled', 'true')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders disabled checkbox for in-use cert (id 3)', async () => {
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
|
||||
const checkboxes = within(activeRow).getAllByRole('checkbox')
|
||||
const rowCheckbox = checkboxes[0]
|
||||
expect(rowCheckbox).toBeDisabled()
|
||||
expect(rowCheckbox).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('renders no checkbox in valid production LE cert row (id 6)', async () => {
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const validLeRow = rows.find(r => r.textContent?.includes('ValidLE'))!
|
||||
expect(within(validLeRow).queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selecting one cert makes the bulk action toolbar visible', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('checkbox'))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('header select-all selects only ids 1, 2, 4, 5 (not in-use id 3)', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const headerRow = (await screen.findAllByRole('row'))[0]
|
||||
const headerCheckbox = within(headerRow).getByRole('checkbox')
|
||||
await user.click(headerCheckbox)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
const rows = screen.getAllByRole('row').slice(1)
|
||||
const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
|
||||
const activeCheckbox = within(activeRow).getByRole('checkbox')
|
||||
expect(activeCheckbox).toBeDisabled()
|
||||
expect(activeCheckbox).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('clicking the toolbar Delete button opens BulkDeleteCertificateDialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirming in the bulk dialog calls deleteCertificate for each selected ID', async () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
const stagingRow = rows.find(r => r.textContent?.includes('LE Staging'))!
|
||||
await user.click(within(customRow).getByRole('checkbox'))
|
||||
await user.click(within(stagingRow).getByRole('checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
|
||||
await waitFor(() => {
|
||||
expect(deleteCertificate).toHaveBeenCalledWith(1)
|
||||
expect(deleteCertificate).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows partial failure toast when some bulk deletes fail', async () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
vi.mocked(deleteCertificate).mockImplementation(async (id: number) => {
|
||||
if (id === 2) throw new Error('network error')
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
const stagingRow = rows.find(r => r.textContent?.includes('LE Staging'))!
|
||||
await user.click(within(customRow).getByRole('checkbox'))
|
||||
await user.click(within(stagingRow).getByRole('checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.bulkDeletePartial'))
|
||||
})
|
||||
|
||||
it('clicking header checkbox twice deselects all and hides the bulk action toolbar', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const headerRow = (await screen.findAllByRole('row'))[0]
|
||||
const headerCheckbox = within(headerRow).getByRole('checkbox')
|
||||
await user.click(headerCheckbox)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
await user.click(headerCheckbox)
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('sorts certificates by name and expiry when headers are clicked', async () => {
|
||||
const certificates: Certificate[] = [
|
||||
{ id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '../ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/Dialog'
|
||||
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
|
||||
interface BulkDeleteCertificateDialogProps {
|
||||
certificates: Certificate[]
|
||||
open: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
function providerLabel(cert: Certificate): string {
|
||||
if (cert.provider === 'letsencrypt-staging') return 'Staging'
|
||||
if (cert.provider === 'custom') return 'Custom'
|
||||
if (cert.status === 'expired') return 'Expired LE'
|
||||
return cert.provider
|
||||
}
|
||||
|
||||
export default function BulkDeleteCertificateDialog({
|
||||
certificates,
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDeleting,
|
||||
}: BulkDeleteCertificateDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (certificates.length === 0) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.bulkDeleteTitle', { count: certificates.length })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('certificates.bulkDeleteDescription', { count: certificates.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-red-900/50 bg-red-900/10 p-4">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400 mt-0.5" />
|
||||
<p className="text-sm text-gray-300">
|
||||
{t('certificates.bulkDeleteConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
aria-label="Certificates to be deleted"
|
||||
className="max-h-48 overflow-y-auto rounded-lg border border-gray-800 divide-y divide-gray-800"
|
||||
>
|
||||
{certificates.map((cert) => (
|
||||
<li
|
||||
key={cert.id ?? cert.domain}
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
>
|
||||
<span className="text-sm text-white">{cert.name || cert.domain}</span>
|
||||
<span className="text-xs text-gray-500">{providerLabel(cert)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onConfirm} isLoading={isDeleting}>
|
||||
{t('certificates.bulkDeleteButton', { count: certificates.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDialog'
|
||||
|
||||
import type { Certificate } from '../../../api/certificates'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => (opts ? JSON.stringify(opts) : key),
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
const makeCert = (overrides: Partial<Certificate>): Certificate => ({
|
||||
id: 1,
|
||||
name: 'Test Cert',
|
||||
domain: 'test.example.com',
|
||||
issuer: 'Custom CA',
|
||||
expires_at: '2026-01-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const certs: Certificate[] = [
|
||||
makeCert({ id: 1, name: 'Cert One', domain: 'one.example.com' }),
|
||||
makeCert({ id: 2, name: 'Cert Two', domain: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
|
||||
makeCert({ id: 3, name: 'Cert Three', domain: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
|
||||
]
|
||||
|
||||
describe('BulkDeleteCertificateDialog', () => {
|
||||
it('renders dialog with count in title when 3 certs supplied', () => {
|
||||
render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={certs}
|
||||
open={true}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
isDeleting={false}
|
||||
/>
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByRole('heading', { name: '{"count":3}' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists each certificate name in the scrollable list', () => {
|
||||
render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={certs}
|
||||
open={true}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
isDeleting={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Cert One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cert Two')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cert Three')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument()
|
||||
expect(screen.getByText('Staging')).toBeInTheDocument()
|
||||
expect(screen.getByText('Expired LE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when the Delete button is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={certs}
|
||||
open={true}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
isDeleting={false}
|
||||
/>
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: '{"count":3}' }))
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onCancel when the Cancel button is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={certs}
|
||||
open={true}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
isDeleting={false}
|
||||
/>
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.cancel' }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Delete button is loading/disabled when isDeleting is true', () => {
|
||||
render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={certs}
|
||||
open={true}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
isDeleting={true}
|
||||
/>
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const deleteBtn = within(dialog).getByRole('button', { name: '{"count":3}' })
|
||||
expect(deleteBtn).toBeDisabled()
|
||||
const cancelBtn = within(dialog).getByRole('button', { name: 'common.cancel' })
|
||||
expect(cancelBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('returns null when certificates array is empty', () => {
|
||||
const { container } = render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={[]}
|
||||
open={true}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
isDeleting={false}
|
||||
/>
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -182,7 +182,17 @@
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
"deleteButton": "Delete",
|
||||
"bulkSelectAll": "Alle löschbaren Zertifikate auswählen",
|
||||
"selectCert": "Zertifikat {{name}} auswählen",
|
||||
"bulkSelectedCount": "{{count}} Zertifikat(e) ausgewählt",
|
||||
"bulkDeleteTitle": "{{count}} Zertifikat(e) löschen",
|
||||
"bulkDeleteDescription": "{{count}} Zertifikat(e) löschen",
|
||||
"bulkDeleteConfirm": "Die folgenden Zertifikate werden dauerhaft gelöscht. Der Server erstellt vor jeder Löschung eine Sicherung.",
|
||||
"bulkDeleteButton": "{{count}} Zertifikat(e) löschen",
|
||||
"bulkDeleteSuccess": "{{count}} Zertifikat(e) gelöscht",
|
||||
"bulkDeletePartial": "{{deleted}} gelöscht, {{failed}} fehlgeschlagen",
|
||||
"bulkDeleteFailed": "Zertifikate konnten nicht gelöscht werden"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
|
||||
@@ -191,7 +191,17 @@
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
"deleteButton": "Delete",
|
||||
"bulkSelectAll": "Select all deletable certificates",
|
||||
"selectCert": "Select certificate {{name}}",
|
||||
"bulkSelectedCount": "{{count}} certificate(s) selected",
|
||||
"bulkDeleteTitle": "Delete {{count}} Certificate(s)",
|
||||
"bulkDeleteDescription": "Delete {{count}} certificate(s)",
|
||||
"bulkDeleteConfirm": "The following certificates will be permanently deleted. The server creates a backup before each removal.",
|
||||
"bulkDeleteButton": "Delete {{count}} Certificate(s)",
|
||||
"bulkDeleteSuccess": "{{count}} certificate(s) deleted",
|
||||
"bulkDeletePartial": "{{deleted}} deleted, {{failed}} failed",
|
||||
"bulkDeleteFailed": "Failed to delete certificates"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
|
||||
@@ -182,7 +182,17 @@
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
"deleteButton": "Delete",
|
||||
"bulkSelectAll": "Seleccionar todos los certificados eliminables",
|
||||
"selectCert": "Seleccionar certificado {{name}}",
|
||||
"bulkSelectedCount": "{{count}} certificado(s) seleccionado(s)",
|
||||
"bulkDeleteTitle": "Eliminar {{count}} Certificado(s)",
|
||||
"bulkDeleteDescription": "Eliminar {{count}} certificado(s)",
|
||||
"bulkDeleteConfirm": "Los siguientes certificados se eliminarán permanentemente. El servidor crea una copia de seguridad antes de cada eliminación.",
|
||||
"bulkDeleteButton": "Eliminar {{count}} Certificado(s)",
|
||||
"bulkDeleteSuccess": "{{count}} certificado(s) eliminado(s)",
|
||||
"bulkDeletePartial": "{{deleted}} eliminado(s), {{failed}} fallido(s)",
|
||||
"bulkDeleteFailed": "No se pudieron eliminar los certificados"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar Sesión",
|
||||
|
||||
@@ -182,7 +182,17 @@
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
"deleteButton": "Delete",
|
||||
"bulkSelectAll": "Sélectionner tous les certificats supprimables",
|
||||
"selectCert": "Sélectionner le certificat {{name}}",
|
||||
"bulkSelectedCount": "{{count}} certificat(s) sélectionné(s)",
|
||||
"bulkDeleteTitle": "Supprimer {{count}} Certificat(s)",
|
||||
"bulkDeleteDescription": "Supprimer {{count}} certificat(s)",
|
||||
"bulkDeleteConfirm": "Les certificats suivants seront définitivement supprimés. Le serveur crée une sauvegarde avant chaque suppression.",
|
||||
"bulkDeleteButton": "Supprimer {{count}} Certificat(s)",
|
||||
"bulkDeleteSuccess": "{{count}} certificat(s) supprimé(s)",
|
||||
"bulkDeletePartial": "{{deleted}} supprimé(s), {{failed}} échoué(s)",
|
||||
"bulkDeleteFailed": "Impossible de supprimer les certificats"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
|
||||
@@ -182,7 +182,17 @@
|
||||
"deleteSuccess": "Certificate deleted",
|
||||
"deleteFailed": "Failed to delete certificate",
|
||||
"deleteInUse": "Cannot delete — certificate is attached to a proxy host",
|
||||
"deleteButton": "Delete"
|
||||
"deleteButton": "Delete",
|
||||
"bulkSelectAll": "选择所有可删除的证书",
|
||||
"selectCert": "选择证书 {{name}}",
|
||||
"bulkSelectedCount": "已选择 {{count}} 个证书",
|
||||
"bulkDeleteTitle": "删除 {{count}} 个证书",
|
||||
"bulkDeleteDescription": "删除 {{count}} 个证书",
|
||||
"bulkDeleteConfirm": "以下证书将被永久删除。服务器在每次删除前会创建备份。",
|
||||
"bulkDeleteButton": "删除 {{count}} 个证书",
|
||||
"bulkDeleteSuccess": "已删除 {{count}} 个证书",
|
||||
"bulkDeletePartial": "已删除 {{deleted}} 个,{{failed}} 个失败",
|
||||
"bulkDeleteFailed": "证书删除失败"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
|
||||
421
tests/certificate-bulk-delete.spec.ts
Normal file
421
tests/certificate-bulk-delete.spec.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Certificate Bulk Delete E2E Tests
|
||||
*
|
||||
* Tests the bulk certificate deletion UX:
|
||||
* - Checkbox column present for each deletable cert
|
||||
* - No checkbox rendered for valid production LE certs
|
||||
* - Selection toolbar appears with count and Delete button
|
||||
* - Select-all header checkbox selects all seeded certs
|
||||
* - Bulk delete dialog shows correct count
|
||||
* - Cancel preserves all selected certs
|
||||
* - Confirming bulk delete removes all selected certs from the table
|
||||
*
|
||||
* @see /projects/Charon/docs/plans/current_spec.md §4 Phase 5
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { test, expect, loginUser } from './fixtures/auth-fixtures';
|
||||
import { request as playwrightRequest } from '@playwright/test';
|
||||
import {
|
||||
waitForLoadingComplete,
|
||||
waitForDialog,
|
||||
waitForAPIResponse,
|
||||
waitForToast,
|
||||
} from './utils/wait-helpers';
|
||||
import { generateUniqueId } from './fixtures/test-data';
|
||||
import { STORAGE_STATE } from './constants';
|
||||
|
||||
const CERTIFICATES_API = /\/api\/v1\/certificates/;
|
||||
|
||||
/**
|
||||
* Real self-signed certificate and key for upload tests.
|
||||
* Generated via: openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=test.local/O=TestOrg"
|
||||
* The backend parses X.509 data, so placeholder PEM from fixtures won't work.
|
||||
*/
|
||||
const REAL_TEST_CERT = `-----BEGIN CERTIFICATE-----
|
||||
MIIDLzCCAhegAwIBAgIUehGqwKI4zLvoZSNHlAuv7cJ0G5AwDQYJKoZIhvcNAQEL
|
||||
BQAwJzETMBEGA1UEAwwKdGVzdC5sb2NhbDEQMA4GA1UECgwHVGVzdE9yZzAeFw0y
|
||||
NjAzMjIwMzQyMDhaFw0yNzAzMjIwMzQyMDhaMCcxEzARBgNVBAMMCnRlc3QubG9j
|
||||
YWwxEDAOBgNVBAoMB1Rlc3RPcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQDdzdQfOkHzG/lZ242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdo
|
||||
ylHhIqbwNq18Dni1znDYsOAlnfZR+gv84U4klRHGE7liNRixBA5ymZ6KI68sOwqx
|
||||
bn6wpDZgNLnjD3POwSQoPEx2BAYwIyLPjXFjfnv5nce8Bt99j/zDVwhq24b9YdMR
|
||||
BVV/sOBsAtNEuRngajA9+i2rmLVrXJSiSFhA/hR0wX6bICpFTtahYX7JqfzlMHFO
|
||||
4lBka9sbC3xujwtFmLtkBovCzf69fA6p2qhJGVNJ9oHeFY3V2CdYq5Q8SZTsG1Yt
|
||||
S0O/2A9ZkQmHezeG9DYeg68nLfJDAgMBAAGjUzBRMB0GA1UdDgQWBBRE+2+ss2yl
|
||||
0vAmlccEC7MBWX6UmDAfBgNVHSMEGDAWgBRE+2+ss2yl0vAmlccEC7MBWX6UmDAP
|
||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCvwsnSRYQ5PYtuhJ3v
|
||||
YhKmjkg+NsojYItlo+UkJmq09LkIEwRqJwFLcDxhyHWqRL5Bpc1PA1VJAG6Pif8D
|
||||
uwwNnXwZZf0P5e7exccSQZnI03OhS0c6/4kfvRSiFiT6BYTYSvQ+OWhpMIIcwhov
|
||||
86muij2Y32E3F0aqOPjEB+cm/XauXzmFjXi7ig7cktphHcwT8zQn43yCG/BJfWe2
|
||||
bRLWqMy+jdr/x2Ij8eWPSlJD3zDxsQiLiO0hFzpQNHfz2Qe17K3dsuhNQ85h2s0w
|
||||
zCLDm4WygKTw2foUXGNtbWG7z6Eq7PI+2fSlJDFgb+xmdIFQdyKDsZeYO5bmdYq5
|
||||
0tY8
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
const REAL_TEST_KEY = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdzdQfOkHzG/lZ
|
||||
242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdoylHhIqbwNq18Dni1znDY
|
||||
sOAlnfZR+gv84U4klRHGE7liNRixBA5ymZ6KI68sOwqxbn6wpDZgNLnjD3POwSQo
|
||||
PEx2BAYwIyLPjXFjfnv5nce8Bt99j/zDVwhq24b9YdMRBVV/sOBsAtNEuRngajA9
|
||||
+i2rmLVrXJSiSFhA/hR0wX6bICpFTtahYX7JqfzlMHFO4lBka9sbC3xujwtFmLtk
|
||||
BovCzf69fA6p2qhJGVNJ9oHeFY3V2CdYq5Q8SZTsG1YtS0O/2A9ZkQmHezeG9DYe
|
||||
g68nLfJDAgMBAAECggEAA8uIcZsBkzNLVOpDcQvfZ+7ldkLt61x4xJUoKqRVt4/c
|
||||
usTjSYTsNdps2lzRLH+h85eRPaonDpVLAP97FlRZk+rUrFhT30mzACdI6LvtLDox
|
||||
imxudgFI91dwm2Xp7QPM77XMkxdUl+5eEVeBchN84kiiSS2BCdQZiEUsLF9sZi2P
|
||||
A5+x6XHImE+Sqfm/xVOZzHjj7ObHxc3bUpDT+RvRDvEBGjtEUlCCWuKvLi3DWIBF
|
||||
T9E38f0hqoxKwc7gsZCZs7phoVm9a3xjQ8Xh3ONLa30aBsJii33KHHxSASc7hMy1
|
||||
cM6GaGcg4xgqFw3B677KWUMc3Ur5YdLu71Bw7MFc4QKBgQD9FyRoWcTEktPdvH9y
|
||||
o7yxRVWcSs5c47h5X9rhcKvUCyEzQ/89Gt1d8e/qMv9JxXmcg3AS8VYeFmzyyMta
|
||||
iKTrHYnA8iRgM6CHvgSD4+vc7niW1de7qxW3T6MrGA4AEoQOPUvd6ZljBPIqxV8h
|
||||
jw9BW5YREZV6fXqqVOVT4GMrbQKBgQDgWpvmu1FY65TjoDljOPBtO17krwaWzb/D
|
||||
jlXQgZgRJVD7kaUPhm7Kb2d7P7t34LgzGH63hF82PlXqtwd5QhB3EZP9mhZTbXxK
|
||||
vwLf+H44ANDlcZiyDG9OJBT6ND5/JP0jHEt/KsP9pcd9xbZWNEZZFzddbbcp1G/v
|
||||
ue6p18XWbwKBgQCmdm8y10BNToldQVrOKxWzvve1CZq7i+fMpRhQyQurNvrKPkIF
|
||||
jcLlxHhZINu6SNFY+TZgry1GMtfLw/fEfzWBkvcE2f7E64/9WCSeHu4GbS8Rfmsb
|
||||
e0aYQCAA+xxSPdtvhi99MOT7NMiXCyQr7W1KPpPwfBFF9HwWxinjxiVT7QKBgFAb
|
||||
Ch9QMrN1Kiw8QUFUS0Q1NqSgedHOlPHWGH3iR9GXaVrpne31KgnNzT0MfHtJGXvk
|
||||
+xm7geN0TmkIAPsiw45AEH80TVRsezyVBwnBSA/m+q9x5/tqxTM5XuQXU1lCc7/d
|
||||
kndNZb1jO9+EgJ42/AdDatlJG2UsHOuTj8vE5zaxAoGBAPthB+5YZfu3de+vnfpa
|
||||
o0oFy++FeeHUTxor2605Lit9ZfEvDTe1/iPQw5TNOLjwx0CdsrCxWk5Tyz50aA30
|
||||
KfVperc+m+vEVXIPI1qluI0iTPcHd/lMQYCsu6tKWmFP/hAFTIy7rOHMHfPx3RzK
|
||||
yRNV1UrzJGv5ZUVKq2kymBut
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
/**
|
||||
* Read the auth JWT from the storage state's localStorage entry.
|
||||
* The Charon API requires an Authorization: Bearer header; cookies alone are not
|
||||
* sufficient in API request contexts (as opposed to browser contexts).
|
||||
*/
|
||||
function getAuthToken(baseURL: string): string | undefined {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8'));
|
||||
const origin = new URL(baseURL).origin;
|
||||
const match = (state.origins ?? []).find(
|
||||
(o: { origin: string }) => o.origin === origin
|
||||
);
|
||||
return match?.localStorage?.find(
|
||||
(e: { name: string }) => e.name === 'charon_auth_token'
|
||||
)?.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom certificate directly via the API, bypassing TestDataManager's
|
||||
* narrow CertificateData type which omits the required `name` field.
|
||||
* Returns the numeric cert ID (from list endpoint) and name for later lookup/cleanup.
|
||||
*/
|
||||
async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; certName: string }> {
|
||||
const id = generateUniqueId();
|
||||
const certName = `bulk-cert-${id}`;
|
||||
const token = getAuthToken(baseURL);
|
||||
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: STORAGE_STATE,
|
||||
...(token ? { extraHTTPHeaders: { Authorization: `Bearer ${token}` } } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await ctx.post('/api/v1/certificates', {
|
||||
multipart: {
|
||||
name: certName,
|
||||
certificate_file: {
|
||||
name: 'cert.pem',
|
||||
mimeType: 'application/x-pem-file',
|
||||
buffer: Buffer.from(REAL_TEST_CERT),
|
||||
},
|
||||
key_file: {
|
||||
name: 'key.pem',
|
||||
mimeType: 'application/x-pem-file',
|
||||
buffer: Buffer.from(REAL_TEST_KEY),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create certificate: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const createResult = await response.json();
|
||||
const certUUID: string = createResult.uuid;
|
||||
|
||||
// The create response excludes the numeric ID (json:"-" on model).
|
||||
// Query the list endpoint and match by UUID to get the numeric ID.
|
||||
const listResponse = await ctx.get('/api/v1/certificates');
|
||||
if (!listResponse.ok()) {
|
||||
throw new Error(`Failed to list certificates: ${listResponse.status()}`);
|
||||
}
|
||||
const certs: Array<{ id: number; uuid: string }> = await listResponse.json();
|
||||
const match = certs.find((c) => c.uuid === certUUID);
|
||||
if (!match) {
|
||||
throw new Error(`Certificate with UUID ${certUUID} not found in list after creation`);
|
||||
}
|
||||
|
||||
return { id: match.id, certName };
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a certificate directly via the API for cleanup.
|
||||
*/
|
||||
async function deleteCertViaAPI(baseURL: string, certId: number): Promise<void> {
|
||||
const token = getAuthToken(baseURL);
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: STORAGE_STATE,
|
||||
...(token ? { extraHTTPHeaders: { Authorization: `Bearer ${token}` } } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
await ctx.delete(`/api/v1/certificates/${certId}`);
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the certificates page and wait for data to load.
|
||||
*/
|
||||
async function navigateToCertificates(page: import('@playwright/test').Page): Promise<void> {
|
||||
const certsResponse = waitForAPIResponse(page, CERTIFICATES_API);
|
||||
await page.goto('/certificates');
|
||||
await certsResponse;
|
||||
await waitForLoadingComplete(page);
|
||||
}
|
||||
|
||||
// serial mode: tests share createdCerts[] state via beforeAll/afterAll;
|
||||
// parallelising across workers would give each worker its own isolated array.
|
||||
test.describe.serial('Certificate Bulk Delete', () => {
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080';
|
||||
const createdCerts: Array<{ id: number; certName: string }> = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cert = await createCustomCertViaAPI(baseURL);
|
||||
createdCerts.push(cert);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// .catch(() => {}) handles certs already deleted by test 7
|
||||
for (const cert of createdCerts) {
|
||||
await deleteCertViaAPI(baseURL, cert.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
await navigateToCertificates(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 1: Checkbox column present for each deletable (custom) cert
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Checkbox column present — checkboxes appear for each deletable cert', async ({ page }) => {
|
||||
await test.step('Verify each seeded cert row has a selectable checkbox', async () => {
|
||||
for (const { certName } of createdCerts) {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const checkbox = row.getByRole('checkbox', {
|
||||
name: new RegExp(`Select certificate ${certName}`, 'i'),
|
||||
});
|
||||
await expect(checkbox).toBeVisible();
|
||||
await expect(checkbox).toBeEnabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 2: Valid production LE cert row has no checkbox rendered
|
||||
// ---------------------------------------------------------------------------
|
||||
test('No checkbox for valid LE — valid production LE cert row has no checkbox', async ({ page }) => {
|
||||
await test.step('Find valid production LE cert rows and verify no checkbox', async () => {
|
||||
const leRows = page.getByRole('row').filter({ hasText: /let.*encrypt/i });
|
||||
const leCount = await leRows.count();
|
||||
|
||||
if (leCount === 0) {
|
||||
test.skip(true, 'No Let\'s Encrypt certificates present in this environment');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < leCount; i++) {
|
||||
const row = leRows.nth(i);
|
||||
const rowText = await row.textContent();
|
||||
const isExpiredOrStaging = /expired|staging/i.test(rowText ?? '');
|
||||
if (isExpiredOrStaging) continue;
|
||||
|
||||
// Valid production LE cert: first cell is aria-hidden with no checkbox
|
||||
const firstCell = row.locator('td').first();
|
||||
await expect(firstCell).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(row.getByRole('checkbox')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 3: Select one → toolbar appears with count and Delete button
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Select one — checking one cert shows count and Delete button in toolbar', async ({ page }) => {
|
||||
const { certName } = createdCerts[0];
|
||||
|
||||
await test.step('Click checkbox for first seeded cert', async () => {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
const checkbox = row.getByRole('checkbox', {
|
||||
name: new RegExp(`Select certificate ${certName}`, 'i'),
|
||||
});
|
||||
await checkbox.click();
|
||||
});
|
||||
|
||||
await test.step('Verify toolbar appears with count 1 and bulk Delete button', async () => {
|
||||
const toolbar = page.getByRole('status').filter({ hasText: /selected/i });
|
||||
await expect(toolbar).toBeVisible();
|
||||
await expect(toolbar).toContainText('1 certificate(s) selected');
|
||||
|
||||
const bulkDeleteBtn = toolbar.getByRole('button', { name: /Delete \d+ Certificate/i });
|
||||
await expect(bulkDeleteBtn).toBeVisible();
|
||||
await expect(bulkDeleteBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 4: Select-all → header checkbox selects all seeded certs
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Select-all — header checkbox selects all seeded certs; toolbar shows count', async ({ page }) => {
|
||||
await test.step('Click the select-all header checkbox', async () => {
|
||||
const selectAllCheckbox = page.getByRole('checkbox', {
|
||||
name: /Select all deletable certificates/i,
|
||||
});
|
||||
await expect(selectAllCheckbox).toBeVisible({ timeout: 10000 });
|
||||
await selectAllCheckbox.click();
|
||||
});
|
||||
|
||||
await test.step('Verify all seeded cert row checkboxes are checked', async () => {
|
||||
for (const { certName } of createdCerts) {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
const checkbox = row.getByRole('checkbox');
|
||||
await expect(checkbox).toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Verify toolbar is visible with bulk Delete button', async () => {
|
||||
const toolbar = page.getByRole('status').filter({ hasText: /selected/i });
|
||||
await expect(toolbar).toBeVisible();
|
||||
const bulkDeleteBtn = toolbar.getByRole('button', { name: /Delete \d+ Certificate/i });
|
||||
await expect(bulkDeleteBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 5: Dialog shows correct count ("Delete 3 Certificate(s)")
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Dialog shows correct count — bulk dialog shows "Delete 3 Certificate(s)" for 3 selected', async ({ page }) => {
|
||||
await test.step('Select each of the 3 seeded certs individually', async () => {
|
||||
for (const { certName } of createdCerts) {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
const checkbox = row.getByRole('checkbox', {
|
||||
name: new RegExp(`Select certificate ${certName}`, 'i'),
|
||||
});
|
||||
await checkbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Click the bulk Delete button in the toolbar', async () => {
|
||||
const toolbar = page.getByRole('status').filter({ hasText: /selected/i });
|
||||
await expect(toolbar).toBeVisible();
|
||||
const bulkDeleteBtn = toolbar.getByRole('button', { name: /Delete \d+ Certificate/i });
|
||||
await bulkDeleteBtn.click();
|
||||
});
|
||||
|
||||
await test.step('Verify dialog title shows "Delete 3 Certificate(s)"', async () => {
|
||||
const dialog = await waitForDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText('Delete 3 Certificate(s)');
|
||||
});
|
||||
|
||||
await test.step('Cancel the dialog to preserve certs for subsequent tests', async () => {
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 6: Cancel preserves all selected certs in the list
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Cancel preserves certs — cancelling bulk dialog leaves all certs in list', async ({ page }) => {
|
||||
await test.step('Select all 3 seeded certs and open bulk delete dialog', async () => {
|
||||
for (const { certName } of createdCerts) {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
await row.getByRole('checkbox', {
|
||||
name: new RegExp(`Select certificate ${certName}`, 'i'),
|
||||
}).click();
|
||||
}
|
||||
const toolbar = page.getByRole('status').filter({ hasText: /selected/i });
|
||||
await toolbar.getByRole('button', { name: /Delete \d+ Certificate/i }).click();
|
||||
});
|
||||
|
||||
await test.step('Click Cancel in the bulk delete dialog', async () => {
|
||||
const dialog = await waitForDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /cancel/i }).click();
|
||||
});
|
||||
|
||||
await test.step('Verify dialog is closed and all 3 certs remain in the list', async () => {
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
for (const { certName } of createdCerts) {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario 7: Confirming bulk delete removes all selected certs from the table
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Confirm deletes all selected — bulk delete removes all selected certs', async ({ page }) => {
|
||||
await test.step('Select all 3 seeded certs and open bulk delete dialog', async () => {
|
||||
for (const { certName } of createdCerts) {
|
||||
const row = page.getByRole('row').filter({ hasText: certName });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
await row.getByRole('checkbox', {
|
||||
name: new RegExp(`Select certificate ${certName}`, 'i'),
|
||||
}).click();
|
||||
}
|
||||
const toolbar = page.getByRole('status').filter({ hasText: /selected/i });
|
||||
await toolbar.getByRole('button', { name: /Delete \d+ Certificate/i }).click();
|
||||
});
|
||||
|
||||
await test.step('Confirm bulk deletion', async () => {
|
||||
const dialog = await waitForDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
const confirmBtn = dialog.getByRole('button', { name: /Delete \d+ Certificate/i });
|
||||
await expect(confirmBtn).toBeVisible();
|
||||
await expect(confirmBtn).toBeEnabled();
|
||||
await confirmBtn.click();
|
||||
});
|
||||
|
||||
await test.step('Await success toast confirming all deletions settled', async () => {
|
||||
// toast.success fires in onSuccess after Promise.allSettled resolves
|
||||
await waitForToast(page, /certificate.*deleted/i, { type: 'success' });
|
||||
});
|
||||
|
||||
await test.step('Verify all 3 certs are removed from the table', async () => {
|
||||
for (const { certName } of createdCerts) {
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: certName })
|
||||
).toHaveCount(0, { timeout: 10000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user