From 2969eb58e46b4d2a36824cd9f5acf5c00a4ba591 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 11 Mar 2026 22:19:35 +0000 Subject: [PATCH 001/181] chore: update TypeScript to 6.0.1-rc and adjust package dependencies - Removed duplicate @typescript-eslint/utils dependency in frontend/package.json - Updated TypeScript version from 5.9.3 to 6.0.1-rc in frontend/package.json and package.json - Adjusted ResizeObserver mock to use globalThis in tests - Modified tsconfig.json and tsconfig.node.json to include empty types array - Cleaned up package-lock.json to reflect TypeScript version change and updated dev dependencies --- ARCHITECTURE.md | 2 +- docs/getting-started.md | 24 + .../archive/telegram_test_remediation_spec.md | 497 +++++++ docs/plans/current_spec.md | 1213 ++++++++++------- .../qa_report_ts6_upgrade_2026-03-11.md | 43 + frontend/package-lock.json | 123 +- frontend/package.json | 7 +- .../__tests__/AccessListForm.test.tsx | 2 +- frontend/src/test/setup.ts | 2 +- frontend/tsconfig.json | 3 +- frontend/tsconfig.node.json | 1 + package-lock.json | 85 +- package.json | 8 +- 13 files changed, 1525 insertions(+), 485 deletions(-) create mode 100644 docs/plans/archive/telegram_test_remediation_spec.md create mode 100644 docs/reports/qa_report_ts6_upgrade_2026-03-11.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a5f57b8..db5b4376 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,7 +139,7 @@ graph TB | Component | Technology | Version | Purpose | |-----------|-----------|---------|---------| | **Framework** | React | 19.2.3 | UI framework | -| **Language** | TypeScript | 5.x | Type-safe JavaScript | +| **Language** | TypeScript | 6.x | Type-safe JavaScript | | **Build Tool** | Vite | 6.1.9 | Fast bundler and dev server | | **CSS Framework** | Tailwind CSS | 3.x | Utility-first CSS | | **Routing** | React Router | 7.x | Client-side routing | diff --git a/docs/getting-started.md b/docs/getting-started.md index f4ac3076..baf71292 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,6 +21,24 @@ Imagine you have several apps running on your computer. Maybe a blog, a file sto ## Step 1: Install Charon +### Required Secrets (Generate Before Installing) + +Two secrets must be set before starting Charon. Omitting them will cause **sessions to reset on every container restart**, locking users out. + +Generate both values now and keep them somewhere safe: + +```bash +# JWT secret — signs and validates login sessions +openssl rand -hex 32 + +# Encryption key — protects stored credentials at rest +openssl rand -base64 32 +``` + +> **Why this matters:** If `CHARON_JWT_SECRET` is not set, Charon generates a random key on each boot. Any active login session becomes invalid the moment the container restarts, producing a "Session validation failed" error. + +--- + ### Option A: Docker Compose (Easiest) Create a file called `docker-compose.yml`: @@ -43,6 +61,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro environment: - CHARON_ENV=production + - CHARON_JWT_SECRET= + - CHARON_ENCRYPTION_KEY= ``` Then run: @@ -64,6 +84,8 @@ docker run -d \ -v ./charon-data:/app/data \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -e CHARON_ENV=production \ + -e CHARON_JWT_SECRET= \ + -e CHARON_ENCRYPTION_KEY= \ wikid82/charon:latest ``` @@ -78,6 +100,8 @@ docker run -d \ -v ./charon-data:/app/data \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -e CHARON_ENV=production \ + -e CHARON_JWT_SECRET= \ + -e CHARON_ENCRYPTION_KEY= \ ghcr.io/wikid82/charon:latest ``` diff --git a/docs/plans/archive/telegram_test_remediation_spec.md b/docs/plans/archive/telegram_test_remediation_spec.md new file mode 100644 index 00000000..12f1e701 --- /dev/null +++ b/docs/plans/archive/telegram_test_remediation_spec.md @@ -0,0 +1,497 @@ +# Telegram Notification Provider — Test Failure Remediation Plan + +**Date:** 2026-03-11 +**Author:** Planning Agent +**Status:** Remediation Required — All security scans pass, test failures block merge +**Previous Plan:** Archived as `docs/plans/telegram_implementation_spec.md` + +--- + +## 1. Introduction + +The Telegram notification provider feature is functionally complete with passing security scans and coverage gates. However, **56 E2E test failures** and **2 frontend unit test failures** block the PR merge. This plan identifies root causes, categorises each failure set, and provides specific remediation steps. + +### Failure Summary + +| Spec File | Failures | Browsers | Unique Est. | Category | +|---|---|---|---|---| +| `notifications.spec.ts` | 48 | 3 | ~16 | **Our change** | +| `notifications-payload.spec.ts` | 18 | 3 | ~6 | **Our change** | +| `telegram-notification-provider.spec.ts` | 4 | 1–3 | ~2 | **Our change** | +| `encryption-management.spec.ts` | 20 | 3 | ~7 | Pre-existing | +| `auth-middleware-cascade.spec.ts` | 18 | 3 | 6 | Pre-existing | +| `Notifications.test.tsx` (unit) | 2 | — | 2 | **Our change** | + +CI retries: 2 per test (`playwright.config.js` L144). Failure counts above represent unique test failures × browser projects. + +--- + +## 2. Root Cause Analysis + +### Root Cause A: `isNew` Guard on Test Button (CRITICAL — Causes ~80% of failures) + +**What changed:** The Telegram feature added a guard in `Notifications.tsx` (L117-124) that blocks the "Test" button for new (unsaved) providers: + +```typescript +// Line 117-124: handleTest() early return guard +const handleTest = () => { + const formData = watch(); + const currentType = normalizeProviderType(formData.type); + if (!formData.id && currentType !== 'email') { + toast.error(t('notificationProviders.saveBeforeTesting')); + return; + } + testMutation.mutate({ ...formData, type: currentType } as Partial); +}; +``` + +And a `disabled` attribute on the test button at `Notifications.tsx` (L382): + +```typescript +// Line 382: Button disabled state +disabled={testMutation.isPending || (isNew && !isEmail)} +``` + +**Why it was added:** The backend `Test` handler at `notification_provider_handler.go` (L333-336) requires a saved provider ID for all non-email types. For Gotify/Telegram, the server needs the stored token. For Discord/Webhook, the server still fetches the provider from DB. Without a saved provider, the backend returns `MISSING_PROVIDER_ID`. + +**Why it breaks tests:** Many existing E2E and unit tests click the test button from a **new (unsaved) provider form** using mocked endpoints. With the new guard: +1. The ` + + + {t('certificates.deleteInUse')} + + + + ) + } - // Allow deletion for custom/staging certs not in use (status check removed) - const message = cert.provider === 'custom' - ? 'Are you sure you want to delete this certificate? This will create a backup before deleting.' - : 'Delete this staging certificate? It will be regenerated on next request.' - if (confirm(message)) { - deleteMutation.mutate(cert.id!) - } - }} - className="text-red-400 hover:text-red-300 transition-colors" - title={cert.provider === 'custom' ? 'Delete Certificate' : 'Delete Staging Certificate'} - > - - - )} + if (deletable) { + return ( + + ) + } + + return null + })()} )) @@ -178,6 +210,17 @@ export default function CertificateList() { + { + if (certToDelete?.id) { + deleteMutation.mutate(certToDelete.id) + } + }} + onCancel={() => setCertToDelete(null)} + isDeleting={deleteMutation.isPending} + /> ) } diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx index e1dbcb49..550239a5 100644 --- a/frontend/src/components/__tests__/CertificateList.test.tsx +++ b/frontend/src/components/__tests__/CertificateList.test.tsx @@ -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 { }) 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() + 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() + 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() + 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() + 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() 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() - 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() 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 () => { diff --git a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx new file mode 100644 index 00000000..03fbf23f --- /dev/null +++ b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx @@ -0,0 +1,80 @@ +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 DeleteCertificateDialogProps { + certificate: Certificate | null + open: boolean + onConfirm: () => void + onCancel: () => void + isDeleting: boolean +} + +function getWarningKey(cert: Certificate): string { + if (cert.status === 'expired') return 'certificates.deleteConfirmExpired' + if (cert.provider === 'letsencrypt-staging') return 'certificates.deleteConfirmStaging' + return 'certificates.deleteConfirmCustom' +} + +export default function DeleteCertificateDialog({ + certificate, + open, + onConfirm, + onCancel, + isDeleting, +}: DeleteCertificateDialogProps) { + const { t } = useTranslation() + + if (!certificate) return null + + return ( + { if (!isOpen) onCancel() }}> + + + {t('certificates.deleteTitle')} + + {certificate.name || certificate.domain} + + + +
+
+ +

+ {t(getWarningKey(certificate))} +

+
+ +
+
{t('certificates.domain')}
+
{certificate.domain}
+
{t('certificates.status')}
+
{certificate.status}
+
{t('certificates.provider')}
+
{certificate.provider}
+
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx new file mode 100644 index 00000000..c5998599 --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx @@ -0,0 +1,128 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' + +import DeleteCertificateDialog from '../../dialogs/DeleteCertificateDialog' + +import type { Certificate } from '../../../api/certificates' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +const baseCert: Certificate = { + id: 1, + name: 'Test Cert', + domain: 'test.example.com', + issuer: 'Custom CA', + expires_at: '2026-01-01T00:00:00Z', + status: 'valid', + provider: 'custom', +} + +describe('DeleteCertificateDialog', () => { + it('renders warning text for custom cert', () => { + render( + + ) + expect(screen.getByText('certificates.deleteConfirmCustom')).toBeInTheDocument() + expect(screen.getByText('certificates.deleteTitle')).toBeInTheDocument() + }) + + it('renders warning text for staging cert', () => { + const staging: Certificate = { ...baseCert, provider: 'letsencrypt-staging', status: 'untrusted' } + render( + + ) + expect(screen.getByText('certificates.deleteConfirmStaging')).toBeInTheDocument() + }) + + it('renders warning text for expired cert', () => { + const expired: Certificate = { ...baseCert, provider: 'letsencrypt', status: 'expired' } + render( + + ) + expect(screen.getByText('certificates.deleteConfirmExpired')).toBeInTheDocument() + }) + + it('calls onCancel when Cancel is clicked', async () => { + const onCancel = vi.fn() + const user = userEvent.setup() + render( + + ) + await user.click(screen.getByRole('button', { name: 'common.cancel' })) + expect(onCancel).toHaveBeenCalled() + }) + + it('calls onConfirm when Delete is clicked', async () => { + const onConfirm = vi.fn() + const user = userEvent.setup() + render( + + ) + await user.click(screen.getByRole('button', { name: 'certificates.deleteButton' })) + expect(onConfirm).toHaveBeenCalled() + }) + + it('renders nothing when certificate is null', () => { + const { container } = render( + + ) + expect(container.innerHTML).toBe('') + }) + + it('renders expired warning for expired staging cert (priority ordering)', () => { + const expiredStaging: Certificate = { ...baseCert, provider: 'letsencrypt-staging', status: 'expired' } + render( + + ) + expect(screen.getByText('certificates.deleteConfirmExpired')).toBeInTheDocument() + expect(screen.queryByText('certificates.deleteConfirmStaging')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index a9b48f96..e41d856a 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -173,7 +173,16 @@ "uploadSuccess": "Zertifikat erfolgreich hochgeladen", "uploadFailed": "Fehler beim Hochladen des Zertifikats", "note": "Hinweis", - "noteText": "Sie können benutzerdefinierte Zertifikate und Staging-Zertifikate löschen. Produktions-Let's-Encrypt-Zertifikate werden automatisch erneuert und sollten nur beim Umgebungswechsel gelöscht werden." + "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "provider": "Provider", + "deleteTitle": "Delete Certificate", + "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", + "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", + "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteSuccess": "Certificate deleted", + "deleteFailed": "Failed to delete certificate", + "deleteInUse": "Cannot delete — certificate is attached to a proxy host", + "deleteButton": "Delete" }, "auth": { "login": "Anmelden", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 1ade81fd..a9b34f1c 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -182,7 +182,16 @@ "uploadSuccess": "Certificate uploaded successfully", "uploadFailed": "Failed to upload certificate", "note": "Note", - "noteText": "You can delete custom certificates and staging certificates. Production Let's Encrypt certificates are automatically renewed and should not be deleted unless switching environments." + "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "provider": "Provider", + "deleteTitle": "Delete Certificate", + "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", + "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", + "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteSuccess": "Certificate deleted", + "deleteFailed": "Failed to delete certificate", + "deleteInUse": "Cannot delete — certificate is attached to a proxy host", + "deleteButton": "Delete" }, "auth": { "login": "Login", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 209e7dc5..643492d0 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -173,7 +173,16 @@ "uploadSuccess": "Certificado subido exitosamente", "uploadFailed": "Error al subir el certificado", "note": "Nota", - "noteText": "Puedes eliminar certificados personalizados y certificados de prueba. Los certificados de Let's Encrypt de producción se renuevan automáticamente y no deben eliminarse a menos que cambies de entorno." + "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "provider": "Provider", + "deleteTitle": "Delete Certificate", + "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", + "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", + "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteSuccess": "Certificate deleted", + "deleteFailed": "Failed to delete certificate", + "deleteInUse": "Cannot delete — certificate is attached to a proxy host", + "deleteButton": "Delete" }, "auth": { "login": "Iniciar Sesión", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index d1249a17..a7171e0b 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -173,7 +173,16 @@ "uploadSuccess": "Certificat téléversé avec succès", "uploadFailed": "Échec du téléversement du certificat", "note": "Note", - "noteText": "Vous pouvez supprimer les certificats personnalisés et les certificats de test. Les certificats Let's Encrypt de production sont renouvelés automatiquement et ne doivent pas être supprimés sauf en cas de changement d'environnement." + "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "provider": "Provider", + "deleteTitle": "Delete Certificate", + "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", + "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", + "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteSuccess": "Certificate deleted", + "deleteFailed": "Failed to delete certificate", + "deleteInUse": "Cannot delete — certificate is attached to a proxy host", + "deleteButton": "Delete" }, "auth": { "login": "Connexion", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 8555f7ed..6f646ebe 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -173,7 +173,16 @@ "uploadSuccess": "证书上传成功", "uploadFailed": "证书上传失败", "note": "注意", - "noteText": "您可以删除自定义证书和测试证书。生产环境的Let's Encrypt证书会自动续期,除非切换环境否则不应删除。" + "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "provider": "Provider", + "deleteTitle": "Delete Certificate", + "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", + "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", + "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteSuccess": "Certificate deleted", + "deleteFailed": "Failed to delete certificate", + "deleteInUse": "Cannot delete — certificate is attached to a proxy host", + "deleteButton": "Delete" }, "auth": { "login": "登录", diff --git a/tests/certificate-delete.spec.ts b/tests/certificate-delete.spec.ts new file mode 100644 index 00000000..95a4e4ae --- /dev/null +++ b/tests/certificate-delete.spec.ts @@ -0,0 +1,487 @@ +/** + * Certificate Deletion E2E Tests + * + * Tests the certificate deletion UX enhancement: + * - Delete button visibility based on cert type, status, and in-use state + * - Accessible confirmation dialog (replaces native confirm()) + * - Cancel/confirm flows + * - Disabled button with tooltip for in-use certs + * - No delete button for valid production LE certs + * + * @see /projects/Charon/docs/plans/current_spec.md + */ + +import { test, expect, loginUser } from './fixtures/auth-fixtures'; +import { request as playwrightRequest } from '@playwright/test'; +import { + waitForLoadingComplete, + waitForToast, + waitForDialog, + waitForAPIResponse, +} 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-----`; + +/** + * 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. + * + * Note: The POST response excludes the numeric `id` (model uses json:"-"), + * so we query the list endpoint to resolve the numeric ID by matching on UUID. + */ +async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; certName: string }> { + const id = generateUniqueId(); + const certName = `test-cert-${id}`; + + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + }); + + 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 { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + }); + + try { + await ctx.delete(`/api/v1/certificates/${certId}`); + } finally { + await ctx.dispose(); + } +} + +/** + * Create a proxy host linked to a certificate via direct API. + * Returns the proxy host ID for cleanup. + */ +async function createProxyHostWithCertViaAPI( + baseURL: string, + certificateId: number +): Promise<{ id: string }> { + const id = generateUniqueId(); + const domain = `proxy-${id}.test.local`; + + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + }); + + try { + const response = await ctx.post('/api/v1/proxy-hosts', { + data: { + domain_names: domain, + forward_host: '127.0.0.1', + forward_port: 3000, + forward_scheme: 'https', + certificate_id: certificateId, + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to create proxy host: ${response.status()} ${await response.text()}`); + } + + const result = await response.json(); + return { id: result.id }; + } finally { + await ctx.dispose(); + } +} + +/** + * Delete a proxy host via API for cleanup. + */ +async function deleteProxyHostViaAPI(baseURL: string, hostId: string): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + }); + + try { + await ctx.delete(`/api/v1/proxy-hosts/${hostId}`); + } finally { + await ctx.dispose(); + } +} + +/** + * Navigate to the certificates page and wait for data to load + */ +async function navigateToCertificates(page: import('@playwright/test').Page): Promise { + const certsResponse = waitForAPIResponse(page, CERTIFICATES_API); + await page.goto('/certificates'); + await certsResponse; + await waitForLoadingComplete(page); +} + +test.describe('Certificate Deletion', () => { + const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; + const createdCertIds: number[] = []; + + test.beforeEach(async ({ page, adminUser }) => { + await loginUser(page, adminUser); + await waitForLoadingComplete(page); + }); + + test.afterAll(async () => { + // Clean up any certs created during tests that weren't deleted by the tests + for (const certId of createdCertIds) { + await deleteCertViaAPI(baseURL, certId).catch(() => {}); + } + }); + + // --------------------------------------------------------------------------- + // Scenario 1: Certificates page loads and shows certificate list + // --------------------------------------------------------------------------- + test('should display certificates page with heading and list', async ({ page }) => { + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Verify page heading is visible', async () => { + const heading = page.getByRole('heading', { name: /certificates/i }); + await expect(heading).toBeVisible(); + }); + + await test.step('Verify certificate list or empty state is present', async () => { + const table = page.getByRole('table'); + const emptyState = page.getByText(/no.*certificates/i); + + await expect(async () => { + const hasTable = (await table.count()) > 0 && (await table.first().isVisible()); + const hasEmpty = (await emptyState.count()) > 0 && (await emptyState.first().isVisible()); + expect(hasTable || hasEmpty).toBeTruthy(); + }).toPass({ timeout: 10000 }); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 2: Custom cert not in use shows delete button + // --------------------------------------------------------------------------- + test('should show delete button for custom cert not in use', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Verify delete button is visible for the custom cert', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + + const deleteButton = certRow.getByRole('button', { name: /delete/i }); + await expect(deleteButton).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 3: Delete button opens confirmation dialog + // --------------------------------------------------------------------------- + test('should open confirmation dialog when delete button is clicked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Click the delete button', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + const deleteButton = certRow.getByRole('button', { name: /delete/i }); + await deleteButton.click(); + }); + + await test.step('Verify confirmation dialog is visible', async () => { + const dialog = await waitForDialog(page); + await expect(dialog).toBeVisible(); + + await expect(dialog.getByText(/Delete Certificate/)).toBeVisible(); + await expect(dialog.getByRole('button', { name: /Cancel/i })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /^Delete$/i })).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 4: Cancel closes dialog without deleting + // --------------------------------------------------------------------------- + test('should close dialog and keep cert when Cancel is clicked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate to certificates and open delete dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + const deleteButton = certRow.getByRole('button', { name: /delete/i }); + await deleteButton.click(); + await waitForDialog(page); + }); + + await test.step('Click Cancel button', async () => { + const dialog = page.getByRole('dialog'); + const cancelButton = dialog.getByRole('button', { name: /cancel/i }); + await cancelButton.click(); + }); + + await test.step('Verify dialog is closed and cert still exists', async () => { + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 5: Successful deletion removes cert from list + // --------------------------------------------------------------------------- + test('should delete cert and show success toast on confirm', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + // Don't push to createdCertIds — this test will delete it via UI + certName = result.certName; + }); + + await test.step('Navigate to certificates and open delete dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + const deleteButton = certRow.getByRole('button', { name: /Delete Certificate/i }); + await deleteButton.click(); + await waitForDialog(page); + }); + + await test.step('Confirm deletion and verify cert is removed', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Wait for the dialog's confirm Delete button + const confirmDeleteButton = dialog.getByRole('button', { name: /^Delete$/i }); + await expect(confirmDeleteButton).toBeVisible(); + await expect(confirmDeleteButton).toBeEnabled(); + + // Click confirm and wait for the DELETE API response simultaneously + const [deleteResponse] = await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes('/api/v1/certificates/') && resp.request().method() === 'DELETE', + { timeout: 15000 } + ), + confirmDeleteButton.click(), + ]); + + // Verify the API call succeeded + expect(deleteResponse.status()).toBeLessThan(400); + + // Verify the cert row is removed from the list + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toHaveCount(0, { timeout: 10000 }); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 6: In-use cert shows disabled delete button with tooltip + // --------------------------------------------------------------------------- + test('should show disabled delete button with tooltip for in-use cert', async ({ + page, + }) => { + let certName: string; + let proxyHostId: string; + + await test.step('Seed a custom cert and attach it to a proxy host', async () => { + const certResult = await createCustomCertViaAPI(baseURL); + createdCertIds.push(certResult.id); + certName = certResult.certName; + + // Create a proxy host that references this certificate via certificate_id + const proxyResult = await createProxyHostWithCertViaAPI(baseURL, certResult.id); + proxyHostId = proxyResult.id; + }); + + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Verify delete button is disabled for the in-use cert', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + + const deleteButton = certRow.getByRole('button', { name: /Delete Certificate/i }); + await expect(deleteButton).toBeVisible(); + await expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + }); + + await test.step('Verify tooltip on hover', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + const deleteButton = certRow.getByRole('button', { name: /Delete Certificate/i }); + + await deleteButton.hover(); + + const tooltip = page.getByRole('tooltip').or( + page.getByText(/cannot delete/i) + ); + await expect(tooltip.first()).toBeVisible({ timeout: 5000 }); + }); + + // Cleanup: delete proxy host first (so cert can be cleaned up), then cert + await test.step('Cleanup proxy host', async () => { + if (proxyHostId) { + await deleteProxyHostViaAPI(baseURL, proxyHostId).catch(() => {}); + } + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 7: Valid production LE cert not in use has no delete button + // --------------------------------------------------------------------------- + test('should not show delete button for valid production LE cert', async ({ page }) => { + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Check for valid production LE certs', async () => { + const leCertRows = page + .getByRole('row') + .filter({ hasText: /let.*encrypt/i }); + + const leCount = await leCertRows.count(); + if (leCount === 0) { + test.skip(true, 'No Let\'s Encrypt certificates present in this environment to verify'); + return; + } + + for (let i = 0; i < leCount; i++) { + const row = leCertRows.nth(i); + const rowText = await row.textContent(); + + // Skip expired LE certs — they ARE expected to have a delete button + const isExpired = /expired/i.test(rowText ?? ''); + if (isExpired) continue; + + // Valid production LE cert should NOT have a delete button + const deleteButton = row.getByRole('button', { name: /delete/i }); + await expect(deleteButton).toHaveCount(0); + } + }); + }); +}); From a98e37b8b44840729ffb1cbbaf37c13fd8b0a95a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 22 Mar 2026 13:30:00 +0000 Subject: [PATCH 145/181] fix: update @vitest/eslint-plugin, i18next, and react-i18next versions for compatibility --- frontend/package-lock.json | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54cc1c6f..a10079cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4361,9 +4361,9 @@ } }, "node_modules/@vitest/eslint-plugin": { - "version": "1.6.12", - "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.12.tgz", - "integrity": "sha512-4kI47BJNFE+EQ5bmPbHzBF+ibNzx2Fj0Jo9xhWsTPxMddlHwIWl6YAxagefh461hrwx/W0QwBZpxGS404kBXyg==", + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.13.tgz", + "integrity": "sha512-ui7JGWBoQpS5NKKW0FDb1eTuFEZ5EupEv2Psemuyfba7DfA5K52SeDLelt6P4pQJJ/4UGkker/BgMk/KrjH3WQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4374,11 +4374,15 @@ "node": ">=18" }, "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, "typescript": { "optional": true }, @@ -7032,9 +7036,9 @@ } }, "node_modules/i18next": { - "version": "25.10.3", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.3.tgz", - "integrity": "sha512-9XCjFgF7q4wNdmy7RFcDBIx1ndSXU3QwtbmqPjOdUxYxU9gbovJzZUY5Mb3ejWmDhxMl6Wmr2OenWOU3uyy6VQ==", + "version": "25.10.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.4.tgz", + "integrity": "sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==", "funding": [ { "type": "individual", @@ -9763,9 +9767,9 @@ } }, "node_modules/react-i18next": { - "version": "16.6.0", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.0.tgz", - "integrity": "sha512-bxVftl2q/pfupKVmBH80ui1rHl3ia2sdcR0Yhn6cr0PyoHfO8JLZ19fccwOIH+0dMBCIkdO5gAmEQFCTAggeQg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.1.tgz", + "integrity": "sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", From b2c40345f8431c5e3e9c24eec1e552c21e918331 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:24:03 +0000 Subject: [PATCH 146/181] fix(deps): update non-major-updates --- frontend/package-lock.json | 8 +++---- frontend/package.json | 8 +++---- package-lock.json | 43 ++++++++++++++++++++++++++++++-------- package.json | 2 +- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a10079cf..dc311818 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,14 +19,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "i18next": "^25.10.3", + "i18next": "^25.10.4", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-hook-form": "^7.71.2", + "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.6.0", + "react-i18next": "^16.6.1", "react-router-dom": "^7.13.1", "tailwind-merge": "^3.5.0", "tldts": "^7.0.27" @@ -51,7 +51,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-istanbul": "^4.1.0", "@vitest/coverage-v8": "^4.1.0", - "@vitest/eslint-plugin": "^1.6.12", + "@vitest/eslint-plugin": "^1.6.13", "@vitest/ui": "^4.1.0", "autoprefixer": "^10.4.27", "eslint": "^10.1.0", diff --git a/frontend/package.json b/frontend/package.json index f2094294..08e908f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,14 +38,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "i18next": "^25.10.3", + "i18next": "^25.10.4", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.577.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-hook-form": "^7.71.2", + "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.6.0", + "react-i18next": "^16.6.1", "react-router-dom": "^7.13.1", "tailwind-merge": "^3.5.0", "tldts": "^7.0.27" @@ -70,7 +70,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-istanbul": "^4.1.0", "@vitest/coverage-v8": "^4.1.0", - "@vitest/eslint-plugin": "^1.6.12", + "@vitest/eslint-plugin": "^1.6.13", "@vitest/ui": "^4.1.0", "autoprefixer": "^10.4.27", "eslint": "^10.1.0", diff --git a/package-lock.json b/package-lock.json index 6fa4473b..2fb24160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/node": "^25.5.0", "dotenv": "^17.3.1", - "markdownlint-cli2": "^0.21.0", + "markdownlint-cli2": "^0.22.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "tar": "^7.5.12", @@ -1592,9 +1592,9 @@ } }, "node_modules/globby": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.0.tgz", - "integrity": "sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.1.tgz", + "integrity": "sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==", "dev": true, "license": "MIT", "dependencies": { @@ -1880,6 +1880,16 @@ ], "license": "MIT" }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jsonstream-next": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jsonstream-next/-/jsonstream-next-3.0.0.tgz", @@ -2290,19 +2300,21 @@ } }, "node_modules/markdownlint-cli2": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.21.0.tgz", - "integrity": "sha512-DzzmbqfMW3EzHsunP66x556oZDzjcdjjlL2bHG4PubwnL58ZPAfz07px4GqteZkoCGnBYi779Y2mg7+vgNCwbw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.22.0.tgz", + "integrity": "sha512-mOC9BY/XGtdX3M9n3AgERd79F0+S7w18yBBTNIQ453sI87etZfp1z4eajqSMV70CYjbxKe5ktKvT2HCpvcWx9w==", "dev": true, "license": "MIT", "dependencies": { - "globby": "16.1.0", + "globby": "16.1.1", "js-yaml": "4.1.1", "jsonc-parser": "3.3.1", + "jsonpointer": "5.0.1", "markdown-it": "14.1.1", "markdownlint": "0.40.0", "markdownlint-cli2-formatter-default": "0.0.6", - "micromatch": "4.0.8" + "micromatch": "4.0.8", + "smol-toml": "1.6.0" }, "bin": { "markdownlint-cli2": "markdownlint-cli2-bin.mjs" @@ -3502,6 +3514,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 24f17ffa..e067aca2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@playwright/test": "^1.58.2", "@types/node": "^25.5.0", "dotenv": "^17.3.1", - "markdownlint-cli2": "^0.21.0", + "markdownlint-cli2": "^0.22.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "tar": "^7.5.12", From ef5e2e2ea27d0d1f3f6ca50ae3108afa7693e791 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 22 Mar 2026 14:27:34 +0000 Subject: [PATCH 147/181] fix: enhance setupAuditTestDB for proper database connection handling and documentation --- .../internal/api/tests/user_smtp_audit_test.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/tests/user_smtp_audit_test.go b/backend/internal/api/tests/user_smtp_audit_test.go index 48f7752e..c2a1e113 100644 --- a/backend/internal/api/tests/user_smtp_audit_test.go +++ b/backend/internal/api/tests/user_smtp_audit_test.go @@ -32,7 +32,9 @@ func hashForTest(t *testing.T, password string) string { return string(h) } -// setupAuditTestDB creates a clean in-memory database for each test +// setupAuditTestDB creates a clean in-memory database for each test. +// MaxOpenConns(1) is required: without it, GORM's pool can open multiple +// connections to ":memory:", each receiving its own empty database. func setupAuditTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ @@ -40,11 +42,23 @@ func setupAuditTestDB(t *testing.T) *gorm.DB { }) require.NoError(t, err) - // Auto-migrate required models + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + t.Cleanup(func() { + _ = sqlDB.Close() + }) + + // Auto-migrate required models (includes SecurityAudit so the + // background audit goroutine in SecurityService doesn't retry + // against a missing table). err = db.AutoMigrate( &models.User{}, &models.Setting{}, &models.ProxyHost{}, + &models.SecurityAudit{}, ) require.NoError(t, err) return db From 53fc2f1e7853057e6ed34847eb7119319150ccc9 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 22 Mar 2026 14:28:42 +0000 Subject: [PATCH 148/181] fix: remove unused waitForToast import from certificate-delete.spec.ts --- tests/certificate-delete.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/certificate-delete.spec.ts b/tests/certificate-delete.spec.ts index 95a4e4ae..4882742f 100644 --- a/tests/certificate-delete.spec.ts +++ b/tests/certificate-delete.spec.ts @@ -15,7 +15,6 @@ import { test, expect, loginUser } from './fixtures/auth-fixtures'; import { request as playwrightRequest } from '@playwright/test'; import { waitForLoadingComplete, - waitForToast, waitForDialog, waitForAPIResponse, } from './utils/wait-helpers'; From 97255f84e6367ac7fdf916722e5258e0307788fc Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 22 Mar 2026 17:32:16 +0000 Subject: [PATCH 149/181] fix: add tests for delete certificate functionality and error handling in CertificateList --- .../__tests__/CertificateList.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx index 550239a5..8228a133 100644 --- a/frontend/src/components/__tests__/CertificateList.test.tsx +++ b/frontend/src/components/__tests__/CertificateList.test.tsx @@ -246,6 +246,48 @@ describe('CertificateList', () => { expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument() }) + it('shows error toast when delete mutation fails', async () => { + const { deleteCertificate } = await import('../../api/certificates') + const { toast } = await import('../../utils/toast') + vi.mocked(deleteCertificate).mockRejectedValueOnce(new Error('Network error')) + const user = userEvent.setup() + + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })) + + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' })) + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network error')) + }) + + it('clicking disabled delete button for in-use cert does not open dialog', async () => { + const user = userEvent.setup() + renderWithClient() + 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' }) + + await user.click(btn) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('closes delete dialog when cancel is clicked', async () => { + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'common.cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).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' }, From bae55fb876ba683b667aa1d16e15bb6ad4a33c7d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 22 Mar 2026 18:49:02 +0000 Subject: [PATCH 150/181] chore(ci): prevent test log truncation in backend coverage workflows - Install gotestsum in CI so the coverage script uses compact pkgname-formatted output instead of go test -v, which produces massive verbose logs that exceed GitHub Actions' step log buffer - Upload the full test output as a downloadable artifact on every run (including failures) so truncated logs never block debugging - Aligns upload-artifact pin to v7.0.0 matching the rest of the repo --- .github/workflows/codecov-upload.yml | 11 +++++++++++ .github/workflows/quality-checks.yml | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/codecov-upload.yml b/.github/workflows/codecov-upload.yml index 725602f2..91cd5f12 100644 --- a/.github/workflows/codecov-upload.yml +++ b/.github/workflows/codecov-upload.yml @@ -126,6 +126,9 @@ jobs: echo "__CHARON_EOF__" } >> "$GITHUB_ENV" + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest + - name: Run Go tests with coverage working-directory: ${{ github.workspace }} env: @@ -134,6 +137,14 @@ jobs: bash scripts/go-test-coverage.sh 2>&1 | tee backend/test-output.txt exit "${PIPESTATUS[0]}" + - name: Upload test output artifact + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: backend-test-output + path: backend/test-output.txt + retention-days: 7 + - name: Upload backend coverage to Codecov uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 49ec1d8e..2d6b304f 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -148,6 +148,9 @@ jobs: run: | bash "scripts/repo_health_check.sh" + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest + - name: Run Go tests id: go-tests working-directory: ${{ github.workspace }} @@ -156,6 +159,14 @@ jobs: run: | bash "scripts/go-test-coverage.sh" 2>&1 | tee backend/test-output.txt; exit "${PIPESTATUS[0]}" + - name: Upload test output artifact + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: backend-test-output + path: backend/test-output.txt + retention-days: 7 + - name: Go Test Summary if: always() working-directory: backend From 0bb7826ad56e9f277a86fd741bdf009dc855f10c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:26:16 +0000 Subject: [PATCH 151/181] fix(deps): update non-major-updates --- frontend/package-lock.json | 32 ++++++++++++++++---------------- frontend/package.json | 4 ++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dc311818..4239426c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.94.5", + "@tanstack/react-query": "^5.95.0", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -69,7 +69,7 @@ "eslint-plugin-unicorn": "^63.0.0", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "29.0.1", - "knip": "^6.0.1", + "knip": "^6.0.2", "postcss": "^8.5.8", "tailwindcss": "^4.2.2", "typescript": "^6.0.1-rc", @@ -3289,9 +3289,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.94.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.94.5.tgz", - "integrity": "sha512-Vx1JJiBURW/wdNGP45afjrqn0LfxYwL7K/bSrQvNRtyLGF1bxQPgUXCpzscG29e+UeFOh9hz1KOVala0N+bZiA==", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.0.tgz", + "integrity": "sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==", "license": "MIT", "funding": { "type": "github", @@ -3299,12 +3299,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.94.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.94.5.tgz", - "integrity": "sha512-1wmrxKFkor+q8l+ygdHmv0Sq5g84Q3p4xvuJ7AdSIAhQQ7udOt+ZSZ19g1Jea3mHqtlTslLGJsmC4vHFgP0P3A==", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.0.tgz", + "integrity": "sha512-EMP8B+BK9zvnAemT8M/y3z/WO0NjZ7fIUY3T3wnHYK6AA3qK/k33i7tPgCXCejhX0cd4I6bJIXN2GmjrHjDBzg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.94.5" + "@tanstack/query-core": "5.95.0" }, "funding": { "type": "github", @@ -6808,9 +6808,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7780,9 +7780,9 @@ } }, "node_modules/knip": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.0.1.tgz", - "integrity": "sha512-qk5m+w6IYEqfRG5546DXZJYl5AXsgFfDD6ULaDvkubqNtLye79sokBg3usURrWFjASMeQtvX19TfldU3jHkMNA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.0.2.tgz", + "integrity": "sha512-W17Bo5N9AYn0ZkgWHGBmK/01SrSmr3B6iStr3zudDa2eqi+Kc8VmPjSpTYKDV2Uy/kojrlcH/gS1wypAXfXRRA==", "dev": true, "funding": [ { @@ -7799,7 +7799,7 @@ "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", - "get-tsconfig": "4.13.6", + "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.120.0", diff --git a/frontend/package.json b/frontend/package.json index 08e908f2..bc83d383 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.94.5", + "@tanstack/react-query": "^5.95.0", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -88,7 +88,7 @@ "eslint-plugin-unicorn": "^63.0.0", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "29.0.1", - "knip": "^6.0.1", + "knip": "^6.0.2", "postcss": "^8.5.8", "tailwindcss": "^4.2.2", "typescript": "^6.0.1-rc", From 69736503ac6e7e5c5775222044afb3455e1597ef Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 00:04:30 +0000 Subject: [PATCH 152/181] 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. --- docs/plans/current_spec.md | 969 +++++++++++------- frontend/src/components/CertificateList.tsx | 145 ++- .../__tests__/CertificateList.test.tsx | 129 ++- .../dialogs/BulkDeleteCertificateDialog.tsx | 87 ++ .../BulkDeleteCertificateDialog.test.tsx | 129 +++ frontend/src/locales/de/translation.json | 12 +- frontend/src/locales/en/translation.json | 12 +- frontend/src/locales/es/translation.json | 12 +- frontend/src/locales/fr/translation.json | 12 +- frontend/src/locales/zh/translation.json | 12 +- tests/certificate-bulk-delete.spec.ts | 421 ++++++++ 11 files changed, 1532 insertions(+), 408 deletions(-) create mode 100644 frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx create mode 100644 frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx create mode 100644 tests/certificate-bulk-delete.spec.ts diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 6f8e0f12..27f10a89 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,494 +1,693 @@ -# Certificate Deletion Feature — Spec +# Certificate Bulk Delete — Spec **Date**: 2026-03-22 **Priority**: Medium **Type**: User Requested Feature -**Status**: Approved — Supervisor Reviewed 2026-03-22 +**Status**: Planning — Awaiting Implementation --- ## 1. Problem Statement -### CWE-614 Description +The Certificates page now supports individual deletion of custom, staging, and expired +production Let's Encrypt certificates. Users who accumulate many such certificates must +click through a confirmation dialog for each one. There is no mechanism to select and +destroy multiple certificates in a single operation. -Users accumulate expired and orphaned certificates in the Certificates UI over time. Currently, -the delete button is only shown for `custom` (manually uploaded) and `staging` certificates. Expired -production Let's Encrypt certificates that are no longer attached to any proxy host cannot be -removed, creating UI clutter and user confusion. - -### SARIF Finding - -1. Allow deletion of **expired** certificates that are not attached to any proxy host. -2. Allow deletion of **custom** (manually uploaded) certificates that are not attached to any - proxy host, regardless of expiry status (already partially implemented). -3. Allow deletion of **staging** certificates that are not attached to any proxy host (already - partially implemented). -4. **Prevent deletion** of any certificate currently attached to a proxy host. -5. Replace the native `confirm()` dialog with an accessible, themed confirmation dialog. -6. Provide clear visual feedback on why a certificate can or cannot be deleted. +This feature adds checkbox-based bulk selection and a single confirmation step to delete +N certificates at once, operating under exactly the same policy terms as the existing +individual delete affordance. ### Non-Goals -- Bulk certificate deletion (separate feature). -- Auto-cleanup / scheduled pruning of expired certificates. -- Changes to certificate auto-renewal logic. +- Changes to the individual delete flow or `DeleteCertificateDialog`. +- A new backend batch endpoint (sequential per-cert calls are sufficient). +- Auto-cleanup / scheduled pruning. +- Migrating `CertificateList` from its current raw `` to the `DataTable` component. --- -## 2. Root Cause Analysis +## 2. Existing Foundation -### 2.1 Existing Backend Infrastructure +### 2.1 Individual Delete Policy (Preserved Verbatim) -The backend already has complete delete support: +The following logic lives in `frontend/src/components/CertificateList.tsx` and must be +preserved without modification. Bulk selection obeys it exactly: -| Component | File | Status | -|-----------|------|--------| -| Model | `backend/internal/models/ssl_certificate.go` | `SSLCertificate` struct with `Provider` ("letsencrypt", "letsencrypt-staging", "custom"), `ExpiresAt` fields | -| Service | `backend/internal/services/certificate_service.go` | `DeleteCertificate(id)`, `IsCertificateInUse(id)` — fully implemented | -| Handler | `backend/internal/api/handlers/certificate_handler.go` | `Delete()` — validates in-use, creates backup, deletes, sends notification | -| Route | `backend/internal/api/routes/routes.go:673` | `DELETE /api/v1/certificates/:id` — already registered | -| Error | `backend/internal/services/certificate_service.go:23` | `ErrCertInUse` sentinel error defined | -| Tests | `backend/internal/api/handlers/certificate_handler_test.go` | Tests for in-use, backup, backup failure, auth, invalid ID, not found | +```ts +export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean { + return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id) +} -**Key finding**: The backend imposes NO provider or expiry restrictions on deletion. Any certificate -can be deleted as long as it is not referenced by a proxy host (`certificate_id` FK). The -backend is already correct for the requested feature. +export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean { + if (!cert.id) return false + if (isInUse(cert, hosts)) return false + return ( + cert.provider === 'custom' || + cert.provider === 'letsencrypt-staging' || + cert.status === 'expired' + ) +} +``` -### 2.2 Existing Frontend Infrastructure +| Certificate Category | `isDeletable` | `isInUse` | Individual button | Checkbox | +|---|---|---|---|---| +| custom / staging — not in use | ✅ true | ❌ false | Active delete Trash2 | ✅ Enabled checkbox | +| custom / staging / expired LE — in use | ❌ false | ✅ true | `aria-disabled` Trash2 + tooltip | ✅ Checkbox rendered but `disabled` + tooltip | +| expired LE — not in use | ✅ true | ❌ false | Active delete Trash2 | ✅ Enabled checkbox | +| valid/expiring LE — not in use | ❌ false | ❌ false | No affordance at all | No checkbox, no column cell | +| valid/expiring LE — in use | ❌ false | ✅ true | No affordance at all | No checkbox, no column cell | -| Component | File | Status | -|-----------|------|--------| -| API client | `frontend/src/api/certificates.ts` | `deleteCertificate(id)` — exists | -| Hook | `frontend/src/hooks/useCertificates.ts` | `useCertificates()` — react-query based | -| List component | `frontend/src/components/CertificateList.tsx` | Delete button and mutation — exists but **gated incorrectly** | -| Page | `frontend/src/pages/Certificates.tsx` | Upload dialog only | -| Cleanup dialog | `frontend/src/components/dialogs/CertificateCleanupDialog.tsx` | Used for proxy host deletion cleanup — not for standalone cert deletion | -| i18n | `frontend/src/locales/en/translation.json:168-185` | Certificate strings — needs new deletion strings | +### 2.2 Backend — No New Endpoint Required -### 2.3 Current Delete Button Visibility Logic (The Problem) +`DELETE /api/v1/certificates/:id` is registered at `backend/internal/api/routes/routes.go:673` +and already: -In `frontend/src/components/CertificateList.tsx:145`: +- Guards against in-use certs (`IsCertificateInUse` → `409 Conflict`). +- Creates a server-side backup before deletion. +- Deletes the DB record and ACME files. +- Invalidates the cert cache and fires a notification. + +Bulk deletion will call this endpoint N times concurrently using `Promise.allSettled`, +exactly as the ProxyHosts bulk delete does for `deleteHost`. `ids.map(id => deleteCertificate(id))` +fires all promises concurrently; `Promise.allSettled` awaits all settlements before resolving. +No batch endpoint is warranted at this scale. + +### 2.3 CertificateList Rendering Architecture + +`CertificateList.tsx` renders a purpose-built raw `
` with a manual `sortedCertificates` +`useMemo`. It does **not** use the `DataTable` UI component. This plan does not migrate it — +the selection layer will be grafted directly onto the existing table. + +The `Checkbox` component at `frontend/src/components/ui/Checkbox.tsx` supports an +`indeterminate` prop backed by Radix UI `CheckboxPrimitive`. This component will be reused +for both the header "select all" checkbox and each row checkbox, matching the rendering +style in `DataTable.tsx`. + +### 2.4 Bulk Selection Precedent — ProxyHosts Page + +`frontend/src/pages/ProxyHosts.tsx` is the reference implementation for bulk operations: + +- `selectedHosts: Set` — `useState>(new Set())` +- The `DataTable` `selectable` prop handles the per-row checkbox column and the header + "select all" checkbox automatically, but `DataTable.handleSelectAll` selects **every** row. +- For certificates the "select all" must only select the `isDeletable && !isInUse` subset, + so we cannot delegate to `DataTable`'s built-in logic even if we migrated. +- The bulk action bar is a conditional `
` that appears only when + `selectedHosts.size > 0`, containing the count and action buttons. + +--- + +## 3. Technical Specification + +### 3.1 State Changes — `CertificateList.tsx` + +Add two pieces of state alongside the existing `certToDelete` and `sortColumn` state: + +```ts +const [selectedIds, setSelectedIds] = useState>(new Set()) +const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false) +``` + +Add a memoised derived set of all cert IDs that are eligible for selection: + +```ts +const selectableCertIds = useMemo>(() => { + const ids = new Set() + for (const cert of sortedCertificates) { + if (isDeletable(cert, hosts) && cert.id) { + ids.add(cert.id) + } + } + return ids +}, [sortedCertificates, hosts]) +``` + +> Only `isDeletable` certs (not in use, correct provider/status) enter `selectableCertIds`. +> In-use-but-would-be-deletable certs (`isInUse && (custom || staging || expired)`) do NOT +> enter the selectable set — their row checkbox is rendered but is `disabled`. + +Add selection handlers: + +```ts +const handleSelectAll = () => { + if (selectedIds.size === selectableCertIds.size) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(selectableCertIds)) + } +} + +const handleSelectRow = (id: number) => { + const next = new Set(selectedIds) + next.has(id) ? next.delete(id) : next.add(id) + setSelectedIds(next) +} +``` + +Header checkbox state derived from selection: + +```ts +const allSelectableSelected = + selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size +const someSelected = + selectedIds.size > 0 && selectedIds.size < selectableCertIds.size +``` + +### 3.2 Table Column Changes — `CertificateList.tsx` + +#### 3.2.1 New Leftmost `
` row, before the existing +"Name" column: ```tsx -{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && ( + ``` -This condition **excludes expired production Let's Encrypt certificates**, which is the core -issue. An expired LE cert not attached to any host should be deletable. +Also update the empty-state ` ``` -Where `isInUse(cert, hosts)` checks: -``` -hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id) +**Case B — in-use-but-deletable category (`isInUse && (custom || staging || expired)`):** + +The individual delete button mirrors this with `aria-disabled`. The checkbox must match: +rendered but disabled, with the same tooltip text from `t('certificates.deleteInUse')`. + +```tsx + ``` -In plain terms: -- **Custom / staging** certs: deletable if not in use (any expiry status). -- **Production LE** certs: deletable **only if expired** AND not in use. -- **Any cert in use** by a proxy host: NOT deletable, regardless of status. +> Radix `Checkbox` with `disabled` swallows pointer events. Wrapping in a `` restores +> hover targeting for the tooltip — the same technique used for the existing `aria-disabled` +> delete buttons where `TooltipTrigger asChild` wraps the `
` — Header Checkbox + +Insert a new `` as the **first column** in the `
+ +` to `colSpan={7}`. -### 2.4 Certificate-to-ProxyHost Relationship +#### 3.2.2 New Leftmost `` — Per-Row Checkbox -- `ProxyHost.CertificateID` (`*uint`, nullable FK) → `SSLCertificate.ID` -- Defined in `backend/internal/models/proxy_host.go:24-25` -- GORM foreign key: `gorm:"foreignKey:CertificateID"` -- **No cascade delete** on the FK — deletion is manually guarded by `IsCertificateInUse()` -- Frontend checks in-use client-side via `hosts.some(h => h.certificate_id === cert.id)` +For each row in the `sortedCertificates.map(...)`, insert a new `` as the first cell. +Three mutually exclusive cases: -### 2.5 Provider Values +**Case A — `isDeletable && !isInUse` (enabled checkbox):** -| Provider Value | Source | Deletable? | -|---------------|--------|------------| -| `letsencrypt` | Auto-provisioned by Caddy ACME | Only when **expired** AND **not in use** | -| `letsencrypt-staging` | Staging ACME | When **not in use** (any status) | -| `custom` | User-uploaded via UI | When **not in use** (any status) | - -> **Note**: The model comment in `ssl_certificate.go` lists `"self-signed"` as a possible -> provider, but no code path ever writes that value. The actual provider universe is -> `letsencrypt`, `letsencrypt-staging`, `custom`. The stale comment should be corrected as -> part of this PR. - -#### Edge Case: `expiring` LE Cert Not In Use - -An `expiring` Let's Encrypt certificate that is not attached to any proxy host is in limbo — -not expired yet, but no proxy host references it, so no renewal will be triggered. **Decision**: -accept this as intended behavior. The cert will eventually expire and become deletable. We do -**not** add `expiring` to the deletable set because Caddy may still auto-renew certificates -that were previously provisioned, even if no host currently references them. - -### 2.6 Existing UX Issues - -1. Delete uses native `confirm()` — not accessible, not themed. -2. No tooltip or visual indicator explaining why a cert cannot be deleted. -3. The in-use check is duplicated: once client-side before `confirm()`, once server-side in the handler. This is fine (defense in depth) but the server is the source of truth. - ---- - -## 3. Technical Specifications - -### 3.1 Backend Changes - -**No backend code changes required.** The existing `DELETE /api/v1/certificates/:id` endpoint -already: -- Validates the certificate exists -- Checks `IsCertificateInUse()` and returns `409 Conflict` if in use -- Creates a backup before deletion -- Deletes the DB record (and ACME files for LE certs) -- Invalidates the cert cache -- Sends a notification -- Returns `200 OK` on success - -The backend does not restrict by provider or expiry — all deletion policy is enforced by the -frontend's visibility of the delete button and confirmed server-side by the in-use check. - -### 3.2 Frontend Changes - -#### 3.2.1 Delete Button Visibility — `CertificateList.tsx` - -Replace the current delete button condition with new business logic: - -``` -isDeletable(cert, hosts) = - cert.id exists - AND NOT isInUse(cert, hosts) - AND ( - cert.provider === 'custom' - OR cert.provider === 'letsencrypt-staging' - OR cert.status === 'expired' - ) +```tsx + + handleSelectRow(cert.id!)} + aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })} + /> + + + + + + + + + {t('certificates.deleteInUse')} + + +` (§3.2.1); update empty-state `colSpan` 6 → 7. +9. Insert leftmost `` for each row with the three cases A/B/C (§3.2.2). +10. Mount `` at the end of the fragment (§3.4). -### Phase 2: Frontend Implementation +**Invariant**: `isDeletable` and `isInUse` exported function signatures must not change. +All pre-existing assertions in `CertificateList.test.tsx` must continue to pass. -**Estimated changes**: ~3 files modified, 1 file created. +### Phase 4 — Unit Tests -#### Step 1: Create `DeleteCertificateDialog` +#### 4.1 New file: `BulkDeleteCertificateDialog.test.tsx` -**File**: `frontend/src/components/dialogs/DeleteCertificateDialog.tsx` +**File**: `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` -``` -Props: - - certificate: Certificate | null (from api/certificates.ts) - - open: boolean - - onConfirm: () => void - - onCancel: () => void - - isDeleting: boolean +Mock `react-i18next` using the `t: (key, opts?) => opts ? JSON.stringify(opts) : key` +pattern used throughout the test suite. -Structure: - - Dialog (open, onOpenChange=onCancel) - - DialogContent - - DialogHeader - - DialogTitle: t('certificates.deleteTitle') - - Certificate info: name, domain, status badge, provider - - Warning text (varies by provider/status) - - DialogFooter - - Button (secondary): t('common.cancel') - - Button (destructive, loading=isDeleting): Delete -``` +| # | Test description | +|---|---| +| 1 | renders dialog with count in title when 3 certs supplied | +| 2 | lists each certificate name in the scrollable list | +| 3 | calls `onConfirm` when the Delete button is clicked | +| 4 | calls `onCancel` when the Cancel button is clicked | +| 5 | Delete button is loading/disabled when `isDeleting={true}` | +| 6 | returns null when `certificates` array is empty | -#### Step 2: Update `CertificateList.tsx` +#### 4.2 Additions to `CertificateList.test.tsx` -1. Extract `isDeletable(cert, hosts)` helper function. -2. Extract `isInUse(cert, hosts)` helper function. -3. Replace the inline delete button condition with `isDeletable()`. -4. Add disabled delete button with tooltip for in-use certs. -5. Replace `confirm()` with `DeleteCertificateDialog` state management: - - `const [certToDelete, setCertToDelete] = useState(null)` - - Open dialog: `setCertToDelete(cert)` - - Confirm: `deleteMutation.mutate(certToDelete.id)` - - Cancel/success: `setCertToDelete(null)` -6. Remove the duplicate client-side `createBackup()` call from the mutation — the server - already creates a backup. Keeping the client-side call creates two backups per deletion. +Extend the existing `describe('CertificateList', ...)` in +`frontend/src/components/__tests__/CertificateList.test.tsx`. -#### Step 3: Add i18n keys +The existing fixture (`createCertificatesValue`) already supplies: +- `id: 1` custom expired, not in use → `isDeletable = true` → enabled checkbox +- `id: 2` letsencrypt-staging, not in use → `isDeletable = true` → enabled checkbox +- `id: 4` custom valid, not in use → `isDeletable = true` → enabled checkbox +- `id: 5` expired LE, not in use → `isDeletable = true` → enabled checkbox +- `id: 3` custom valid, in use (host has `certificate_id: 3`) → disabled checkbox +- `id: 6` valid LE, not in use → `isDeletable = false` → no checkbox -**Files**: All locale files under `frontend/src/locales/*/translation.json` +| # | Test description | +|---|---| +| 1 | renders enabled checkboxes for ids 1, 2, 4, 5 (deletable, not in use) | +| 2 | renders disabled checkbox (with `aria-disabled`) for id 3 (in-use) | +| 3 | renders no checkbox in id 6's row (valid production LE) | +| 4 | selecting one cert makes the bulk action toolbar visible | +| 5 | header select-all selects only ids 1, 2, 4, 5 — not id 3 (in-use) | +| 6 | clicking the toolbar Delete button opens `BulkDeleteCertificateDialog` | +| 7 | confirming in the bulk dialog calls `deleteCertificate` for each selected ID | -Add the keys from §3.2.4. +### Phase 5 — Playwright E2E Tests -**Complexity**: Low — mostly UI wiring, no new APIs. +**File to create**: `tests/certificate-bulk-delete.spec.ts` -### Phase 3: Backend Unit Tests (Gap Coverage) +Reuse `createCustomCertViaAPI` from `tests/certificate-delete.spec.ts`. Import shared +test helpers from: +- `tests/fixtures/auth-fixtures` — `test`, `expect`, `loginUser` +- `tests/utils/wait-helpers` — `waitForLoadingComplete`, `waitForDialog`, + `waitForAPIResponse` +- `tests/fixtures/test-data` — `generateUniqueId` +- `tests/constants` — `STORAGE_STATE` -While the backend code needs no changes, add tests for the newly-important scenarios: +Seed three custom certs via `beforeAll`, clean up with `afterAll`. Each `test.beforeEach` +navigates to `/certificates` and calls `waitForLoadingComplete`. -**File**: `backend/internal/api/handlers/certificate_handler_test.go` +| # | Test scenario | +|---|---| +| 1 | **Checkbox column present**: checkboxes appear for each deletable cert | +| 2 | **No checkbox for valid LE**: valid production LE cert row has no checkbox | +| 3 | **Select one → toolbar appears**: checking one cert shows the count and Delete button | +| 4 | **Select-all**: header checkbox selects all three seeded certs; toolbar shows count 3 | +| 5 | **Dialog shows correct count**: opening bulk dialog shows "Delete 3 Certificate(s)" | +| 6 | **Cancel preserves certs**: cancelling the dialog leaves all three certs in the list | +| 7 | **Confirm deletes all selected**: confirming removes all selected certs from the table | -1. **Test: Delete expired LE cert not in use succeeds** — ensures the backend does not block - expired LE certs from deletion. -2. **Test: Delete valid LE cert not in use succeeds** — confirms the backend has no - provider-based restrictions (policy is frontend-only). - -The `IsCertificateInUse` service-level tests already exist in `certificate_service_test.go`. -Do **not** duplicate them. Keep only the handler-level tests above that verify the HTTP layer -behavior for expired LE cert deletion. - -**Complexity**: Low — standard Go table-driven tests. - -### Phase 4: Frontend Unit Tests - -**File**: `frontend/src/components/__tests__/CertificateList.test.tsx` - -1. Test `isDeletable()` helper with all provider/status/in-use combinations. -2. Test that delete button renders for deletable certs. -3. Test that delete button is disabled for in-use certs. -4. Test that delete button is hidden for valid production LE certs. - -**File**: `frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx` - -5. Test dialog renders with correct warning text per provider. -6. Test Cancel closes dialog. -7. Test Delete calls onConfirm. - -**Complexity**: Low. - -### Phase 5: Documentation - -Update `frontend/src/locales/en/translation.json` key `"noteText"` to reflect the expanded -deletion policy: - -> "You can delete custom certificates, staging certificates, and expired production certificates -> that are not attached to any proxy host. Active production certificates are automatically -> renewed by Caddy." - -No other documentation changes needed — the feature is self-explanatory in the UI. - -Removes the inline codeql[go/cookie-secure-not-set] suppression which -masked the finding without correcting it, and updates the five unit -tests that previously asserted Secure = false for local-network HTTP. -``` - -## 5. Acceptance Criteria - -- [ ] Expired certificates not attached to any proxy host show a delete button. -- [ ] Custom certificates not attached to any proxy host show a delete button. -- [ ] Staging certificates not attached to any proxy host show a delete button. -- [ ] Certificates attached to a proxy host show a disabled delete button with tooltip. -- [ ] Valid production LE certificates not in use do NOT show a delete button. -- [ ] Clicking delete opens an accessible confirmation dialog (not native `confirm()`). -- [ ] Dialog shows certificate details and appropriate warning text. -- [ ] Confirming deletion removes the certificate and shows a success toast. -- [ ] Canceling the dialog does not delete anything. -- [ ] Server returns `409 Conflict` if the certificate becomes attached between client check and - server delete (race condition safety). -- [ ] A backup is created before each deletion (server-side). -- [ ] All new UI elements are keyboard navigable and screen-reader accessible. -- [ ] All Playwright E2E tests pass on Firefox, Chromium, and WebKit. -- [ ] All new backend unit tests pass. -- [ ] All new frontend unit tests pass. -- [ ] No regressions in existing certificate or proxy host tests. +The "confirm deletes" test awaits the success or failure toast appearance (toast appearance +confirms all requests have settled via `Promise.allSettled`) before asserting the cert +names are no longer visible in the table. --- -## 6. Commit Slicing Strategy +## 5. Backend Considerations -A successful remediation satisfies all of the following: +### 5.1 No New Endpoint -**Rationale**: The scope is small (1 new component, 2 modified files, i18n additions, and tests). -All changes are tightly coupled — the new dialog component is only meaningful together with the -updated delete button logic. Splitting this into multiple PRs would add review overhead without -reducing risk. +The existing `DELETE /api/v1/certificates/:id` route at +`backend/internal/api/routes/routes.go:673` is the only backend touch point. Bulk deletion +is orchestrated entirely in the frontend using `Promise.allSettled`. This is intentional: -### PR-1: Certificate Deletion UX Enhancement +- The volume of certificates eligible for bulk deletion is small in practice. +- Each deletion independently creates a server-side backup. A batch endpoint would need + N individual backups anyway, yielding no efficiency gain. +- Concurrent `Promise.allSettled` provides natural per-item error isolation — a 409 on + one cert (race: cert becomes in-use between checkbox selection and confirmation) surfaces + as a failed count in the toast rather than an unhandled rejection. -**Scope**: All phases (E2E tests, frontend implementation, backend test gaps, frontend unit tests, -docs update). +### 5.2 No Backend Tests Required -**Files**: +The handler tests added in the single-delete PR already cover: success, in-use 409, auth +guard, invalid ID, not-found, and backup-failure paths. Bulk deletion calls the same handler +N times with no new code paths. Nothing new at the backend layer warrants new tests. -| File | Action | -|------|--------| -| `tests/certificate-delete.spec.ts` | Create | -| `frontend/src/components/dialogs/DeleteCertificateDialog.tsx` | Create | -| `frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx` | Create | -| `frontend/src/components/CertificateList.tsx` | Modify | -| `frontend/src/components/__tests__/CertificateList.test.tsx` | Modify | -| `frontend/src/locales/en/translation.json` | Modify | -| `frontend/src/locales/de/translation.json` | Modify | -| `frontend/src/locales/es/translation.json` | Modify | -| `frontend/src/locales/fr/translation.json` | Modify | -| `frontend/src/locales/pt/translation.json` | Modify | -| `backend/internal/api/handlers/certificate_handler_test.go` | Modify | +--- -**Dependencies**: None — the backend API is already complete. +## 6. Security Considerations + +- Bulk delete inherits all security properties of the individual delete endpoint: + authentication required, in-use guard server-side, numeric ID validation in the handler. +- The client-side `isDeletable` check is a UX gate, not a security gate; the server is the + authoritative enforcer. +- `Promise.allSettled` does not short-circuit — a 409 on one cert becomes a failed count, + not an unhandled exception, preserving the remaining deletions. + +--- + +## 7. Commit Slicing Strategy + +**Single PR.** The entire feature — `BulkDeleteCertificateDialog`, i18n keys in 5 locales, +selection layer in `CertificateList.tsx`, unit tests, and E2E tests — is one cohesive +change. The diff is small (< 400 lines of production code across ~6 files) and all parts +are interdependent. Splitting would temporarily ship a broken feature mid-PR. + +Suggested commit title: +``` +feat(certificates): add bulk deletion with checkbox selection +``` + +--- + +## 8. File Change Summary + +| File | Change | Description | +|------|--------|-------------| +| `frontend/src/components/CertificateList.tsx` | Modified | Selection state, checkbox column, bulk toolbar, bulk mutation | +| `frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx` | Created | New dialog for bulk confirmation | +| `frontend/src/locales/en/translation.json` | Modified | 10 new i18n keys under `certificates` | +| `frontend/src/locales/de/translation.json` | Modified | 10 new i18n keys (DE) | +| `frontend/src/locales/es/translation.json` | Modified | 10 new i18n keys (ES) | +| `frontend/src/locales/fr/translation.json` | Modified | 10 new i18n keys (FR) | +| `frontend/src/locales/zh/translation.json` | Modified | 10 new i18n keys (ZH) | +| `frontend/src/components/__tests__/CertificateList.test.tsx` | Modified | 7 new unit test cases | +| `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` | Created | 6 unit test cases for the new dialog | +| `tests/certificate-bulk-delete.spec.ts` | Created | E2E test suite (7 scenarios) | + +**Dependencies**: None — the backend API is already complete. No database migrations. **Validation Gates**: -- `go test ./backend/...` — all pass -- `npx vitest run` — all pass -- Playwright E2E on Firefox, Chromium, WebKit — all pass -- `make lint-fast` — no new warnings - -**Rollback**: Revert the single PR. No database migrations to undo. No backend API changes. - -**Contingency**: If E2E tests are flaky due to certificate seed data timing, add explicit -`waitFor` on the certificate list load state before asserting button visibility. +- `npx vitest run` — all existing and new unit tests pass +- Playwright E2E on Firefox — all 7 new scenarios pass +- `npx tsc --noEmit` — 0 errors +- `make lint-fast` — 0 new warnings diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index fbdc8153..e4802324 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -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('name') const [sortDirection, setSortDirection] = useState('asc') const [certToDelete, setCertToDelete] = useState(null) + const [selectedIds, setSelectedIds] = useState>(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>(() => { + const ids = new Set() + 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) && ( )} + {selectedIds.size > 0 && ( +
+ + {t('certificates.bulkSelectedCount', { count: selectedIds.size })} + + +
+ )}
+ {certificates.length === 0 ? ( - ) : ( - 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 ( + {deletable && !inUse ? ( + + ) : isInUseDeletableCategory ? ( + + ) : ( + - )) + ) + }) )}
+ + handleSort('name')} className="px-6 py-3 cursor-pointer hover:text-white transition-colors" @@ -136,13 +226,47 @@ export default function CertificateList() {
+ No certificates found.
+ handleSelectRow(cert.id!)} + aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })} + /> + + + + + + + + + {t('certificates.deleteInUse')} + + + {cert.name || '-'} {cert.domain} @@ -163,9 +287,6 @@ export default function CertificateList() { {(() => { - 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 ( @@ -204,7 +325,8 @@ export default function CertificateList() { })()}
@@ -221,6 +343,13 @@ export default function CertificateList() { onCancel={() => setCertToDelete(null)} isDeleting={deleteMutation.isPending} /> + c.id && selectedIds.has(c.id))} + open={showBulkDeleteDialog} + onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))} + onCancel={() => setShowBulkDeleteDialog(false)} + isDeleting={bulkDeleteMutation.isPending} + /> ) } diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx index 8228a133..43097b12 100644 --- a/frontend/src/components/__tests__/CertificateList.test.tsx +++ b/frontend/src/components/__tests__/CertificateList.test.tsx @@ -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() 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() 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() 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() 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() 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() 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() 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() 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() 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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' }, diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx new file mode 100644 index 00000000..95ef012b --- /dev/null +++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx @@ -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 ( + { if (!isOpen) onCancel() }}> + + + {t('certificates.bulkDeleteTitle', { count: certificates.length })} + + {t('certificates.bulkDeleteDescription', { count: certificates.length })} + + + +
+
+ +

+ {t('certificates.bulkDeleteConfirm')} +

+
+ +
    + {certificates.map((cert) => ( +
  • + {cert.name || cert.domain} + {providerLabel(cert)} +
  • + ))} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx new file mode 100644 index 00000000..dd0a1991 --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx @@ -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) => (opts ? JSON.stringify(opts) : key), + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +const makeCert = (overrides: Partial): 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( + + ) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('heading', { name: '{"count":3}' })).toBeInTheDocument() + }) + + it('lists each certificate name in the scrollable list', () => { + render( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + expect(container.innerHTML).toBe('') + }) +}) diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index e41d856a..59c6e0b3 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -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", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index a9b34f1c..181a97e3 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -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", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 643492d0..6452f8e0 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -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", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index a7171e0b..b41448a2 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -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", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 6f646ebe..7e17a015 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -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": "登录", diff --git a/tests/certificate-bulk-delete.spec.ts b/tests/certificate-bulk-delete.spec.ts new file mode 100644 index 00000000..31b732b3 --- /dev/null +++ b/tests/certificate-bulk-delete.spec.ts @@ -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 { + 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 { + 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 }); + } + }); + }); +}); From 9ed698b2366eed20803fb98219c9f299d7a5b1f6 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 02:23:08 +0000 Subject: [PATCH 153/181] feat: enhance certificate management with expiring status - Update isInUse function to handle certificates without an ID. - Modify isDeletable function to include 'expiring' status as deletable. - Adjust CertificateList component to reflect changes in deletable logic. - Update BulkDeleteCertificateDialog and DeleteCertificateDialog to handle expiring certificates. - Add tests for expiring certificates in CertificateList and BulkDeleteCertificateDialog. - Update translations for expiring certificates in multiple languages. --- docs/plans/current_spec.md | 792 ++++-------------- frontend/src/components/CertificateList.tsx | 6 +- .../__tests__/CertificateList.test.tsx | 15 +- .../dialogs/BulkDeleteCertificateDialog.tsx | 1 + .../dialogs/DeleteCertificateDialog.tsx | 1 + .../BulkDeleteCertificateDialog.test.tsx | 14 + frontend/src/locales/de/translation.json | 1 + frontend/src/locales/en/translation.json | 3 +- frontend/src/locales/es/translation.json | 1 + frontend/src/locales/fr/translation.json | 1 + frontend/src/locales/zh/translation.json | 1 + 11 files changed, 198 insertions(+), 638 deletions(-) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 27f10a89..e02b864e 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,44 +1,14 @@ -# Certificate Bulk Delete — Spec +# Fix: Allow deletion of expiring_soon certificates not in use -**Date**: 2026-03-22 -**Priority**: Medium -**Type**: User Requested Feature -**Status**: Planning — Awaiting Implementation +> **Status note:** The bug report refers to the status as `expiring_soon`. In this codebase the actual status string is `'expiring'` (defined in `frontend/src/api/certificates.ts` line 10 and `backend/internal/services/certificate_service.go` line 33). All references below use `'expiring'`. --- -## 1. Problem Statement +## 1. Bug Root Cause -The Certificates page now supports individual deletion of custom, staging, and expired -production Let's Encrypt certificates. Users who accumulate many such certificates must -click through a confirmation dialog for each one. There is no mechanism to select and -destroy multiple certificates in a single operation. - -This feature adds checkbox-based bulk selection and a single confirmation step to delete -N certificates at once, operating under exactly the same policy terms as the existing -individual delete affordance. - -### Non-Goals - -- Changes to the individual delete flow or `DeleteCertificateDialog`. -- A new backend batch endpoint (sequential per-cert calls are sufficient). -- Auto-cleanup / scheduled pruning. -- Migrating `CertificateList` from its current raw `` to the `DataTable` component. - ---- - -## 2. Existing Foundation - -### 2.1 Individual Delete Policy (Preserved Verbatim) - -The following logic lives in `frontend/src/components/CertificateList.tsx` and must be -preserved without modification. Bulk selection obeys it exactly: +`frontend/src/components/CertificateList.tsx` — `isDeletable` (lines 26–34): ```ts -export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean { - return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id) -} - export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean { if (!cert.id) return false if (isInUse(cert, hosts)) return false @@ -50,322 +20,43 @@ export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean { } ``` -| Certificate Category | `isDeletable` | `isInUse` | Individual button | Checkbox | -|---|---|---|---|---| -| custom / staging — not in use | ✅ true | ❌ false | Active delete Trash2 | ✅ Enabled checkbox | -| custom / staging / expired LE — in use | ❌ false | ✅ true | `aria-disabled` Trash2 + tooltip | ✅ Checkbox rendered but `disabled` + tooltip | -| expired LE — not in use | ✅ true | ❌ false | Active delete Trash2 | ✅ Enabled checkbox | -| valid/expiring LE — not in use | ❌ false | ❌ false | No affordance at all | No checkbox, no column cell | -| valid/expiring LE — in use | ❌ false | ✅ true | No affordance at all | No checkbox, no column cell | +A cert with `provider === 'letsencrypt'` and `status === 'expiring'` that is not attached to any proxy host evaluates to `false` because `'expiring' !== 'expired'`. No delete button is rendered, and the cert cannot be selected for bulk delete. -### 2.2 Backend — No New Endpoint Required +**Additional bug:** `isInUse` has a false-positive when `cert.id` is falsy — `undefined === undefined` would match any proxy host with an unset certificate reference, incorrectly treating the cert as in-use. Fix by adding `if (!cert.id) return false` as the first line of `isInUse`. -`DELETE /api/v1/certificates/:id` is registered at `backend/internal/api/routes/routes.go:673` -and already: +Three secondary locations propagate the same status blind-spot: -- Guards against in-use certs (`IsCertificateInUse` → `409 Conflict`). -- Creates a server-side backup before deletion. -- Deletes the DB record and ACME files. -- Invalidates the cert cache and fires a notification. - -Bulk deletion will call this endpoint N times concurrently using `Promise.allSettled`, -exactly as the ProxyHosts bulk delete does for `deleteHost`. `ids.map(id => deleteCertificate(id))` -fires all promises concurrently; `Promise.allSettled` awaits all settlements before resolving. -No batch endpoint is warranted at this scale. - -### 2.3 CertificateList Rendering Architecture - -`CertificateList.tsx` renders a purpose-built raw `
` with a manual `sortedCertificates` -`useMemo`. It does **not** use the `DataTable` UI component. This plan does not migrate it — -the selection layer will be grafted directly onto the existing table. - -The `Checkbox` component at `frontend/src/components/ui/Checkbox.tsx` supports an -`indeterminate` prop backed by Radix UI `CheckboxPrimitive`. This component will be reused -for both the header "select all" checkbox and each row checkbox, matching the rendering -style in `DataTable.tsx`. - -### 2.4 Bulk Selection Precedent — ProxyHosts Page - -`frontend/src/pages/ProxyHosts.tsx` is the reference implementation for bulk operations: - -- `selectedHosts: Set` — `useState>(new Set())` -- The `DataTable` `selectable` prop handles the per-row checkbox column and the header - "select all" checkbox automatically, but `DataTable.handleSelectAll` selects **every** row. -- For certificates the "select all" must only select the `isDeletable && !isInUse` subset, - so we cannot delegate to `DataTable`'s built-in logic even if we migrated. -- The bulk action bar is a conditional `
` that appears only when - `selectedHosts.size > 0`, containing the count and action buttons. +- `frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx` — `providerLabel` falls through to `return cert.provider` (showing raw `"letsencrypt"`) for expiring certs instead of a human-readable label. +- `frontend/src/components/dialogs/DeleteCertificateDialog.tsx` — `getWarningKey` falls through to `'certificates.deleteConfirmCustom'` for expiring certs instead of a contextual message. --- -## 3. Technical Specification +## 2. Frontend Fix -### 3.1 State Changes — `CertificateList.tsx` - -Add two pieces of state alongside the existing `certToDelete` and `sortColumn` state: +### 2a. `frontend/src/components/CertificateList.tsx` — `isDeletable` +**Before:** ```ts -const [selectedIds, setSelectedIds] = useState>(new Set()) -const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false) +return ( + cert.provider === 'custom' || + cert.provider === 'letsencrypt-staging' || + cert.status === 'expired' +) ``` -Add a memoised derived set of all cert IDs that are eligible for selection: - +**After:** ```ts -const selectableCertIds = useMemo>(() => { - const ids = new Set() - for (const cert of sortedCertificates) { - if (isDeletable(cert, hosts) && cert.id) { - ids.add(cert.id) - } - } - return ids -}, [sortedCertificates, hosts]) +return ( + cert.provider === 'custom' || + cert.provider === 'letsencrypt-staging' || + cert.status === 'expired' || + cert.status === 'expiring' +) ``` -> Only `isDeletable` certs (not in use, correct provider/status) enter `selectableCertIds`. -> In-use-but-would-be-deletable certs (`isInUse && (custom || staging || expired)`) do NOT -> enter the selectable set — their row checkbox is rendered but is `disabled`. - -Add selection handlers: - -```ts -const handleSelectAll = () => { - if (selectedIds.size === selectableCertIds.size) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(selectableCertIds)) - } -} - -const handleSelectRow = (id: number) => { - const next = new Set(selectedIds) - next.has(id) ? next.delete(id) : next.add(id) - setSelectedIds(next) -} -``` - -Header checkbox state derived from selection: - -```ts -const allSelectableSelected = - selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size -const someSelected = - selectedIds.size > 0 && selectedIds.size < selectableCertIds.size -``` - -### 3.2 Table Column Changes — `CertificateList.tsx` - -#### 3.2.1 New Leftmost `
` row, before the existing -"Name" column: - -```tsx - -``` - -Also update the empty-state ` -``` - -**Case B — in-use-but-deletable category (`isInUse && (custom || staging || expired)`):** - -The individual delete button mirrors this with `aria-disabled`. The checkbox must match: -rendered but disabled, with the same tooltip text from `t('certificates.deleteInUse')`. - -```tsx - -``` - -> Radix `Checkbox` with `disabled` swallows pointer events. Wrapping in a `` restores -> hover targeting for the tooltip — the same technique used for the existing `aria-disabled` -> delete buttons where `TooltipTrigger asChild` wraps the ` diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx index 43097b12..ea63b910 100644 --- a/frontend/src/components/__tests__/CertificateList.test.tsx +++ b/frontend/src/components/__tests__/CertificateList.test.tsx @@ -150,9 +150,14 @@ describe('CertificateList', () => { expect(isDeletable(cert, noHosts)).toBe(false) }) - it('returns false for expiring LE cert not in use', () => { + it('returns true 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) + expect(isDeletable(cert, noHosts)).toBe(true) + }) + + it('returns false for expiring LE cert that is in use', () => { + const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' } + expect(isDeletable(cert, withHost(7))).toBe(false) }) }) @@ -172,6 +177,12 @@ describe('CertificateList', () => { 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('returns false when cert.id is undefined even if a host has certificate_id undefined', () => { + const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } + const host = createProxyHost({ certificate_id: undefined }) + expect(isInUse(cert, [host])).toBe(false) + }) }) it('renders delete button for deletable certs', async () => { diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx index 95ef012b..4db633ab 100644 --- a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx +++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx @@ -25,6 +25,7 @@ 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' + if (cert.status === 'expiring') return 'Expiring LE' return cert.provider } diff --git a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx index 03fbf23f..68491eb6 100644 --- a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx +++ b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx @@ -23,6 +23,7 @@ interface DeleteCertificateDialogProps { function getWarningKey(cert: Certificate): string { if (cert.status === 'expired') return 'certificates.deleteConfirmExpired' + if (cert.status === 'expiring') return 'certificates.deleteConfirmExpiring' if (cert.provider === 'letsencrypt-staging') return 'certificates.deleteConfirmStaging' return 'certificates.deleteConfirmCustom' } diff --git a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx index dd0a1991..65d4eac4 100644 --- a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx +++ b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx @@ -126,4 +126,18 @@ describe('BulkDeleteCertificateDialog', () => { ) expect(container.innerHTML).toBe('') }) + + it('renders "Expiring LE" label for a letsencrypt cert with status expiring', () => { + const expiringCert = makeCert({ id: 4, name: 'Expiring Cert', domain: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' }) + render( + + ) + expect(screen.getByText('Expiring LE')).toBeInTheDocument() + }) }) diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 59c6e0b3..fde5acb9 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -179,6 +179,7 @@ "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteConfirmExpiring": "This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.", "deleteSuccess": "Certificate deleted", "deleteFailed": "Failed to delete certificate", "deleteInUse": "Cannot delete — certificate is attached to a proxy host", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 181a97e3..5dbcda48 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -182,12 +182,13 @@ "uploadSuccess": "Certificate uploaded successfully", "uploadFailed": "Failed to upload certificate", "note": "Note", - "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "noteText": "You can delete custom certificates, staging certificates, and expired or expiring production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", "provider": "Provider", "deleteTitle": "Delete Certificate", "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteConfirmExpiring": "This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.", "deleteSuccess": "Certificate deleted", "deleteFailed": "Failed to delete certificate", "deleteInUse": "Cannot delete — certificate is attached to a proxy host", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 6452f8e0..6e525ffc 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -179,6 +179,7 @@ "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteConfirmExpiring": "This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.", "deleteSuccess": "Certificate deleted", "deleteFailed": "Failed to delete certificate", "deleteInUse": "Cannot delete — certificate is attached to a proxy host", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index b41448a2..3c927858 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -179,6 +179,7 @@ "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteConfirmExpiring": "This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.", "deleteSuccess": "Certificate deleted", "deleteFailed": "Failed to delete certificate", "deleteInUse": "Cannot delete — certificate is attached to a proxy host", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 7e17a015..265f6f85 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -179,6 +179,7 @@ "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", "deleteConfirmStaging": "This staging certificate will be removed. It will be regenerated on next request.", "deleteConfirmExpired": "This expired certificate is no longer active and will be permanently removed.", + "deleteConfirmExpiring": "This certificate is expiring soon. It will be permanently removed and will not be auto-renewed.", "deleteSuccess": "Certificate deleted", "deleteFailed": "Failed to delete certificate", "deleteInUse": "Cannot delete — certificate is attached to a proxy host", From 73aecc60e84d635dd98f4de70d895a62a6472a07 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 05:24:58 +0000 Subject: [PATCH 154/181] fix(i18n): restore localized noteText in all non-English certificate locales - The certificate section's noteText had previously been translated into Chinese, German, Spanish, and French but was inadvertently overwritten with an English string when the individual certificate delete feature was introduced. - All four locales now carry properly translated text that also reflects the updated policy: expired or expiring production certificates that are not attached to a proxy host are now eligible for deletion. - Newly introduced keys (deleteConfirmExpiring and other delete-related keys) remain as English placeholders pending professional translation, which is the established pattern for this project. --- frontend/src/locales/de/translation.json | 2 +- frontend/src/locales/es/translation.json | 2 +- frontend/src/locales/fr/translation.json | 2 +- frontend/src/locales/zh/translation.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index fde5acb9..d415aa35 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -173,7 +173,7 @@ "uploadSuccess": "Zertifikat erfolgreich hochgeladen", "uploadFailed": "Fehler beim Hochladen des Zertifikats", "note": "Hinweis", - "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "noteText": "Sie können benutzerdefinierte Zertifikate, Staging-Zertifikate sowie abgelaufene oder ablaufende Produktionszertifikate löschen, die keinem Proxy-Host zugeordnet sind. Aktive Produktionszertifikate werden von Caddy automatisch erneuert.", "provider": "Provider", "deleteTitle": "Delete Certificate", "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 6e525ffc..4740805c 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -173,7 +173,7 @@ "uploadSuccess": "Certificado subido exitosamente", "uploadFailed": "Error al subir el certificado", "note": "Nota", - "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "noteText": "Puedes eliminar certificados personalizados, certificados de staging y certificados de producción vencidos o por vencer que no estén vinculados a ningún host proxy. Los certificados de producción activos se renuevan automáticamente mediante Caddy.", "provider": "Provider", "deleteTitle": "Delete Certificate", "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 3c927858..624d858c 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -173,7 +173,7 @@ "uploadSuccess": "Certificat téléversé avec succès", "uploadFailed": "Échec du téléversement du certificat", "note": "Note", - "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "noteText": "Vous pouvez supprimer les certificats personnalisés, les certificats de staging et les certificats de production expirés ou arrivant à expiration qui ne sont associés à aucun hôte proxy. Les certificats de production actifs sont renouvelés automatiquement par Caddy.", "provider": "Provider", "deleteTitle": "Delete Certificate", "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 265f6f85..4d21149c 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -173,7 +173,7 @@ "uploadSuccess": "证书上传成功", "uploadFailed": "证书上传失败", "note": "注意", - "noteText": "You can delete custom certificates, staging certificates, and expired production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", + "noteText": "您可以删除未附加到任何代理主机的自定义证书、暂存证书以及已过期或即将过期的生产证书。活跃的生产证书由 Caddy 自动续期。", "provider": "Provider", "deleteTitle": "Delete Certificate", "deleteConfirmCustom": "This will permanently delete this certificate. A backup will be created first.", From f13d95df0fe6595b4d26e1beac517e6659939de9 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 05:32:52 +0000 Subject: [PATCH 155/181] fix: specify gotestsum version in workflows for consistency --- .github/renovate.json | 26 ++++++++++++++++++++++++++ .github/workflows/codecov-upload.yml | 2 +- .github/workflows/quality-checks.yml | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 7def45de..2f37f034 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -130,6 +130,32 @@ "datasourceTemplate": "go", "versioningTemplate": "semver" }, + { + "customType": "regex", + "description": "Track gotestsum version in codecov workflow", + "managerFilePatterns": [ + "/^\\.github/workflows/codecov-upload\\.yml$/" + ], + "matchStrings": [ + "gotestsum@v(?[^\\s]+)" + ], + "depNameTemplate": "gotest.tools/gotestsum", + "datasourceTemplate": "go", + "versioningTemplate": "semver" + }, + { + "customType": "regex", + "description": "Track gotestsum version in quality checks workflow", + "managerFilePatterns": [ + "/^\\.github/workflows/quality-checks\\.yml$/" + ], + "matchStrings": [ + "gotestsum@v(?[^\\s]+)" + ], + "depNameTemplate": "gotest.tools/gotestsum", + "datasourceTemplate": "go", + "versioningTemplate": "semver" + }, { "customType": "regex", "description": "Track govulncheck version in scripts", diff --git a/.github/workflows/codecov-upload.yml b/.github/workflows/codecov-upload.yml index 91cd5f12..0e2aaec7 100644 --- a/.github/workflows/codecov-upload.yml +++ b/.github/workflows/codecov-upload.yml @@ -127,7 +127,7 @@ jobs: } >> "$GITHUB_ENV" - name: Install gotestsum - run: go install gotest.tools/gotestsum@latest + run: go install gotest.tools/gotestsum@v1.13.0 - name: Run Go tests with coverage working-directory: ${{ github.workspace }} diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 2d6b304f..d14dec74 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -149,7 +149,7 @@ jobs: bash "scripts/repo_health_check.sh" - name: Install gotestsum - run: go install gotest.tools/gotestsum@latest + run: go install gotest.tools/gotestsum@v1.13.0 - name: Run Go tests id: go-tests From 4bdc771cd45b668eece8fc15f65320643a4c8a90 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 05:39:37 +0000 Subject: [PATCH 156/181] feat: synchronize selected certificate IDs with available certificates on update --- frontend/src/components/CertificateList.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index cd35e821..8866fa2e 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { Trash2, ChevronUp, ChevronDown } from 'lucide-react' -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { useTranslation } from 'react-i18next' import BulkDeleteCertificateDialog from './dialogs/BulkDeleteCertificateDialog' @@ -46,6 +46,15 @@ export default function CertificateList() { const [selectedIds, setSelectedIds] = useState>(new Set()) const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false) + useEffect(() => { + setSelectedIds(prev => { + const validIds = new Set(certificates.map(c => c.id).filter((id): id is number => id != null)) + const reconciled = new Set([...prev].filter(id => validIds.has(id))) + if (reconciled.size === prev.size) return prev + return reconciled + }) + }, [certificates]) + const deleteMutation = useMutation({ mutationFn: async (id: number) => { await deleteCertificate(id) From 3cacecde5ab58e2d412c9ec6a5464856c313ddc6 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 05:42:02 +0000 Subject: [PATCH 157/181] fx: replace getAuthToken function with getStorageStateAuthHeaders for improved auth handling --- tests/certificate-bulk-delete.spec.ts | 28 +++------------------------ 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/tests/certificate-bulk-delete.spec.ts b/tests/certificate-bulk-delete.spec.ts index 31b732b3..4e318616 100644 --- a/tests/certificate-bulk-delete.spec.ts +++ b/tests/certificate-bulk-delete.spec.ts @@ -13,7 +13,6 @@ * @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 { @@ -23,6 +22,7 @@ import { waitForToast, } from './utils/wait-helpers'; import { generateUniqueId } from './fixtures/test-data'; +import { getStorageStateAuthHeaders } from './utils/api-helpers'; import { STORAGE_STATE } from './constants'; const CERTIFICATES_API = /\/api\/v1\/certificates/; @@ -82,26 +82,6 @@ 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. @@ -110,12 +90,11 @@ function getAuthToken(baseURL: string): string | undefined { 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}` } } : {}), + extraHTTPHeaders: getStorageStateAuthHeaders(), }); try { @@ -164,11 +143,10 @@ async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; ce * Delete a certificate directly via the API for cleanup. */ async function deleteCertViaAPI(baseURL: string, certId: number): Promise { - const token = getAuthToken(baseURL); const ctx = await playwrightRequest.newContext({ baseURL, storageState: STORAGE_STATE, - ...(token ? { extraHTTPHeaders: { Authorization: `Bearer ${token}` } } : {}), + extraHTTPHeaders: getStorageStateAuthHeaders(), }); try { From a707d8e67e579eeb8cd6077430d5719efe0f1d8a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 05:45:23 +0000 Subject: [PATCH 158/181] feat(i18n): add localized provider labels for certificate management --- .../dialogs/BulkDeleteCertificateDialog.tsx | 12 ++++++------ frontend/src/locales/de/translation.json | 6 +++++- frontend/src/locales/en/translation.json | 6 +++++- frontend/src/locales/es/translation.json | 6 +++++- frontend/src/locales/fr/translation.json | 6 +++++- frontend/src/locales/zh/translation.json | 6 +++++- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx index 4db633ab..276c81ec 100644 --- a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx +++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx @@ -21,11 +21,11 @@ interface BulkDeleteCertificateDialogProps { 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' - if (cert.status === 'expiring') return 'Expiring LE' +function providerLabel(cert: Certificate, t: (key: string) => string): string { + if (cert.provider === 'letsencrypt-staging') return t('certificates.providerStaging') + if (cert.provider === 'custom') return t('certificates.providerCustom') + if (cert.status === 'expired') return t('certificates.providerExpiredLE') + if (cert.status === 'expiring') return t('certificates.providerExpiringLE') return cert.provider } @@ -68,7 +68,7 @@ export default function BulkDeleteCertificateDialog({ className="flex items-center justify-between px-4 py-2" > {cert.name || cert.domain} - {providerLabel(cert)} + {providerLabel(cert, t)} ))} diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index d415aa35..d89b6cac 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -193,7 +193,11 @@ "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" + "bulkDeleteFailed": "Zertifikate konnten nicht gelöscht werden", + "providerStaging": "Staging", + "providerCustom": "Benutzerdefiniert", + "providerExpiredLE": "Abgelaufen LE", + "providerExpiringLE": "Ablaufend LE" }, "auth": { "login": "Anmelden", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 5dbcda48..33488e45 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -202,7 +202,11 @@ "bulkDeleteButton": "Delete {{count}} Certificate(s)", "bulkDeleteSuccess": "{{count}} certificate(s) deleted", "bulkDeletePartial": "{{deleted}} deleted, {{failed}} failed", - "bulkDeleteFailed": "Failed to delete certificates" + "bulkDeleteFailed": "Failed to delete certificates", + "providerStaging": "Staging", + "providerCustom": "Custom", + "providerExpiredLE": "Expired LE", + "providerExpiringLE": "Expiring LE" }, "auth": { "login": "Login", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 4740805c..1b2c1543 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -193,7 +193,11 @@ "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" + "bulkDeleteFailed": "No se pudieron eliminar los certificados", + "providerStaging": "Pruebas", + "providerCustom": "Personalizado", + "providerExpiredLE": "LE Expirado", + "providerExpiringLE": "LE Por expirar" }, "auth": { "login": "Iniciar Sesión", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 624d858c..555349ca 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -193,7 +193,11 @@ "bulkDeleteButton": "Supprimer {{count}} Certificat(s)", "bulkDeleteSuccess": "{{count}} certificat(s) supprimé(s)", "bulkDeletePartial": "{{deleted}} supprimé(s), {{failed}} échoué(s)", - "bulkDeleteFailed": "Impossible de supprimer les certificats" + "bulkDeleteFailed": "Impossible de supprimer les certificats", + "providerStaging": "Test", + "providerCustom": "Personnalisé", + "providerExpiredLE": "LE Expiré", + "providerExpiringLE": "LE Expirant" }, "auth": { "login": "Connexion", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 4d21149c..fe85faba 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -193,7 +193,11 @@ "bulkDeleteButton": "删除 {{count}} 个证书", "bulkDeleteSuccess": "已删除 {{count}} 个证书", "bulkDeletePartial": "已删除 {{deleted}} 个,{{failed}} 个失败", - "bulkDeleteFailed": "证书删除失败" + "bulkDeleteFailed": "证书删除失败", + "providerStaging": "测试", + "providerCustom": "自定义", + "providerExpiredLE": "已过期 LE", + "providerExpiringLE": "即将过期 LE" }, "auth": { "login": "登录", From 849c3513bb297d3766ba3d966cadd2be75a1ef3b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 05:46:49 +0000 Subject: [PATCH 159/181] feat(i18n): add aria-label for bulk delete certificates in multiple languages --- frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx | 2 +- frontend/src/locales/de/translation.json | 1 + frontend/src/locales/en/translation.json | 1 + frontend/src/locales/es/translation.json | 1 + frontend/src/locales/fr/translation.json | 1 + frontend/src/locales/zh/translation.json | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx index 276c81ec..17f867ac 100644 --- a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx +++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx @@ -59,7 +59,7 @@ export default function BulkDeleteCertificateDialog({
    {certificates.map((cert) => ( diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index d89b6cac..4151de7b 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -190,6 +190,7 @@ "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.", + "bulkDeleteListAriaLabel": "Zu löschende Zertifikate", "bulkDeleteButton": "{{count}} Zertifikat(e) löschen", "bulkDeleteSuccess": "{{count}} Zertifikat(e) gelöscht", "bulkDeletePartial": "{{deleted}} gelöscht, {{failed}} fehlgeschlagen", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 33488e45..51b45bea 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -199,6 +199,7 @@ "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.", + "bulkDeleteListAriaLabel": "Certificates to be deleted", "bulkDeleteButton": "Delete {{count}} Certificate(s)", "bulkDeleteSuccess": "{{count}} certificate(s) deleted", "bulkDeletePartial": "{{deleted}} deleted, {{failed}} failed", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index 1b2c1543..14544d4f 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -190,6 +190,7 @@ "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.", + "bulkDeleteListAriaLabel": "Certificados a eliminar", "bulkDeleteButton": "Eliminar {{count}} Certificado(s)", "bulkDeleteSuccess": "{{count}} certificado(s) eliminado(s)", "bulkDeletePartial": "{{deleted}} eliminado(s), {{failed}} fallido(s)", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 555349ca..9e5c733b 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -190,6 +190,7 @@ "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.", + "bulkDeleteListAriaLabel": "Certificats à supprimer", "bulkDeleteButton": "Supprimer {{count}} Certificat(s)", "bulkDeleteSuccess": "{{count}} certificat(s) supprimé(s)", "bulkDeletePartial": "{{deleted}} supprimé(s), {{failed}} échoué(s)", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index fe85faba..4cb8e552 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -190,6 +190,7 @@ "bulkDeleteTitle": "删除 {{count}} 个证书", "bulkDeleteDescription": "删除 {{count}} 个证书", "bulkDeleteConfirm": "以下证书将被永久删除。服务器在每次删除前会创建备份。", + "bulkDeleteListAriaLabel": "将被删除的证书", "bulkDeleteButton": "删除 {{count}} 个证书", "bulkDeleteSuccess": "已删除 {{count}} 个证书", "bulkDeletePartial": "已删除 {{deleted}} 个,{{failed}} 个失败", From 7d986f28219f2e20547fca3b8664c361e8792c6d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 23 Mar 2026 13:14:48 +0000 Subject: [PATCH 160/181] chore: update package versions in package-lock.json for consistency --- frontend/package-lock.json | 30 ++++---- package-lock.json | 150 ++++++++++++++++++------------------- 2 files changed, 90 insertions(+), 90 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4239426c..aea821ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5032,9 +5032,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001780", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", "dev": true, "funding": [ { @@ -7036,9 +7036,9 @@ } }, "node_modules/i18next": { - "version": "25.10.4", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.4.tgz", - "integrity": "sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==", + "version": "25.10.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.5.tgz", + "integrity": "sha512-jRnF7eRNsdcnh7AASSgaU3lj/8lJZuHkfsouetnLEDH0xxE1vVi7qhiJ9RhdSPUyzg4ltb7P7aXsFlTk9sxL2w==", "funding": [ { "type": "individual", @@ -7780,9 +7780,9 @@ } }, "node_modules/knip": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.0.2.tgz", - "integrity": "sha512-W17Bo5N9AYn0ZkgWHGBmK/01SrSmr3B6iStr3zudDa2eqi+Kc8VmPjSpTYKDV2Uy/kojrlcH/gS1wypAXfXRRA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.0.3.tgz", + "integrity": "sha512-6Ai+Iv41dVpBYH6mReFejhniWq4eiaKrBw4kghqz2Ew5psQMYEqYxJtXLdj/7vRJ3nVaHpakhYUCKO8p3ftNsQ==", "dev": true, "funding": [ { @@ -9767,9 +9767,9 @@ } }, "node_modules/react-i18next": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.1.tgz", - "integrity": "sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==", + "version": "16.6.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.2.tgz", + "integrity": "sha512-/S/GPzElTqEi5o2kzd0/O2627hPDmE6OGhJCCwCfUaQ3syyu+kaYH8/PYFtZeWc25NzfxTN/2fD1QjvrTgrFfA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", @@ -10632,9 +10632,9 @@ "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz", + "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==", "dev": true, "license": "MIT", "engines": { diff --git a/package-lock.json b/package-lock.json index 2fb24160..3dc6c32e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -378,9 +378,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, "license": "MIT", "funding": { @@ -404,9 +404,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "cpu": [ "arm64" ], @@ -421,9 +421,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "cpu": [ "arm64" ], @@ -438,9 +438,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "cpu": [ "x64" ], @@ -455,9 +455,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "cpu": [ "x64" ], @@ -472,9 +472,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "cpu": [ "arm" ], @@ -489,9 +489,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "cpu": [ "arm64" ], @@ -506,9 +506,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "cpu": [ "arm64" ], @@ -523,9 +523,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "cpu": [ "ppc64" ], @@ -540,9 +540,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "cpu": [ "s390x" ], @@ -557,9 +557,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "cpu": [ "x64" ], @@ -574,9 +574,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "cpu": [ "x64" ], @@ -591,9 +591,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "cpu": [ "arm64" ], @@ -608,9 +608,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "cpu": [ "wasm32" ], @@ -625,9 +625,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "cpu": [ "arm64" ], @@ -642,9 +642,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "cpu": [ "x64" ], @@ -659,9 +659,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true, "license": "MIT" }, @@ -3388,14 +3388,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3404,21 +3404,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" } }, "node_modules/run-parallel": { @@ -3820,16 +3820,16 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", + "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "bin": { From ca477c48d4236fbb5dfc0910837e3149dd66ea67 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 24 Mar 2026 01:47:22 +0000 Subject: [PATCH 161/181] chore: Enhance documentation for E2E testing: - Added clarity and structure to README files, including recent updates and getting started sections. - Improved manual verification documentation for CrowdSec authentication, emphasizing expected outputs and success criteria. - Updated debugging guide with detailed output examples and automatic trace capture information. - Refined best practices for E2E tests, focusing on efficient polling, locator strategies, and state management. - Documented triage report for DNS Provider feature tests, highlighting issues fixed and test results before and after improvements. - Revised E2E test writing guide to include when to use specific helper functions and patterns for better test reliability. - Enhanced troubleshooting documentation with clear resolutions for common issues, including timeout and token configuration problems. - Updated tests README to provide quick links and best practices for writing robust tests. --- ARCHITECTURE.md | 73 ++++ CHANGELOG.md | 5 + CONTRIBUTING.md | 5 +- README.md | 20 +- SECURITY.md | 22 +- VERSION.md | 6 +- docs/SECURITY_PRACTICES.md | 15 + docs/actions/trivy-scan-failure.md | 4 +- .../crowdsec_integration_failure_analysis.md | 20 +- .../sprint1-timeout-remediation-findings.md | 33 ++ docs/development/go_version_upgrades.md | 19 + docs/development/running-e2e.md | 19 +- docs/features/api.md | 1 + docs/features/caddyfile-import.md | 3 + docs/features/dns-challenge.md | 4 + docs/features/docker-integration.md | 2 + docs/features/plugin-security.md | 22 ++ docs/features/proxy-headers.md | 4 + docs/getting-started.md | 14 +- docs/github-setup.md | 8 + docs/guides/crowdsec-setup.md | 9 + docs/maintenance/README.md | 3 + docs/maintenance/geolite2-checksum-update.md | 34 +- .../patches/e2e_workflow_timeout_fix.patch.md | 332 ++++++++++-------- docs/performance/feature-flags-endpoint.md | 50 +++ docs/plans/rate_limit_ci_fix_spec.md | 38 ++ docs/plans/telegram_implementation_spec.md | 6 + docs/plans/telegram_remediation_spec.md | 10 + docs/reviews/crowdsec_auth_fix_code_review.md | 13 + docs/runbooks/emergency-lockout-recovery.md | 2 + .../archive/2026-02-06-validation-report.md | 12 +- .../archive/PHASE_2_3_VALIDATION_REPORT.md | 18 + .../SECURITY-EXCEPTION-nebula-v1.9.7.md | 20 +- .../archive/VULNERABILITY_ACCEPTANCE.md | 5 + .../VULNERABILITY_ASSESSMENT_PHASE2.md | 8 + .../accessibility_remediation_crowdsec.md | 3 + .../advisory_2026-02-01_base_image_cves.md | 44 ++- ...visory_2026-02-04_debian_cves_temporary.md | 8 + docs/security/archive/api-key-handling.md | 7 + docs/security/ghsa-69x3-g4r3-p962-options.md | 1 + docs/testing/DEBUGGING_IMPLEMENTATION.md | 31 ++ docs/testing/DEBUG_OUTPUT_EXAMPLES.md | 30 ++ docs/testing/FAILURE_DIAGNOSIS_GUIDE.md | 44 ++- docs/testing/README.md | 1 + .../crowdsec_auth_manual_verification.md | 15 + docs/testing/debugging-guide.md | 10 + docs/testing/e2e-best-practices.md | 38 ++ .../testing/e2e-dns-provider-triage-report.md | 27 ++ docs/testing/e2e-test-writing-guide.md | 13 + docs/testing/sprint1-improvements.md | 2 + docs/troubleshooting/e2e-tests.md | 38 ++ tests/README.md | 10 + 52 files changed, 983 insertions(+), 198 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a964e8da..55d2aa54 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -306,11 +306,13 @@ graph TB **Key Modules:** #### API Layer (`internal/api/`) + - **Handlers:** Process HTTP requests, validate input, return responses - **Middleware:** CORS, GZIP, authentication, logging, metrics, panic recovery - **Routes:** Route registration and grouping (public vs authenticated) **Example Endpoints:** + - `GET /api/v1/proxy-hosts` - List all proxy hosts - `POST /api/v1/proxy-hosts` - Create new proxy host - `PUT /api/v1/proxy-hosts/:id` - Update proxy host @@ -318,6 +320,7 @@ graph TB - `WS /api/v1/logs` - WebSocket for real-time logs #### Service Layer (`internal/services/`) + - **ProxyService:** CRUD operations for proxy hosts, validation logic - **CertificateService:** ACME certificate provisioning and renewal - **DockerService:** Container discovery and monitoring @@ -327,12 +330,14 @@ graph TB **Design Pattern:** Services contain business logic and call multiple repositories/managers #### Caddy Manager (`internal/caddy/`) + - **Manager:** Orchestrates Caddy configuration updates - **Config Builder:** Generates Caddy JSON from database models - **Reload Logic:** Atomic config application with rollback on failure - **Security Integration:** Injects Cerberus middleware into Caddy pipelines **Responsibilities:** + 1. Generate Caddy JSON configuration from database state 2. Validate configuration before applying 3. Trigger Caddy reload via JSON API @@ -340,22 +345,26 @@ graph TB 5. Integrate security layers (WAF, ACL, Rate Limiting) #### Security Suite (`internal/cerberus/`) + - **ACL (Access Control Lists):** IP-based allow/deny rules, GeoIP blocking - **WAF (Web Application Firewall):** Coraza engine with OWASP CRS - **CrowdSec:** Behavior-based threat detection with global intelligence - **Rate Limiter:** Per-IP request throttling **Integration Points:** + - Middleware injection into Caddy request pipeline - Database-driven rule configuration - Metrics collection for security events #### Database Layer (`internal/database/`) + - **Migrations:** Automatic schema versioning with GORM AutoMigrate - **Seeding:** Default settings and admin user creation - **Connection Management:** SQLite with WAL mode and connection pooling **Schema Overview:** + - **ProxyHost:** Domain, upstream target, SSL config - **RemoteServer:** Upstream server definitions - **CaddyConfig:** Generated Caddy configuration (audit trail) @@ -372,6 +381,7 @@ graph TB **Component Architecture:** #### Pages (`src/pages/`) + - **Dashboard:** System overview, recent activity, quick actions - **ProxyHosts:** List, create, edit, delete proxy configurations - **Certificates:** Manage SSL/TLS certificates, view expiry @@ -380,17 +390,20 @@ graph TB - **Users:** User management (admin only) #### Components (`src/components/`) + - **Forms:** Reusable form inputs with validation - **Modals:** Dialog components for CRUD operations - **Tables:** Data tables with sorting, filtering, pagination - **Layout:** Header, sidebar, navigation #### API Client (`src/api/`) + - Centralized API calls with error handling - Request/response type definitions - Authentication token management **Example:** + ```typescript export const getProxyHosts = async (): Promise => { const response = await fetch('/api/v1/proxy-hosts', { @@ -402,11 +415,13 @@ export const getProxyHosts = async (): Promise => { ``` #### State Management + - **React Context:** Global state for auth, theme, language - **Local State:** Component-specific state with `useState` - **Custom Hooks:** Encapsulate API calls and side effects **Example Hook:** + ```typescript export const useProxyHosts = () => { const [hosts, setHosts] = useState([]); @@ -425,11 +440,13 @@ export const useProxyHosts = () => { **Purpose:** High-performance reverse proxy with automatic HTTPS **Integration:** + - Embedded as a library in the Go backend - Configured via JSON API (not Caddyfile) - Listens on ports 80 (HTTP) and 443 (HTTPS) **Features Used:** + - Dynamic configuration updates without restarts - Automatic HTTPS with Let's Encrypt and ZeroSSL - DNS challenge support for wildcard certificates @@ -437,6 +454,7 @@ export const useProxyHosts = () => { - Request logging and metrics **Configuration Flow:** + 1. User creates proxy host via frontend 2. Backend validates and saves to database 3. Caddy Manager generates JSON configuration @@ -461,12 +479,14 @@ For each proxy host, Charon generates **two routes** with the same domain: - Handlers: Full Cerberus security suite This pattern is **intentional and valid**: + - Emergency route provides break-glass access to security controls - Main route protects application with enterprise security features - Caddy processes routes in order (emergency matches first) - Validator allows duplicate hosts when one has paths and one doesn't **Example:** + ```json // Emergency Route (evaluated first) { @@ -488,6 +508,7 @@ This pattern is **intentional and valid**: **Purpose:** Persistent data storage **Why SQLite:** + - Embedded (no external database server) - Serverless (perfect for single-user/small team) - ACID compliant with WAL mode @@ -495,16 +516,19 @@ This pattern is **intentional and valid**: - Backup-friendly (single file) **Configuration:** + - **WAL Mode:** Allows concurrent reads during writes - **Foreign Keys:** Enforced referential integrity - **Pragma Settings:** Performance optimizations **Backup Strategy:** + - Automated daily backups to `data/backups/` - Retention: 7 daily, 4 weekly, 12 monthly backups - Backup during low-traffic periods **Migrations:** + - GORM AutoMigrate for schema changes - Manual migrations for complex data transformations - Rollback support via backup restoration @@ -537,6 +561,7 @@ graph LR **Purpose:** Prevent brute-force attacks and API abuse **Implementation:** + - Per-IP request counters with sliding window - Configurable thresholds (e.g., 100 req/min, 1000 req/hour) - HTTP 429 response when limit exceeded @@ -547,12 +572,14 @@ graph LR **Purpose:** Behavior-based threat detection **Features:** + - Local log analysis (brute-force, port scans, exploits) - Global threat intelligence (crowd-sourced IP reputation) - Automatic IP banning with configurable duration - Decision management API (view, create, delete bans) **Modes:** + - **Local Only:** No external API calls - **API Mode:** Sync with CrowdSec cloud for global intelligence @@ -561,12 +588,14 @@ graph LR **Purpose:** IP-based access control **Features:** + - Per-proxy-host allow/deny rules - CIDR range support (e.g., `192.168.1.0/24`) - Geographic blocking via GeoIP2 (MaxMind) - Admin whitelist (emergency access) **Evaluation Order:** + 1. Check admin whitelist (always allow) 2. Check deny list (explicit block) 3. Check allow list (explicit allow) @@ -579,6 +608,7 @@ graph LR **Engine:** Coraza with OWASP Core Rule Set (CRS) **Detection Categories:** + - SQL Injection (SQLi) - Cross-Site Scripting (XSS) - Remote Code Execution (RCE) @@ -587,12 +617,14 @@ graph LR - Command Injection **Modes:** + - **Monitor:** Log but don't block (testing) - **Block:** Return HTTP 403 for violations ### Layer 5: Application Security **Additional Protections:** + - **SSRF Prevention:** Block requests to private IP ranges in webhooks/URL validation - **HTTP Security Headers:** CSP, HSTS, X-Frame-Options, X-Content-Type-Options - **Input Validation:** Server-side validation for all user inputs @@ -610,6 +642,7 @@ graph LR 3. **Direct Database Access:** Manual SQLite update as last resort **Emergency Token:** + - 64-character hex token set via `CHARON_EMERGENCY_TOKEN` - Grants temporary admin access - Rotated after each use @@ -635,6 +668,7 @@ Charon operates with **two distinct traffic flows** on separate ports, each with - **Testing:** Playwright E2E tests verify UI/UX functionality on this port **Why No Middleware?** + - Management interface must remain accessible even when security modules are misconfigured - Emergency endpoints (`/api/v1/emergency/*`) require unrestricted access for system recovery - Separation of concerns: admin access control is handled by JWT, not proxy-level security @@ -797,6 +831,7 @@ sequenceDiagram **Rationale:** Simplicity over scalability - target audience is home users and small teams **Container Contents:** + - Frontend static files (Vite build output) - Go backend binary - Embedded Caddy server @@ -911,11 +946,13 @@ services: ### High Availability Considerations **Current Limitations:** + - SQLite does not support clustering - Single point of failure (one container) - Not designed for horizontal scaling **Future Options:** + - PostgreSQL backend for HA deployments - Read replicas for load balancing - Container orchestration (Kubernetes, Docker Swarm) @@ -927,6 +964,7 @@ services: ### Local Development Setup 1. **Prerequisites:** + ```bash - Go 1.26+ (backend development) - Node.js 23+ and npm (frontend development) @@ -935,12 +973,14 @@ services: ``` 2. **Clone Repository:** + ```bash git clone https://github.com/Wikid82/Charon.git cd Charon ``` 3. **Backend Development:** + ```bash cd backend go mod download @@ -949,6 +989,7 @@ services: ``` 4. **Frontend Development:** + ```bash cd frontend npm install @@ -957,6 +998,7 @@ services: ``` 5. **Full-Stack Development (Docker):** + ```bash docker-compose -f .docker/compose/docker-compose.dev.yml up # Frontend + Backend + Caddy in one container @@ -965,12 +1007,14 @@ services: ### Git Workflow **Branch Strategy:** + - `main`: Stable production branch - `feature/*`: New feature development - `fix/*`: Bug fixes - `chore/*`: Maintenance tasks **Commit Convention:** + - `feat:` New user-facing feature - `fix:` Bug fix in application code - `chore:` Infrastructure, CI/CD, dependencies @@ -979,6 +1023,7 @@ services: - `test:` Adding or updating tests **Example:** + ``` feat: add DNS-01 challenge support for Cloudflare @@ -1031,6 +1076,7 @@ Closes #123 **Purpose:** Validate critical user flows in a real browser **Scope:** + - User authentication - Proxy host CRUD operations - Certificate provisioning @@ -1038,6 +1084,7 @@ Closes #123 - Real-time log streaming **Execution:** + ```bash # Run against Docker container npx playwright test --project=chromium @@ -1050,10 +1097,12 @@ npx playwright test --debug ``` **Coverage Modes:** + - **Docker Mode:** Integration testing, no coverage (0% reported) - **Vite Dev Mode:** Coverage collection with V8 inspector **Why Two Modes?** + - Playwright coverage requires source maps and raw source files - Docker serves pre-built production files (no source maps) - Vite dev server exposes source files for coverage instrumentation @@ -1067,6 +1116,7 @@ npx playwright test --debug **Coverage Target:** 85% minimum **Execution:** + ```bash # Run all tests go test ./... @@ -1079,11 +1129,13 @@ go test -cover ./... ``` **Test Organization:** + - `*_test.go` files alongside source code - Table-driven tests for comprehensive coverage - Mocks for external dependencies (database, HTTP clients) **Example:** + ```go func TestCreateProxyHost(t *testing.T) { tests := []struct { @@ -1123,6 +1175,7 @@ func TestCreateProxyHost(t *testing.T) { **Coverage Target:** 85% minimum **Execution:** + ```bash # Run all tests npm test @@ -1135,6 +1188,7 @@ npm run test:coverage ``` **Test Organization:** + - `*.test.tsx` files alongside components - Mock API calls with MSW (Mock Service Worker) - Snapshot tests for UI consistency @@ -1146,12 +1200,14 @@ npm run test:coverage **Location:** `backend/integration/` **Scope:** + - API endpoint end-to-end flows - Database migrations - Caddy manager integration - CrowdSec API calls **Execution:** + ```bash go test ./integration/... ``` @@ -1161,6 +1217,7 @@ go test ./integration/... **Automated Hooks (via `.pre-commit-config.yaml`):** **Fast Stage (< 5 seconds):** + - Trailing whitespace removal - EOF fixer - YAML syntax check @@ -1168,11 +1225,13 @@ go test ./integration/... - Markdown link validation **Manual Stage (run explicitly):** + - Backend coverage tests (60-90s) - Frontend coverage tests (30-60s) - TypeScript type checking (10-20s) **Why Manual?** + - Coverage tests are slow and would block commits - Developers run them on-demand before pushing - CI enforces coverage on pull requests @@ -1180,10 +1239,12 @@ go test ./integration/... ### Continuous Integration (GitHub Actions) **Workflow Triggers:** + - `push` to `main`, `feature/*`, `fix/*` - `pull_request` to `main` **CI Jobs:** + 1. **Lint:** golangci-lint, ESLint, markdownlint, hadolint 2. **Test:** Go tests, Vitest, Playwright 3. **Security:** Trivy, CodeQL, Grype, Govulncheck @@ -1205,6 +1266,7 @@ go test ./integration/... - **PRERELEASE:** `-beta.1`, `-rc.1`, etc. **Examples:** + - `1.0.0` - Stable release - `1.1.0` - New feature (DNS provider support) - `1.1.1` - Bug fix (GORM query fix) @@ -1215,12 +1277,14 @@ go test ./integration/... ### Build Pipeline (Multi-Platform) **Platforms Supported:** + - `linux/amd64` - `linux/arm64` **Build Process:** 1. **Frontend Build:** + ```bash cd frontend npm ci --only=production @@ -1229,6 +1293,7 @@ go test ./integration/... ``` 2. **Backend Build:** + ```bash cd backend go build -o charon cmd/api/main.go @@ -1236,6 +1301,7 @@ go test ./integration/... ``` 3. **Docker Image Build:** + ```bash docker buildx build \ --platform linux/amd64,linux/arm64 \ @@ -1292,6 +1358,7 @@ go test ./integration/... - Level: SLSA Build L3 (hermetic builds) **Verification Example:** + ```bash # Verify image signature cosign verify \ @@ -1309,6 +1376,7 @@ grype ghcr.io/wikid82/charon@sha256: ### Rollback Strategy **Container Rollback:** + ```bash # List available versions docker images wikid82/charon @@ -1319,6 +1387,7 @@ docker-compose up -d --pull always wikid82/charon:1.1.1 ``` **Database Rollback:** + ```bash # Restore from backup docker exec charon /app/scripts/restore-backup.sh \ @@ -1355,11 +1424,13 @@ docker exec charon /app/scripts/restore-backup.sh \ ### API Extensibility **REST API Design:** + - Version prefix: `/api/v1/` - Future versions: `/api/v2/` (backward-compatible) - Deprecation policy: 2 major versions supported **WebHooks (Future):** + - Event notifications for external systems - Triggers: Proxy host created, certificate renewed, security event - Payload: JSON with event type and data @@ -1369,6 +1440,7 @@ docker exec charon /app/scripts/restore-backup.sh \ **Current:** Cerberus security middleware injected into Caddy pipeline **Future:** + - User-defined middleware (rate limiting rules, custom headers) - JavaScript/Lua scripting for request transformation - Plugin marketplace for community contributions @@ -1452,6 +1524,7 @@ docker exec charon /app/scripts/restore-backup.sh \ **GitHub Copilot Instructions:** All agents (`Planning`, `Backend_Dev`, `Frontend_Dev`, `DevOps`) must reference `ARCHITECTURE.md` when: + - Creating new components - Modifying core systems - Changing integration points diff --git a/CHANGELOG.md b/CHANGELOG.md index a78f0d11..780670df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,16 +30,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - See [Notification Guide](docs/features/notifications.md) for setup instructions ### CI/CD + - **Supply Chain**: Optimized verification workflow to prevent redundant builds - Change: Removed direct Push/PR triggers; now waits for 'Docker Build' via `workflow_run` ### Security + - **Supply Chain**: Enhanced PR verification workflow stability and accuracy - **Vulnerability Reporting**: Eliminated false negatives ("0 vulnerabilities") by enforcing strict failure conditions - **Tooling**: Switched to manual Grype installation ensuring usage of latest stable binary - **Observability**: Improved debugging visibility for vulnerability scans and SARIF generation ### Performance + - **E2E Tests**: Reduced feature flag API calls by 90% through conditional polling optimization (Phase 2) - Conditional skip: Exits immediately if flags already in expected state (~50% of cases) - Request coalescing: Shares in-flight API requests between parallel test workers @@ -51,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevents timeout errors in Firefox/WebKit caused by strict label matching ### Fixed + - **TCP Monitor Creation**: Fixed misleading form UX that caused silent HTTP 500 errors when creating TCP monitors - Corrected URL placeholder to show `host:port` format instead of the incorrect `tcp://host:port` prefix - Added dynamic per-type placeholder and helper text (HTTP monitors show a full URL example; TCP monitors show `host:port`) @@ -72,6 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Test Performance**: Reduced system settings test execution time by 31% (from 23 minutes to 16 minutes) ### Changed + - **Testing Infrastructure**: Enhanced E2E test helpers with better synchronization and error handling - **CI**: Optimized E2E workflow shards [Reduced from 4 to 3] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 963bd4d2..422b8534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,6 @@ brew install lefthook go install github.com/evilmartians/lefthook@latest ``` - - ```bash # Option 1: Homebrew (macOS/Linux) brew install golangci-lint @@ -84,17 +82,20 @@ For local development, install go 1.26.0+ from [go.dev/dl](https://go.dev/dl/). When the project's Go version is updated (usually by Renovate): 1. **Pull the latest changes** + ```bash git pull ``` 2. **Update your local Go installation** + ```bash # Run the Go update skill (downloads and installs the new version) .github/skills/scripts/skill-runner.sh utility-update-go-version ``` 3. **Rebuild your development tools** + ```bash # This fixes lefthook hook errors and IDE issues ./scripts/rebuild-go-tools.sh diff --git a/README.md b/README.md index 64f23ed8..776b95a6 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ services: retries: 3 start_period: 40s ``` + > **Docker Socket Access:** Charon runs as a non-root user. If you mount the Docker socket for container discovery, the container needs permission to read it. Find your socket's group ID and add it to the compose file: > > ```bash @@ -107,26 +108,34 @@ services: > - "998" > ``` -### 2️⃣ Generate encryption key: +### 2️⃣ Generate encryption key + ```bash openssl rand -base64 32 ``` -### 3️⃣ Start Charon: + +### 3️⃣ Start Charon + ```bash docker-compose up -d ``` -### 4️⃣ Access the dashboard: + +### 4️⃣ Access the dashboard + Open your browser and navigate to `http://localhost:8080` to access the dashboard and create your admin account. + ```code http://localhost:8080 ``` -### Getting Started: -Full setup instructions and documentation are available at [https://wikid82.github.io/Charon/docs/getting-started.html](https://wikid82.github.io/Charon/docs/getting-started.html). +### Getting Started + +Full setup instructions and documentation are available at [https://wikid82.github.io/Charon/docs/getting-started.html](https://wikid82.github.io/Charon/docs/getting-started.html). --- ## ✨ Top 10 Features ### 🎯 **Point & Click Management** + No config files. No terminal commands. Just click, type your domain name, and you're live. If you can use a website, you can run Charon. ### 🔐 **Automatic HTTPS Certificates** @@ -160,6 +169,7 @@ See exactly what's happening with live request logs, uptime monitoring, and inst ### 📥 **Migration Made Easy** Already invested in another reverse proxy? Bring your work with you by importing your existing configurations with one click: + - **Caddyfile** — Migrate from other Caddy setups - **Nginx** — Import from Nginx based configurations (Coming Soon) - **Traefik** - Import from Traefik based configurations (Coming Soon) diff --git a/SECURITY.md b/SECURITY.md index 51679df7..96c6c2bc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,16 +41,19 @@ container image. The binaries were compiled against Go 1.25.6, which contains th Charon's own application code, compiled with Go 1.26.1, is unaffected. **Who** + - Discovered by: Automated scan (Grype) - Reported: 2026-03-20 - Affects: CrowdSec Agent component within the container; not directly exposed through Charon's primary application interface **Where** + - Component: CrowdSec Agent (bundled `cscli` and `crowdsec` binaries) - Versions affected: Charon container images with CrowdSec binaries compiled against Go < 1.25.7 **When** + - Discovered: 2026-03-20 - Disclosed (if public): Not yet publicly disclosed - Target fix: When `golang:1.26.2-alpine` is published on Docker Hub @@ -82,16 +85,19 @@ configuration includes the `DEFAULT` keyword, potentially allowing downgrade to suites. Affects Alpine 3.23.3 packages `libcrypto3` and `libssl3` at version 3.5.5-r0. **Who** + - Discovered by: Automated scan (Grype) - Reported: 2026-03-20 - Affects: Container runtime environment; Caddy reverse proxy TLS negotiation could be affected if default key group configuration is used **Where** + - Component: Alpine 3.23.3 base image (`libcrypto3` 3.5.5-r0, `libssl3` 3.5.5-r0) - Versions affected: Alpine 3.23.3 prior to a patched `openssl` APK release **When** + - Discovered: 2026-03-20 - Disclosed (if public): 2026-03-13 (OpenSSL advisory) - Target fix: When Alpine Security publishes a patched `openssl` APK @@ -103,7 +109,7 @@ does not use the `DEFAULT` keyword, which limits practical exploitability. The p present in the base image regardless of Caddy's configuration. **Planned Remediation** -Monitor https://security.alpinelinux.org/vuln/CVE-2026-2673 for a patched Alpine APK. Once +Monitor for a patched Alpine APK. Once available, update the pinned `ALPINE_IMAGE` digest in the Dockerfile, or add an explicit `RUN apk upgrade --no-cache libcrypto3 libssl3` to the runtime stage. @@ -126,16 +132,19 @@ tracked separately above). All issues resolve when CrowdSec is rebuilt against G Charon's own application code is unaffected. **Who** + - Discovered by: Automated scan (Trivy, Grype) - Reported: 2025-12-01 (original cluster); expanded 2026-03-20 - Affects: CrowdSec Agent component within the container; not directly exposed through Charon's primary application interface **Where** + - Component: CrowdSec Agent (bundled `cscli` and `crowdsec` binaries) - Versions affected: All Charon versions shipping CrowdSec binaries compiled against Go < 1.26.2 **When** + - Discovered: 2025-12-01 - Disclosed (if public): Not yet publicly disclosed - Target fix: When `golang:1.26.2-alpine` is published on Docker Hub @@ -168,16 +177,19 @@ loop with no termination condition when given a specially crafted input, causing (CWE-1284). **Who** + - Discovered by: 7aSecurity audit (commissioned by OSTIF) - Reported: 2026-02-17 - Affects: Any component in the container that calls `crc32_combine`-family functions with attacker-controlled input; not directly exposed through Charon's application interface **Where** + - Component: Alpine 3.23.3 base image (`zlib` package, version 1.3.1-r2) - Versions affected: zlib < 1.3.2; all current Charon images using Alpine 3.23.3 **When** + - Discovered: 2026-02-17 (NVD published 2026-02-17) - Disclosed (if public): 2026-02-17 - Target fix: When Alpine 3.23 publishes a patched `zlib` APK (requires zlib 1.3.2) @@ -188,7 +200,7 @@ to the `crc32_combine`-family functions. This code path is not invoked by Charon or backend API. The vulnerability is non-blocking under the project's CI severity policy. **Planned Remediation** -Monitor https://security.alpinelinux.org/vuln/CVE-2026-27171 for a patched Alpine APK. Once +Monitor for a patched Alpine APK. Once available, update the pinned `ALPINE_IMAGE` digest in the Dockerfile, or add an explicit `RUN apk upgrade --no-cache zlib` to the runtime stage. Remove the `.trivyignore` entry at that time. @@ -211,14 +223,17 @@ Seven HIGH-severity CVEs in Debian Trixie base image system libraries (`glibc`, available from the Debian Security Team. **Who** + - Discovered by: Automated scan (Trivy) - Reported: 2026-02-04 **Where** + - Component: Debian Trixie base image (`libc6`, `libc-bin`, `libtasn1-6`, `libtiff`) - Versions affected: Charon container images built on Debian Trixie base (prior to Alpine migration) **When** + - Discovered: 2026-02-04 - Patched: 2026-03-20 - Time to patch: 44 days @@ -256,14 +271,17 @@ by CrowdSec for expression evaluation. Malicious regular expressions in CrowdSec parsers could cause CPU exhaustion and service degradation through exponential backtracking. **Who** + - Discovered by: Automated scan (Trivy) - Reported: 2026-01-11 **Where** + - Component: CrowdSec (via `expr-lang/expr` dependency) - Versions affected: CrowdSec versions using `expr-lang/expr` < v1.17.7 **When** + - Discovered: 2026-01-11 - Patched: 2026-01-11 - Time to patch: 0 days diff --git a/VERSION.md b/VERSION.md index 90129050..311c0601 100644 --- a/VERSION.md +++ b/VERSION.md @@ -24,8 +24,10 @@ Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2` 1. **Create and push a release tag**: ```bash + git tag -a v1.0.0 -m "Release v1.0.0" git push origin v1.0.0 + ``` 2. **GitHub Actions automatically**: @@ -51,10 +53,12 @@ Use it only when you need local/version-file parity checks: echo "1.0.0" > .version ``` -2. **Validate `.version` matches the latest tag**: +1. **Validate `.version` matches the latest tag**: ```bash + bash scripts/check-version-match-tag.sh + ``` ### Deterministic Rollout Verification Gates (Mandatory) diff --git a/docs/SECURITY_PRACTICES.md b/docs/SECURITY_PRACTICES.md index 69b44bb9..fc7961cc 100644 --- a/docs/SECURITY_PRACTICES.md +++ b/docs/SECURITY_PRACTICES.md @@ -53,6 +53,7 @@ logger.Infof("API Key: %s", apiKey) ``` Charon's masking rules: + - Empty: `[empty]` - Short (< 16 chars): `[REDACTED]` - Normal (≥ 16 chars): `abcd...xyz9` (first 4 + last 4) @@ -68,6 +69,7 @@ if !validateAPIKeyFormat(apiKey) { ``` Requirements: + - Length: 16-128 characters - Charset: Alphanumeric + underscore + hyphen - No spaces or special characters @@ -99,6 +101,7 @@ Rotate secrets regularly: ### What to Log ✅ **Safe to log**: + - Timestamps - User IDs (not usernames if PII) - IP addresses (consider GDPR implications) @@ -108,6 +111,7 @@ Rotate secrets regularly: - Performance metrics ❌ **Never log**: + - Passwords or password hashes - API keys or tokens (use masking) - Session IDs (full values) @@ -139,6 +143,7 @@ logger.Infof("Login attempt: username=%s password=%s", username, password) ### Log Aggregation If using external log services (CloudWatch, Splunk, Datadog): + - Ensure logs are encrypted in transit (TLS) - Ensure logs are encrypted at rest - Redact sensitive data before shipping @@ -333,6 +338,7 @@ limiter := rate.NewLimiter(rate.Every(36*time.Second), 100) ``` **Critical endpoints** (require stricter limits): + - Login: 5 attempts per 15 minutes - Password reset: 3 attempts per hour - API key generation: 5 per day @@ -369,6 +375,7 @@ return c.JSON(401, gin.H{"error": "invalid API key: abc123"}) **Applicable if**: Processing data of EU residents **Requirements**: + 1. **Data minimization**: Collect only necessary data 2. **Purpose limitation**: Use data only for stated purposes 3. **Storage limitation**: Delete data when no longer needed @@ -376,6 +383,7 @@ return c.JSON(401, gin.H{"error": "invalid API key: abc123"}) 5. **Breach notification**: Report breaches within 72 hours **Implementation**: + - ✅ Charon masks API keys in logs (prevents exposure of personal data) - ✅ Secure file permissions (0600) protect sensitive data - ✅ Log retention policies prevent indefinite storage @@ -390,12 +398,14 @@ return c.JSON(401, gin.H{"error": "invalid API key: abc123"}) **Applicable if**: Processing, storing, or transmitting credit card data **Requirements**: + 1. **Requirement 3.4**: Render PAN unreadable (encryption, masking) 2. **Requirement 8.2**: Strong authentication 3. **Requirement 10.2**: Audit trails 4. **Requirement 10.7**: Retain audit logs for 1 year **Implementation**: + - ✅ Charon uses masking for sensitive credentials (same principle for PAN) - ✅ Secure file permissions align with access control requirements - ⚠️ Charon doesn't handle payment cards directly (delegated to payment processors) @@ -409,12 +419,14 @@ return c.JSON(401, gin.H{"error": "invalid API key: abc123"}) **Applicable if**: SaaS providers, cloud services **Trust Service Criteria**: + 1. **CC6.1**: Logical access controls (authentication, authorization) 2. **CC6.6**: Encryption of data in transit 3. **CC6.7**: Encryption of data at rest 4. **CC7.2**: Monitoring and detection (logging, alerting) **Implementation**: + - ✅ API key validation ensures strong credentials (CC6.1) - ✅ File permissions (0600) protect data at rest (CC6.7) - ✅ Masked logging enables monitoring without exposing secrets (CC7.2) @@ -429,12 +441,14 @@ return c.JSON(401, gin.H{"error": "invalid API key: abc123"}) **Applicable to**: Any organization implementing ISMS **Key Controls**: + 1. **A.9.4.3**: Password management systems 2. **A.10.1.1**: Cryptographic controls 3. **A.12.4.1**: Event logging 4. **A.18.1.5**: Protection of personal data **Implementation**: + - ✅ API key format validation (minimum 16 chars, charset restrictions) - ✅ Key rotation procedures documented - ✅ Secure storage with file permissions (0600) @@ -491,6 +505,7 @@ grep -i "api[_-]key\|token\|password" playwright-report/index.html **Recommended schedule**: Annual or after major releases **Focus areas**: + 1. Authentication bypass 2. Authorization vulnerabilities 3. SQL injection diff --git a/docs/actions/trivy-scan-failure.md b/docs/actions/trivy-scan-failure.md index ec608bde..8a77fdea 100644 --- a/docs/actions/trivy-scan-failure.md +++ b/docs/actions/trivy-scan-failure.md @@ -1,6 +1,6 @@ **Status**: ✅ RESOLVED (January 30, 2026) -https://github.com/Wikid82/Charon/actions/runs/21503634925/job/61955008214 + Run # Normalize image name for reference 🔍 Extracting binary from: ghcr.io/wikid82/charon:feature/beta-release @@ -27,6 +27,7 @@ Add a check to ensure steps.pr-info.outputs.pr_number is set before constructing Suggested code improvement for the “Extract charon binary from container” step: YAML + - name: Extract charon binary from container if: steps.check-artifact.outputs.artifact_exists == 'true' id: extract @@ -44,6 +45,7 @@ YAML echo "🔍 Extracting binary from: ${IMAGE_REF}" ... This ensures the workflow does not attempt to use an invalid image tag when the PR number is missing. Adjust similar logic throughout the workflow to handle missing variables gracefully. + ## Resolution Fixed by adding proper validation for PR number before constructing Docker image reference, ensuring IMAGE_REF is never constructed with empty/missing variables. Branch name sanitization also implemented to handle slashes in feature branch names. diff --git a/docs/analysis/crowdsec_integration_failure_analysis.md b/docs/analysis/crowdsec_integration_failure_analysis.md index db28150c..ea054851 100644 --- a/docs/analysis/crowdsec_integration_failure_analysis.md +++ b/docs/analysis/crowdsec_integration_failure_analysis.md @@ -2,7 +2,7 @@ **Date:** 2026-01-28 **PR:** #550 - Alpine to Debian Trixie Migration -**CI Run:** https://github.com/Wikid82/Charon/actions/runs/21456678628/job/61799104804 +**CI Run:** **Branch:** feature/beta-release --- @@ -18,16 +18,19 @@ The CrowdSec integration tests are failing after migrating the Dockerfile from A ### 1. **CrowdSec Builder Stage Compatibility** **Alpine vs Debian Differences:** + - **Alpine** uses `musl libc`, **Debian** uses `glibc` - Different package managers: `apk` (Alpine) vs `apt` (Debian) - Different package names and availability **Current Dockerfile (lines 218-270):** + ```dockerfile FROM --platform=$BUILDPLATFORM golang:1.25.7-trixie AS crowdsec-builder ``` **Dependencies Installed:** + ```dockerfile RUN apt-get update && apt-get install -y --no-install-recommends \ git clang lld \ @@ -36,6 +39,7 @@ RUN xx-apt install -y gcc libc6-dev ``` **Possible Issues:** + - **Missing build dependencies**: CrowdSec might require additional packages on Debian that were implicitly available on Alpine - **Git clone failures**: Network issues or GitHub rate limiting - **Dependency resolution**: `go mod tidy` might behave differently @@ -44,6 +48,7 @@ RUN xx-apt install -y gcc libc6-dev ### 2. **CrowdSec Binary Path Issues** **Runtime Image (lines 359-365):** + ```dockerfile # Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.25.5+) COPY --from=crowdsec-builder /crowdsec-out/crowdsec /usr/local/bin/crowdsec @@ -52,17 +57,20 @@ COPY --from=crowdsec-builder /crowdsec-out/config /etc/crowdsec.dist ``` **Possible Issues:** + - If the builder stage fails, these COPY commands will fail - If fallback stage is used (for non-amd64), paths might be wrong ### 3. **CrowdSec Configuration Issues** **Entrypoint Script CrowdSec Init (docker-entrypoint.sh):** + - Symlink creation from `/etc/crowdsec` to `/app/data/crowdsec/config` - Configuration file generation and substitution - Hub index updates **Possible Issues:** + - Symlink already exists as directory instead of symlink - Permission issues with non-root user - Configuration templates missing or incompatible @@ -70,12 +78,14 @@ COPY --from=crowdsec-builder /crowdsec-out/config /etc/crowdsec.dist ### 4. **Test Script Environment Issues** **Integration Test (crowdsec_integration.sh):** + - Builds the image with `docker build -t charon:local .` - Starts container and waits for API - Tests CrowdSec Hub connectivity - Tests preset pull/apply functionality **Possible Issues:** + - Build step timing out or failing silently - Container failing to start properly - CrowdSec processes not starting @@ -88,6 +98,7 @@ COPY --from=crowdsec-builder /crowdsec-out/config /etc/crowdsec.dist ### Step 1: Check Build Logs Review the CI build logs for the CrowdSec builder stage: + - Look for `git clone` errors - Check for `go get` or `go mod tidy` failures - Verify `xx-go build` completes successfully @@ -96,6 +107,7 @@ Review the CI build logs for the CrowdSec builder stage: ### Step 2: Verify CrowdSec Binaries Check if CrowdSec binaries are actually present: + ```bash docker run --rm charon:local which crowdsec docker run --rm charon:local which cscli @@ -105,6 +117,7 @@ docker run --rm charon:local cscli version ### Step 3: Check CrowdSec Configuration Verify configuration is properly initialized: + ```bash docker run --rm charon:local ls -la /etc/crowdsec docker run --rm charon:local ls -la /app/data/crowdsec @@ -114,6 +127,7 @@ docker run --rm charon:local cat /etc/crowdsec/config.yaml ### Step 4: Test CrowdSec Locally Run the integration test locally: + ```bash # Build image docker build --no-cache -t charon:local . @@ -129,6 +143,7 @@ docker build --no-cache -t charon:local . ### Fix 1: Add Missing Build Dependencies If the build is failing due to missing dependencies, add them to the CrowdSec builder: + ```dockerfile RUN apt-get update && apt-get install -y --no-install-recommends \ git clang lld \ @@ -139,6 +154,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ### Fix 2: Add Build Stage Debugging Add debugging output to identify where the build fails: + ```dockerfile # After git clone RUN echo "CrowdSec source cloned successfully" && ls -la @@ -153,6 +169,7 @@ RUN echo "Build complete" && ls -la /crowdsec-out/ ### Fix 3: Use CrowdSec Fallback If the build continues to fail, ensure the fallback stage is working: + ```dockerfile # In final stage, use conditional COPY COPY --from=crowdsec-fallback /crowdsec-out/bin/crowdsec /usr/local/bin/crowdsec || \ @@ -162,6 +179,7 @@ COPY --from=crowdsec-builder /crowdsec-out/crowdsec /usr/local/bin/crowdsec ### Fix 4: Verify cscli Before Test Add a verification step in the entrypoint: + ```bash if ! command -v cscli >/dev/null; then echo "ERROR: CrowdSec not installed properly" diff --git a/docs/decisions/sprint1-timeout-remediation-findings.md b/docs/decisions/sprint1-timeout-remediation-findings.md index faebbe9f..6eeb84b8 100644 --- a/docs/decisions/sprint1-timeout-remediation-findings.md +++ b/docs/decisions/sprint1-timeout-remediation-findings.md @@ -11,11 +11,13 @@ **File**: `tests/settings/system-settings.spec.ts` **Changes Made**: + 1. **Removed** `waitForFeatureFlagPropagation()` call from `beforeEach` hook (lines 35-46) - This was causing 10s × 31 tests = 310s of polling overhead per shard - Commented out with clear explanation linking to remediation plan 2. **Added** `test.afterEach()` hook with direct API state restoration: + ```typescript test.afterEach(async ({ page }) => { await test.step('Restore default feature flag state', async () => { @@ -34,12 +36,14 @@ ``` **Rationale**: + - Tests already verify feature flag state individually after toggle actions - Initial state verification in beforeEach was redundant - Explicit cleanup in afterEach ensures test isolation without polling overhead - Direct API mutation for state restoration is faster than polling **Expected Impact**: + - 310s saved per shard (10s × 31 tests) - Elimination of inter-test dependencies - No state leakage between tests @@ -51,12 +55,14 @@ **Changes Made**: 1. **Added module-level cache** for in-flight requests: + ```typescript // Cache for in-flight requests (per-worker isolation) const inflightRequests = new Map>>(); ``` 2. **Implemented cache key generation** with sorted keys and worker isolation: + ```typescript function generateCacheKey( expectedFlags: Record, @@ -81,6 +87,7 @@ - Removes promise from cache after completion (success or failure) 4. **Added cleanup function**: + ```typescript export function clearFeatureFlagCache(): void { inflightRequests.clear(); @@ -89,16 +96,19 @@ ``` **Why Sorted Keys?** + - `{a:true, b:false}` vs `{b:false, a:true}` are semantically identical - Without sorting, they generate different cache keys → cache misses - Sorting ensures consistent key regardless of property order **Why Worker Isolation?** + - Playwright workers run in parallel across different browser contexts - Each worker needs its own cache to avoid state conflicts - Worker index provides unique namespace per parallel process **Expected Impact**: + - 30-40% reduction in duplicate API calls (revised from original 70-80% estimate) - Cache hit rate should be >30% based on similar flag state checks - Reduced API server load during parallel test execution @@ -108,21 +118,26 @@ **Status**: Partially Investigated **Issue**: + - Test: `tests/dns-provider-types.spec.ts` (line 260) - Symptom: Label locator `/script.*path/i` passes in Chromium, fails in Firefox/WebKit - Test code: + ```typescript const scriptField = page.getByLabel(/script.*path/i); await expect(scriptField).toBeVisible({ timeout: 10000 }); ``` **Investigation Steps Completed**: + 1. ✅ Confirmed E2E environment is running and healthy 2. ✅ Attempted to run DNS provider type tests in Chromium 3. ⏸️ Further investigation deferred due to test execution issues **Investigation Steps Remaining** (per spec): + 1. Run with Playwright Inspector to compare accessibility trees: + ```bash npx playwright test tests/dns-provider-types.spec.ts --project=chromium --headed --debug npx playwright test tests/dns-provider-types.spec.ts --project=firefox --headed --debug @@ -137,6 +152,7 @@ 5. If not fixable: Use the helper function approach from Phase 2 **Recommendation**: + - Complete investigation in separate session with headed browser mode - DO NOT add `.or()` chains unless investigation proves it's necessary - Create formal Decision Record once root cause is identified @@ -144,31 +160,37 @@ ## Validation Checkpoints ### Checkpoint 1: Execution Time + **Status**: ⏸️ In Progress **Target**: <15 minutes (900s) for full test suite **Command**: + ```bash time npx playwright test tests/settings/system-settings.spec.ts --project=chromium ``` **Results**: + - Test execution interrupted during validation - Observed: Tests were picking up multiple spec files from security/ folder - Need to investigate test file patterns or run with more specific filtering **Action Required**: + - Re-run with corrected test file path or filtering - Ensure only system-settings tests are executed - Measure execution time and compare to baseline ### Checkpoint 2: Test Isolation + **Status**: ⏳ Pending **Target**: All tests pass with `--repeat-each=5 --workers=4` **Command**: + ```bash npx playwright test tests/settings/system-settings.spec.ts --project=chromium --repeat-each=5 --workers=4 ``` @@ -176,11 +198,13 @@ npx playwright test tests/settings/system-settings.spec.ts --project=chromium -- **Status**: Not executed yet ### Checkpoint 3: Cross-browser + **Status**: ⏳ Pending **Target**: Firefox/WebKit pass rate >85% **Command**: + ```bash npx playwright test tests/settings/system-settings.spec.ts --project=firefox --project=webkit ``` @@ -188,11 +212,13 @@ npx playwright test tests/settings/system-settings.spec.ts --project=firefox --p **Status**: Not executed yet ### Checkpoint 4: DNS provider tests (secondary issue) + **Status**: ⏳ Pending **Target**: Firefox tests pass or investigation complete **Command**: + ```bash npx playwright test tests/dns-provider-types.spec.ts --project=firefox ``` @@ -204,11 +230,13 @@ npx playwright test tests/dns-provider-types.spec.ts --project=firefox ### Decision: Use Direct API Mutation for State Restoration **Context**: + - Tests need to restore default feature flag state after modifications - Original approach used polling-based verification in beforeEach - Alternative approaches: polling in afterEach vs direct API mutation **Options Evaluated**: + 1. **Polling in afterEach** - Verify state propagated after mutation - Pros: Confirms state is actually restored - Cons: Adds 500ms-2s per test (polling overhead) @@ -219,12 +247,14 @@ npx playwright test tests/dns-provider-types.spec.ts --project=firefox - Why chosen: Feature flag updates are synchronous in backend **Rationale**: + - Feature flag updates via PUT /api/v1/feature-flags are processed synchronously - Database write is immediate (SQLite WAL mode) - No async propagation delay in single-process test environment - Subsequent tests will verify state on first read, catching any issues **Impact**: + - Test runtime reduced by 15-60s per test file (31 tests × 500ms-2s polling) - Risk: If state restoration fails, next test will fail loudly (detectable) - Acceptable trade-off for 10-20% execution time improvement @@ -234,15 +264,18 @@ npx playwright test tests/dns-provider-types.spec.ts --project=firefox ### Decision: Cache Key Sorting for Semantic Equality **Context**: + - Multiple tests may check the same feature flag state but with different property order - Without normalization, `{a:true, b:false}` and `{b:false, a:true}` generate different keys **Rationale**: + - JavaScript objects have insertion order, but semantically these are identical states - Sorting keys ensures cache hits for semantically identical flag states - Minimal performance cost (~1ms for sorting 3-5 keys) **Impact**: + - Estimated 10-15% cache hit rate improvement - No downside - pure optimization diff --git a/docs/development/go_version_upgrades.md b/docs/development/go_version_upgrades.md index d3444c21..58a1da52 100644 --- a/docs/development/go_version_upgrades.md +++ b/docs/development/go_version_upgrades.md @@ -78,6 +78,7 @@ git pull origin development ``` This script: + - Detects the required Go version from `go.work` - Downloads it from golang.org - Installs it to `~/sdk/go{version}/` @@ -103,6 +104,7 @@ Even if you used Option A (which rebuilds automatically), you can always manuall ``` This rebuilds: + - **golangci-lint** — Pre-commit linter (critical) - **gopls** — IDE language server (critical) - **govulncheck** — Security scanner @@ -132,11 +134,13 @@ Current Go version: go version go1.26.0 linux/amd64 Your IDE caches the old Go language server (gopls). Reload to use the new one: **VS Code:** + - Press `Cmd/Ctrl+Shift+P` - Type "Developer: Reload Window" - Press Enter **GoLand or IntelliJ IDEA:** + - File → Invalidate Caches → Restart - Wait for indexing to complete @@ -243,6 +247,7 @@ go install golang.org/x/tools/gopls@latest ### How often do Go versions change? Go releases **two major versions per year**: + - February (e.g., Go 1.26.0) - August (e.g., Go 1.27.0) @@ -255,6 +260,7 @@ Plus occasional patch releases (e.g., Go 1.26.1) for security fixes. **Usually no**, but it doesn't hurt. Patch releases (like 1.26.0 → 1.26.1) rarely break tool compatibility. **Rebuild if:** + - Pre-commit hooks start failing - IDE shows unexpected errors - Tools report version mismatches @@ -262,6 +268,7 @@ Plus occasional patch releases (e.g., Go 1.26.1) for security fixes. ### Why don't CI builds have this problem? CI environments are **ephemeral** (temporary). Every workflow run: + 1. Starts with a fresh container 2. Installs Go from scratch 3. Installs tools from scratch @@ -295,12 +302,14 @@ But for Charon development, you only need **one version** (whatever's in `go.wor **Short answer:** Your local tools will be out of sync, but CI will still work. **What breaks:** + - Pre-commit hooks fail (but will auto-rebuild) - IDE shows phantom errors - Manual `go test` might fail locally - CI is unaffected (it always uses the correct version) **When to catch up:** + - Before opening a PR (CI checks will fail if your code uses old Go features) - When local development becomes annoying @@ -326,6 +335,7 @@ But they only take ~400MB each, so cleanup is optional. Renovate updates **Dockerfile** and **go.work**, but it can't update tools on *your* machine. **Think of it like this:** + - Renovate: "Hey team, we're now using Go 1.26.0" - Your machine: "Cool, but my tools are still Go 1.25.6. Let me rebuild them." @@ -334,18 +344,22 @@ The rebuild script bridges that gap. ### What's the difference between `go.work`, `go.mod`, and my system Go? **`go.work`** — Workspace file (multi-module projects like Charon) + - Specifies minimum Go version for the entire project - Used by Renovate to track upgrades **`go.mod`** — Module file (individual Go modules) + - Each module (backend, tools) has its own `go.mod` - Inherits Go version from `go.work` **System Go** (`go version`) — What's installed on your machine + - Must be >= the version in `go.work` - Tools are compiled with whatever version this is **Example:** + ``` go.work says: "Use Go 1.26.0 or newer" go.mod says: "I'm part of the workspace, use its Go version" @@ -364,12 +378,14 @@ Charon's pre-commit hook automatically detects and fixes tool version mismatches **How it works:** 1. **Check versions:** + ```bash golangci-lint version → "built with go1.25.6" go version → "go version go1.26.0" ``` 2. **Detect mismatch:** + ``` ⚠️ golangci-lint Go version mismatch: golangci-lint: 1.25.6 @@ -377,6 +393,7 @@ Charon's pre-commit hook automatically detects and fixes tool version mismatches ``` 3. **Auto-rebuild:** + ``` 🔧 Rebuilding golangci-lint with current Go version... ✅ golangci-lint rebuilt successfully @@ -406,11 +423,13 @@ If you want manual control, edit `scripts/pre-commit-hooks/golangci-lint-fast.sh ## Need Help? **Open a [Discussion](https://github.com/Wikid82/charon/discussions)** if: + - These instructions didn't work for you - You're seeing errors not covered in troubleshooting - You have suggestions for improving this guide **Open an [Issue](https://github.com/Wikid82/charon/issues)** if: + - The rebuild script crashes - Pre-commit auto-rebuild isn't working - CI is failing for Go version reasons diff --git a/docs/development/running-e2e.md b/docs/development/running-e2e.md index d599f546..a1d831a2 100644 --- a/docs/development/running-e2e.md +++ b/docs/development/running-e2e.md @@ -3,16 +3,20 @@ This document explains how to run Playwright tests using a real browser (headed) on Linux machines and in the project's Docker E2E environment. ## Key points + - Playwright's interactive Test UI (--ui) requires an X server (a display). On headless CI or servers, use Xvfb. - Prefer the project's E2E Docker image for integration-like runs; use the local `--ui` flow for manual debugging. ## Quick commands (local Linux) + - Headless (recommended for CI / fast runs): + ```bash npm run e2e ``` - Headed UI on a headless machine (auto-starts Xvfb): + ```bash npm run e2e:ui:headless-server # or, if you prefer manual control: @@ -20,37 +24,46 @@ This document explains how to run Playwright tests using a real browser (headed) ``` - Headed UI on a workstation with an X server already running: + ```bash npx playwright test --ui ``` - Open the running Docker E2E app in your system browser (one-step via VS Code task): - Run the VS Code task: **Open: App in System Browser (Docker E2E)** - - This will rebuild the E2E container (if needed), wait for http://localhost:8080 to respond, and open your system browser automatically. + - This will rebuild the E2E container (if needed), wait for to respond, and open your system browser automatically. - Open the running Docker E2E app in VS Code Simple Browser: - Run the VS Code task: **Open: App in Simple Browser (Docker E2E)** - Then use the command palette: `Simple Browser: Open URL` → paste `http://localhost:8080` ## Using the project's E2E Docker image (recommended for parity with CI) + 1. Rebuild/start the E2E container (this sets up the full test environment): + ```bash .github/skills/scripts/skill-runner.sh docker-rebuild-e2e ``` + If you need a clean rebuild after integration alignment changes: + ```bash .github/skills/scripts/skill-runner.sh docker-rebuild-e2e --clean --no-cache ``` -2. Run the UI against the container (you still need an X server on your host): + +1. Run the UI against the container (you still need an X server on your host): + ```bash PLAYWRIGHT_BASE_URL=http://localhost:8080 npm run e2e:ui:headless-server ``` ## CI guidance + - Do not run Playwright `--ui` in CI. Use headless runs or the E2E Docker image and collect traces/videos for failures. - For coverage, use the provided skill: `.github/skills/scripts/skill-runner.sh test-e2e-playwright-coverage` ## Troubleshooting + - Playwright error: "Looks like you launched a headed browser without having a XServer running." → run `npm run e2e:ui:headless-server` or install Xvfb. - If `npm run e2e:ui:headless-server` fails with an exit code like `148`: - Inspect Xvfb logs: `tail -n 200 /tmp/xvfb.playwright.log` @@ -59,11 +72,13 @@ This document explains how to run Playwright tests using a real browser (headed) - If running inside Docker, prefer the skill-runner which provisions the required services; the UI still needs host X (or use VNC). ## Developer notes (what we changed) + - Added `scripts/run-e2e-ui.sh` — wrapper that auto-starts Xvfb when DISPLAY is unset. - Added `npm run e2e:ui:headless-server` to run the Playwright UI on headless machines. - Playwright config now auto-starts Xvfb when `--ui` is requested locally and prints an actionable error if Xvfb is not available. ## Security & hygiene + - Playwright auth artifacts are ignored by git (`playwright/.auth/`). Do not commit credentials. --- diff --git a/docs/features/api.md b/docs/features/api.md index 089ab019..95a0c68c 100644 --- a/docs/features/api.md +++ b/docs/features/api.md @@ -23,6 +23,7 @@ Authorization: Bearer your-api-token-here ``` Tokens support granular permissions: + - **Read-only**: View configurations without modification - **Full access**: Complete CRUD operations - **Scoped**: Limit to specific resource types diff --git a/docs/features/caddyfile-import.md b/docs/features/caddyfile-import.md index 1d27562f..7e5cec26 100644 --- a/docs/features/caddyfile-import.md +++ b/docs/features/caddyfile-import.md @@ -52,6 +52,7 @@ Caddyfile import parses your existing Caddy configuration files and converts the Choose one of three methods: **Paste Content:** + ``` example.com { reverse_proxy localhost:3000 @@ -63,10 +64,12 @@ api.example.com { ``` **Upload File:** + - Click **Choose File** - Select your Caddyfile **Fetch from URL:** + - Enter URL to raw Caddyfile content - Useful for version-controlled configurations diff --git a/docs/features/dns-challenge.md b/docs/features/dns-challenge.md index bd696891..ba3bba18 100644 --- a/docs/features/dns-challenge.md +++ b/docs/features/dns-challenge.md @@ -447,6 +447,7 @@ Charon displays instructions to remove the TXT record after certificate issuance **Symptom**: Certificate request stuck at "Waiting for Propagation" or validation fails. **Causes**: + - DNS TTL is high (cached old records) - DNS provider has slow propagation - Regional DNS inconsistency @@ -497,6 +498,7 @@ Charon displays instructions to remove the TXT record after certificate issuance **Symptom**: Connection test passes, but record creation fails. **Causes**: + - API token has read-only permissions - Zone/domain not accessible with current credentials - Rate limiting or account restrictions @@ -513,6 +515,7 @@ Charon displays instructions to remove the TXT record after certificate issuance **Symptom**: "Record already exists" error during certificate request. **Causes**: + - Previous challenge attempt left orphaned record - Manual DNS record with same name exists - Another ACME client managing the same domain @@ -551,6 +554,7 @@ Charon displays instructions to remove the TXT record after certificate issuance **Symptom**: "Too many requests" or "Rate limit exceeded" errors. **Causes**: + - Too many certificate requests in short period - DNS provider API rate limits - Let's Encrypt rate limits diff --git a/docs/features/docker-integration.md b/docs/features/docker-integration.md index a0f892af..d5b9e343 100644 --- a/docs/features/docker-integration.md +++ b/docs/features/docker-integration.md @@ -47,6 +47,7 @@ Docker auto-discovery eliminates manual IP address hunting and port memorization For Charon to discover containers, it needs Docker API access. **Docker Compose:** + ```yaml services: charon: @@ -56,6 +57,7 @@ services: ``` **Docker Run:** + ```bash docker run -v /var/run/docker.sock:/var/run/docker.sock:ro charon ``` diff --git a/docs/features/plugin-security.md b/docs/features/plugin-security.md index 067e1907..a3b7b723 100644 --- a/docs/features/plugin-security.md +++ b/docs/features/plugin-security.md @@ -35,18 +35,21 @@ CHARON_PLUGIN_SIGNATURES='{"pluginname": "sha256:..."}' ### Examples **Permissive mode (default)**: + ```bash # Unset — all plugins load without verification unset CHARON_PLUGIN_SIGNATURES ``` **Strict block-all**: + ```bash # Empty object — no external plugins will load export CHARON_PLUGIN_SIGNATURES='{}' ``` **Allowlist specific plugins**: + ```bash # Only powerdns and custom-provider plugins are allowed export CHARON_PLUGIN_SIGNATURES='{"powerdns": "sha256:a1b2c3d4...", "custom-provider": "sha256:e5f6g7h8..."}' @@ -63,6 +66,7 @@ sha256sum myplugin.so | awk '{print "sha256:" $1}' ``` **Example output**: + ``` sha256:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2 ``` @@ -96,6 +100,7 @@ services: ``` This prevents runtime modification of plugin files, mitigating: + - Time-of-check to time-of-use (TOCTOU) attacks - Malicious plugin replacement after signature verification @@ -113,6 +118,7 @@ services: ``` Or in Dockerfile: + ```dockerfile FROM charon:latest USER charon @@ -128,6 +134,7 @@ Plugin directories must **not** be world-writable. Charon enforces this at start | `0777` (world-writable) | ❌ Rejected — plugin loading disabled | **Set secure permissions**: + ```bash chmod 755 /path/to/plugins chmod 644 /path/to/plugins/*.so # Or 755 for executable @@ -192,22 +199,26 @@ After updating plugins, always update your `CHARON_PLUGIN_SIGNATURES` with the n ### Checking if a Plugin Loaded **Check startup logs**: + ```bash docker compose logs charon | grep -i plugin ``` **Expected success output**: + ``` INFO Loaded DNS provider plugin type=powerdns name="PowerDNS" version="1.0.0" INFO Loaded 1 external DNS provider plugins (0 failed) ``` **If using allowlist**: + ``` INFO Plugin signature allowlist enabled with 2 entries ``` **Via API**: + ```bash curl http://localhost:8080/api/admin/plugins \ -H "Authorization: Bearer YOUR-TOKEN" @@ -220,6 +231,7 @@ curl http://localhost:8080/api/admin/plugins \ **Cause**: The plugin filename (without `.so`) is not in `CHARON_PLUGIN_SIGNATURES`. **Solution**: Add the plugin to your allowlist: + ```bash # Get the signature sha256sum powerdns.so | awk '{print "sha256:" $1}' @@ -233,6 +245,7 @@ export CHARON_PLUGIN_SIGNATURES='{"powerdns": "sha256:YOUR_HASH_HERE"}' **Cause**: The plugin file's SHA-256 hash doesn't match the allowlist. **Solution**: + 1. Verify you have the correct plugin file 2. Re-compute the signature: `sha256sum plugin.so` 3. Update `CHARON_PLUGIN_SIGNATURES` with the correct hash @@ -242,6 +255,7 @@ export CHARON_PLUGIN_SIGNATURES='{"powerdns": "sha256:YOUR_HASH_HERE"}' **Cause**: The plugin directory is world-writable (mode `0777` or similar). **Solution**: + ```bash chmod 755 /path/to/plugins chmod 644 /path/to/plugins/*.so @@ -252,11 +266,13 @@ chmod 644 /path/to/plugins/*.so **Cause**: Malformed JSON in the environment variable. **Solution**: Validate your JSON: + ```bash echo '{"powerdns": "sha256:abc123"}' | jq . ``` Common issues: + - Missing quotes around keys or values - Trailing commas - Single quotes instead of double quotes @@ -266,6 +282,7 @@ Common issues: **Cause**: File permissions too restrictive or ownership mismatch. **Solution**: + ```bash # Check current permissions ls -la /path/to/plugins/ @@ -278,27 +295,32 @@ chown charon:charon /path/to/plugins/*.so ### Debugging Checklist 1. **Is the plugin directory configured?** + ```bash echo $CHARON_PLUGINS_DIR ``` 2. **Does the plugin file exist?** + ```bash ls -la $CHARON_PLUGINS_DIR/*.so ``` 3. **Are directory permissions secure?** + ```bash stat -c "%a %n" $CHARON_PLUGINS_DIR # Should be 755 or stricter ``` 4. **Is the signature correct?** + ```bash sha256sum $CHARON_PLUGINS_DIR/myplugin.so ``` 5. **Is the JSON valid?** + ```bash echo "$CHARON_PLUGIN_SIGNATURES" | jq . ``` diff --git a/docs/features/proxy-headers.md b/docs/features/proxy-headers.md index a6c514cc..d77730fe 100644 --- a/docs/features/proxy-headers.md +++ b/docs/features/proxy-headers.md @@ -69,22 +69,26 @@ X-Forwarded-Host preserves the original domain: Your backend must trust proxy headers from Charon. Common configurations: **Node.js/Express:** + ```javascript app.set('trust proxy', true); ``` **Django:** + ```python SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') USE_X_FORWARDED_HOST = True ``` **Rails:** + ```ruby config.action_dispatch.trusted_proxies = [IPAddr.new('10.0.0.0/8')] ``` **PHP/Laravel:** + ```php // In TrustProxies middleware protected $proxies = '*'; diff --git a/docs/getting-started.md b/docs/getting-started.md index baf71292..88e69be2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -229,16 +229,19 @@ The emergency token is a security feature that allows bypassing all security mod Choose your platform: **Linux/macOS (recommended):** + ```bash openssl rand -hex 32 ``` **Windows PowerShell:** + ```powershell [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) ``` **Node.js (all platforms):** + ```bash node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` @@ -252,11 +255,13 @@ CHARON_EMERGENCY_TOKEN= ``` **Example:** + ```bash CHARON_EMERGENCY_TOKEN=7b3b8a36a6fad839f1b3122131ed4b1f05453118a91b53346482415796e740e2 ``` **Verify:** + ```bash # Token should be exactly 64 characters echo -n "$(grep CHARON_EMERGENCY_TOKEN .env | cut -d= -f2)" | wc -c @@ -287,20 +292,23 @@ For continuous integration, store the token in GitHub Secrets: ### Security Best Practices ✅ **DO:** + - Generate tokens using cryptographically secure methods - Store in `.env` (gitignored) or secrets management - Rotate quarterly or after security events - Use minimum 64 characters ❌ **DON'T:** + - Commit tokens to repository (even in examples) - Share tokens via email or chat - Use weak or predictable values - Reuse tokens across environments --- -2. **Settings table** for `security.crowdsec.enabled = "true"` -3. **Starts CrowdSec** if either condition is true + +1. **Settings table** for `security.crowdsec.enabled = "true"` +2. **Starts CrowdSec** if either condition is true **How it works:** @@ -582,7 +590,7 @@ Click "Watch" → "Custom" → Select "Security advisories" on the [Charon repos **2. Notifications and Automatic Updates with Dockhand** - - Dockhand is a free service that monitors Docker images for updates and can send notifications or trigger auto-updates. https://github.com/Finsys/dockhand +- Dockhand is a free service that monitors Docker images for updates and can send notifications or trigger auto-updates. **Best Practices:** diff --git a/docs/github-setup.md b/docs/github-setup.md index 9f211530..09265e0c 100644 --- a/docs/github-setup.md +++ b/docs/github-setup.md @@ -68,6 +68,7 @@ E2E tests require an emergency token to be configured in GitHub Secrets. This to ### Why This Is Needed The emergency token is used by E2E tests to: + - Disable security modules (ACL, WAF, CrowdSec) after testing them - Prevent cascading test failures due to leftover security state - Ensure tests can always access the API regardless of security configuration @@ -77,16 +78,19 @@ The emergency token is used by E2E tests to: 1. **Generate emergency token:** **Linux/macOS:** + ```bash openssl rand -hex 32 ``` **Windows PowerShell:** + ```powershell [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) ``` **Node.js (all platforms):** + ```bash node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` @@ -141,11 +145,13 @@ If the secret is missing or invalid, the workflow will fail with a clear error m ### Security Best Practices ✅ **DO:** + - Use cryptographically secure generation methods - Rotate quarterly or after security events - Store separately for local dev (`.env`) and CI/CD (GitHub Secrets) ❌ **DON'T:** + - Share tokens via email or chat - Commit tokens to repository (even in example files) - Reuse tokens across different environments @@ -154,11 +160,13 @@ If the secret is missing or invalid, the workflow will fail with a clear error m ### Troubleshooting **Error: "CHARON_EMERGENCY_TOKEN not set"** + - Check secret name is exactly `CHARON_EMERGENCY_TOKEN` (case-sensitive) - Verify secret is repository-level, not environment-level - Re-run workflow after adding secret **Error: "Token too short"** + - Hex method must generate exactly 64 characters - Verify you copied the entire token value - Regenerate if needed diff --git a/docs/guides/crowdsec-setup.md b/docs/guides/crowdsec-setup.md index c93b1b84..c6c889e8 100644 --- a/docs/guides/crowdsec-setup.md +++ b/docs/guides/crowdsec-setup.md @@ -88,6 +88,7 @@ In CrowdSec terms: > **✅ Good News: Charon Handles This For You!** > > When you enable CrowdSec for the first time, Charon automatically: +> > 1. Starts the CrowdSec engine > 2. Registers a bouncer and generates a valid API key > 3. Saves the key so it survives container restarts @@ -317,11 +318,13 @@ Replace `YOUR_ENROLLMENT_KEY` with the key from your Console. **Solution:** 1. Check if you're manually setting an API key: + ```bash grep -i "crowdsec_api_key" docker-compose.yml ``` 2. If you find one, **remove it**: + ```yaml # REMOVE this line: - CHARON_SECURITY_CROWDSEC_API_KEY=anything @@ -330,6 +333,7 @@ Replace `YOUR_ENROLLMENT_KEY` with the key from your Console. 3. Follow the [Manual Bouncer Registration](#manual-bouncer-registration) steps above 4. Restart the container: + ```bash docker restart charon ``` @@ -347,6 +351,7 @@ Replace `YOUR_ENROLLMENT_KEY` with the key from your Console. 1. Wait 60 seconds after container start 2. Check if CrowdSec is running: + ```bash docker exec charon cscli lapi status ``` @@ -354,6 +359,7 @@ Replace `YOUR_ENROLLMENT_KEY` with the key from your Console. 3. If you see "connection refused," try toggling CrowdSec OFF then ON in the GUI 4. Check the logs: + ```bash docker logs charon | grep -i crowdsec ``` @@ -431,6 +437,7 @@ If you already run CrowdSec separately (not inside Charon), you can connect to i **Steps:** 1. Register a bouncer on your external CrowdSec: + ```bash cscli bouncers add charon-bouncer ``` @@ -438,6 +445,7 @@ If you already run CrowdSec separately (not inside Charon), you can connect to i 2. Save the API key that's generated (you won't see it again!) 3. In your docker-compose.yml: + ```yaml environment: - CHARON_SECURITY_CROWDSEC_API_URL=http://your-crowdsec-server:8080 @@ -445,6 +453,7 @@ If you already run CrowdSec separately (not inside Charon), you can connect to i ``` 4. Restart Charon: + ```bash docker restart charon ``` diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index 5ca7e03f..4a5e166b 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -9,6 +9,7 @@ This directory contains operational maintenance guides for keeping Charon runnin **When to use:** Docker build fails with GeoLite2-Country.mmdb checksum mismatch **Topics covered:** + - Automated weekly checksum verification workflow - Manual checksum update procedures (5 minutes) - Verification script for checking upstream changes @@ -16,6 +17,7 @@ This directory contains operational maintenance guides for keeping Charon runnin - Alternative sources if upstream mirrors are unavailable **Quick fix:** + ```bash # Download and update checksum automatically NEW_CHECKSUM=$(curl -fsSL "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" | sha256sum | cut -d' ' -f1) @@ -34,6 +36,7 @@ Found a maintenance issue not covered here? Please: 3. **Update this index** with a link to your guide **Format:** + ```markdown ### [Guide Title](filename.md) diff --git a/docs/maintenance/geolite2-checksum-update.md b/docs/maintenance/geolite2-checksum-update.md index d319f171..b6758e9b 100644 --- a/docs/maintenance/geolite2-checksum-update.md +++ b/docs/maintenance/geolite2-checksum-update.md @@ -15,6 +15,7 @@ Charon uses the [MaxMind GeoLite2-Country database](https://dev.maxmind.com/geoi Update the checksum when: 1. **Docker build fails** with the following error: + ``` sha256sum: /app/data/geoip/GeoLite2-Country.mmdb: FAILED sha256sum: WARNING: 1 computed checksum did NOT match @@ -29,6 +30,7 @@ Update the checksum when: ## Automated Workflow (Recommended) Charon includes a GitHub Actions workflow that automatically: + - Checks for upstream GeoLite2 database changes weekly - Calculates the new checksum - Creates a pull request with the update @@ -39,6 +41,7 @@ Charon includes a GitHub Actions workflow that automatically: **Schedule:** Mondays at 2 AM UTC (weekly) **Manual Trigger:** + ```bash gh workflow run update-geolite2.yml ``` @@ -75,16 +78,19 @@ sha256sum /tmp/geolite2-test.mmdb **File:** [`Dockerfile`](../../Dockerfile) (line ~352) **Find this line:** + ```dockerfile ARG GEOLITE2_COUNTRY_SHA256= ``` **Replace with the new checksum:** + ```dockerfile ARG GEOLITE2_COUNTRY_SHA256=436135ee98a521da715a6d483951f3dbbd62557637f2d50d1987fc048874bd5d ``` **Using sed (automated):** + ```bash NEW_CHECKSUM=$(curl -fsSL "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" | sha256sum | cut -d' ' -f1) @@ -119,6 +125,7 @@ docker run --rm charon:test-checksum /app/charon --version ``` **Expected output:** + ``` ✅ GeoLite2-Country.mmdb: OK ✅ Successfully tagged charon:test-checksum @@ -171,11 +178,13 @@ fi ``` **Make executable:** + ```bash chmod +x scripts/verify-geolite2-checksum.sh ``` **Run verification:** + ```bash ./scripts/verify-geolite2-checksum.sh ``` @@ -187,22 +196,26 @@ chmod +x scripts/verify-geolite2-checksum.sh ### Issue: Build Still Fails After Update **Symptoms:** + - Checksum verification fails - "FAILED" error persists **Solutions:** 1. **Clear Docker build cache:** + ```bash docker builder prune -af ``` 2. **Verify the checksum was committed:** + ```bash git show HEAD:Dockerfile | grep "GEOLITE2_COUNTRY_SHA256" ``` 3. **Re-download and verify upstream file:** + ```bash curl -fsSL "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" -o /tmp/test.mmdb sha256sum /tmp/test.mmdb @@ -212,28 +225,31 @@ chmod +x scripts/verify-geolite2-checksum.sh ### Issue: Upstream File Unavailable (404) **Symptoms:** + - `curl` returns 404 Not Found - Automated workflow fails with `download_failed` error **Investigation Steps:** 1. **Check upstream repository:** - - Visit: https://github.com/P3TERX/GeoLite.mmdb + - Visit: - Verify the file still exists at the raw URL - Check for repository status or announcements 2. **Check MaxMind status:** - - Visit: https://status.maxmind.com/ + - Visit: - Check for service outages or maintenance **Temporary Solutions:** 1. **Use cached Docker layer** (if available): + ```bash docker build --cache-from ghcr.io/wikid82/charon:latest -t charon:latest . ``` 2. **Use local copy** (temporary): + ```bash # Download from a working container docker run --rm ghcr.io/wikid82/charon:latest cat /app/data/geoip/GeoLite2-Country.mmdb > /tmp/GeoLite2-Country.mmdb @@ -249,12 +265,14 @@ chmod +x scripts/verify-geolite2-checksum.sh ### Issue: Checksum Mismatch on Re-download **Symptoms:** + - Checksum calculated locally differs from what's in the Dockerfile - Checksum changes between downloads **Investigation Steps:** 1. **Verify file integrity:** + ```bash # Download multiple times and compare for i in {1..3}; do @@ -267,12 +285,14 @@ chmod +x scripts/verify-geolite2-checksum.sh - Try from different network locations 3. **Verify no MITM proxy:** + ```bash # Download via HTTPS and verify certificate curl -v -fsSL "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" -o /tmp/test.mmdb 2>&1 | grep "CN=" ``` **If confirmed as supply chain attack:** + - **STOP** and do not proceed - Report to security team - See [Security Incident Response](../security-incident-response.md) @@ -280,6 +300,7 @@ chmod +x scripts/verify-geolite2-checksum.sh ### Issue: Multi-Platform Build Fails (arm64) **Symptoms:** + - `linux/amd64` build succeeds - `linux/arm64` build fails with checksum error @@ -290,12 +311,14 @@ chmod +x scripts/verify-geolite2-checksum.sh - Should be identical across all platforms 2. **Check buildx platform emulation:** + ```bash docker buildx ls docker buildx inspect ``` 3. **Test arm64 build explicitly:** + ```bash docker buildx build --platform linux/arm64 --load -t test-arm64 . ``` @@ -308,8 +331,8 @@ chmod +x scripts/verify-geolite2-checksum.sh - **Implementation Plan:** [`docs/plans/current_spec.md`](../plans/current_spec.md) - **QA Report:** [`docs/reports/qa_report.md`](../reports/qa_report.md) - **Dockerfile:** [`Dockerfile`](../../Dockerfile) (line ~352) -- **MaxMind GeoLite2:** https://dev.maxmind.com/geoip/geolite2-free-geolocation-data -- **P3TERX Mirror:** https://github.com/P3TERX/GeoLite.mmdb +- **MaxMind GeoLite2:** +- **P3TERX Mirror:** --- @@ -321,9 +344,10 @@ chmod +x scripts/verify-geolite2-checksum.sh **Solution:** Updated one line in `Dockerfile` (line 352) with the correct checksum and implemented an automated workflow to prevent future occurrences. -**Build Failure URL:** https://github.com/Wikid82/Charon/actions/runs/21584236523/job/62188372617 +**Build Failure URL:** **Related PRs:** + - Fix implementation: (link to PR) - Automated workflow addition: (link to PR) diff --git a/docs/patches/e2e_workflow_timeout_fix.patch.md b/docs/patches/e2e_workflow_timeout_fix.patch.md index 1998f991..dda0bc2a 100644 --- a/docs/patches/e2e_workflow_timeout_fix.patch.md +++ b/docs/patches/e2e_workflow_timeout_fix.patch.md @@ -6,8 +6,9 @@ index efbcccda..64fcc121 100644 if: | ((inputs.browser || 'all') == 'chromium' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') -- timeout-minutes: 40 -+ timeout-minutes: 60 + +- timeout-minutes: 40 +- timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" @@ -15,42 +16,45 @@ index efbcccda..64fcc121 100644 npx playwright test \ --project=chromium \ -+ --output=playwright-output/security-chromium \ +- --output=playwright-output/security-chromium \ tests/security-enforcement/ \ tests/security/ \ tests/integration/multi-feature-workflows.spec.ts || STATUS=$? + @@ -370,6 +371,25 @@ jobs: path: test-results/**/*.zip retention-days: 7 -+ - name: Collect diagnostics -+ if: always() -+ run: | -+ mkdir -p diagnostics -+ uptime > diagnostics/uptime.txt -+ free -m > diagnostics/free-m.txt -+ df -h > diagnostics/df-h.txt -+ ps aux > diagnostics/ps-aux.txt -+ docker ps -a > diagnostics/docker-ps.txt || true -+ docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true -+ -+ - name: Upload diagnostics -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: e2e-diagnostics-chromium-security -+ path: diagnostics/ -+ retention-days: 7 -+ +- - name: Collect diagnostics +- if: always() +- run: | +- mkdir -p diagnostics +- uptime > diagnostics/uptime.txt +- free -m > diagnostics/free-m.txt +- df -h > diagnostics/df-h.txt +- ps aux > diagnostics/ps-aux.txt +- docker ps -a > diagnostics/docker-ps.txt || true +- docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true +- +- - name: Upload diagnostics +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: e2e-diagnostics-chromium-security +- path: diagnostics/ +- retention-days: 7 +- - name: Collect Docker logs on failure if: failure() run: | + @@ -394,7 +414,7 @@ jobs: if: | ((inputs.browser || 'all') == 'firefox' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') -- timeout-minutes: 40 -+ timeout-minutes: 60 + +- timeout-minutes: 40 +- timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" @@ -58,42 +62,45 @@ index efbcccda..64fcc121 100644 npx playwright test \ --project=firefox \ -+ --output=playwright-output/security-firefox \ +- --output=playwright-output/security-firefox \ tests/security-enforcement/ \ tests/security/ \ tests/integration/multi-feature-workflows.spec.ts || STATUS=$? + @@ -559,6 +580,25 @@ jobs: path: test-results/**/*.zip retention-days: 7 -+ - name: Collect diagnostics -+ if: always() -+ run: | -+ mkdir -p diagnostics -+ uptime > diagnostics/uptime.txt -+ free -m > diagnostics/free-m.txt -+ df -h > diagnostics/df-h.txt -+ ps aux > diagnostics/ps-aux.txt -+ docker ps -a > diagnostics/docker-ps.txt || true -+ docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true -+ -+ - name: Upload diagnostics -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: e2e-diagnostics-firefox-security -+ path: diagnostics/ -+ retention-days: 7 -+ +- - name: Collect diagnostics +- if: always() +- run: | +- mkdir -p diagnostics +- uptime > diagnostics/uptime.txt +- free -m > diagnostics/free-m.txt +- df -h > diagnostics/df-h.txt +- ps aux > diagnostics/ps-aux.txt +- docker ps -a > diagnostics/docker-ps.txt || true +- docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true +- +- - name: Upload diagnostics +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: e2e-diagnostics-firefox-security +- path: diagnostics/ +- retention-days: 7 +- - name: Collect Docker logs on failure if: failure() run: | + @@ -583,7 +623,7 @@ jobs: if: | ((inputs.browser || 'all') == 'webkit' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') -- timeout-minutes: 40 -+ timeout-minutes: 60 + +- timeout-minutes: 40 +- timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" @@ -101,42 +108,45 @@ index efbcccda..64fcc121 100644 npx playwright test \ --project=webkit \ -+ --output=playwright-output/security-webkit \ +- --output=playwright-output/security-webkit \ tests/security-enforcement/ \ tests/security/ \ tests/integration/multi-feature-workflows.spec.ts || STATUS=$? + @@ -748,6 +789,25 @@ jobs: path: test-results/**/*.zip retention-days: 7 -+ - name: Collect diagnostics -+ if: always() -+ run: | -+ mkdir -p diagnostics -+ uptime > diagnostics/uptime.txt -+ free -m > diagnostics/free-m.txt -+ df -h > diagnostics/df-h.txt -+ ps aux > diagnostics/ps-aux.txt -+ docker ps -a > diagnostics/docker-ps.txt || true -+ docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true -+ -+ - name: Upload diagnostics -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: e2e-diagnostics-webkit-security -+ path: diagnostics/ -+ retention-days: 7 -+ +- - name: Collect diagnostics +- if: always() +- run: | +- mkdir -p diagnostics +- uptime > diagnostics/uptime.txt +- free -m > diagnostics/free-m.txt +- df -h > diagnostics/df-h.txt +- ps aux > diagnostics/ps-aux.txt +- docker ps -a > diagnostics/docker-ps.txt || true +- docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true +- +- - name: Upload diagnostics +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: e2e-diagnostics-webkit-security +- path: diagnostics/ +- retention-days: 7 +- - name: Collect Docker logs on failure if: failure() run: | + @@ -779,7 +839,7 @@ jobs: if: | ((inputs.browser || 'all') == 'chromium' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') -- timeout-minutes: 30 -+ timeout-minutes: 60 + +- timeout-minutes: 30 +- timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" @@ -144,57 +154,61 @@ index efbcccda..64fcc121 100644 npx playwright test \ --project=chromium \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ -+ --output=playwright-output/chromium-shard-${{ matrix.shard }} \ +- --output=playwright-output/chromium-shard-${{ matrix.shard }} \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ + @@ -915,6 +976,14 @@ jobs: path: playwright-report/ retention-days: 14 -+ - name: Upload Playwright output (Chromium shard ${{ matrix.shard }}) -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: playwright-output-chromium-shard-${{ matrix.shard }} -+ path: playwright-output/chromium-shard-${{ matrix.shard }}/ -+ retention-days: 7 -+ +- - name: Upload Playwright output (Chromium shard ${{ matrix.shard }}) +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: playwright-output-chromium-shard-${{ matrix.shard }} +- path: playwright-output/chromium-shard-${{ matrix.shard }}/ +- retention-days: 7 +- - name: Upload Chromium coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + @@ -931,6 +1000,25 @@ jobs: path: test-results/**/*.zip retention-days: 7 -+ - name: Collect diagnostics -+ if: always() -+ run: | -+ mkdir -p diagnostics -+ uptime > diagnostics/uptime.txt -+ free -m > diagnostics/free-m.txt -+ df -h > diagnostics/df-h.txt -+ ps aux > diagnostics/ps-aux.txt -+ docker ps -a > diagnostics/docker-ps.txt || true -+ docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true -+ -+ - name: Upload diagnostics -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: e2e-diagnostics-chromium-shard-${{ matrix.shard }} -+ path: diagnostics/ -+ retention-days: 7 -+ +- - name: Collect diagnostics +- if: always() +- run: | +- mkdir -p diagnostics +- uptime > diagnostics/uptime.txt +- free -m > diagnostics/free-m.txt +- df -h > diagnostics/df-h.txt +- ps aux > diagnostics/ps-aux.txt +- docker ps -a > diagnostics/docker-ps.txt || true +- docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true +- +- - name: Upload diagnostics +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: e2e-diagnostics-chromium-shard-${{ matrix.shard }} +- path: diagnostics/ +- retention-days: 7 +- - name: Collect Docker logs on failure if: failure() run: | + @@ -955,7 +1043,7 @@ jobs: if: | ((inputs.browser || 'all') == 'firefox' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') -- timeout-minutes: 30 -+ timeout-minutes: 60 + +- timeout-minutes: 30 +- timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" @@ -202,57 +216,61 @@ index efbcccda..64fcc121 100644 npx playwright test \ --project=firefox \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ -+ --output=playwright-output/firefox-shard-${{ matrix.shard }} \ +- --output=playwright-output/firefox-shard-${{ matrix.shard }} \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ + @@ -1099,6 +1188,14 @@ jobs: path: playwright-report/ retention-days: 14 -+ - name: Upload Playwright output (Firefox shard ${{ matrix.shard }}) -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: playwright-output-firefox-shard-${{ matrix.shard }} -+ path: playwright-output/firefox-shard-${{ matrix.shard }}/ -+ retention-days: 7 -+ +- - name: Upload Playwright output (Firefox shard ${{ matrix.shard }}) +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: playwright-output-firefox-shard-${{ matrix.shard }} +- path: playwright-output/firefox-shard-${{ matrix.shard }}/ +- retention-days: 7 +- - name: Upload Firefox coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + @@ -1115,6 +1212,25 @@ jobs: path: test-results/**/*.zip retention-days: 7 -+ - name: Collect diagnostics -+ if: always() -+ run: | -+ mkdir -p diagnostics -+ uptime > diagnostics/uptime.txt -+ free -m > diagnostics/free-m.txt -+ df -h > diagnostics/df-h.txt -+ ps aux > diagnostics/ps-aux.txt -+ docker ps -a > diagnostics/docker-ps.txt || true -+ docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true -+ -+ - name: Upload diagnostics -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 -+ with: -+ name: e2e-diagnostics-firefox-shard-${{ matrix.shard }} -+ path: diagnostics/ -+ retention-days: 7 -+ +- - name: Collect diagnostics +- if: always() +- run: | +- mkdir -p diagnostics +- uptime > diagnostics/uptime.txt +- free -m > diagnostics/free-m.txt +- df -h > diagnostics/df-h.txt +- ps aux > diagnostics/ps-aux.txt +- docker ps -a > diagnostics/docker-ps.txt || true +- docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true +- +- - name: Upload diagnostics +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 +- with: +- name: e2e-diagnostics-firefox-shard-${{ matrix.shard }} +- path: diagnostics/ +- retention-days: 7 +- - name: Collect Docker logs on failure if: failure() run: | + @@ -1139,7 +1255,7 @@ jobs: if: | ((inputs.browser || 'all') == 'webkit' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') -- timeout-minutes: 30 -+ timeout-minutes: 60 + +- timeout-minutes: 30 +- timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" @@ -260,48 +278,50 @@ index efbcccda..64fcc121 100644 npx playwright test \ --project=webkit \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ -+ --output=playwright-output/webkit-shard-${{ matrix.shard }} \ +- --output=playwright-output/webkit-shard-${{ matrix.shard }} \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ + @@ -1283,6 +1400,14 @@ jobs: path: playwright-report/ retention-days: 14 -+ - name: Upload Playwright output (WebKit shard ${{ matrix.shard }}) -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 -+ with: -+ name: playwright-output-webkit-shard-${{ matrix.shard }} -+ path: playwright-output/webkit-shard-${{ matrix.shard }}/ -+ retention-days: 7 -+ +- - name: Upload Playwright output (WebKit shard ${{ matrix.shard }}) +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 +- with: +- name: playwright-output-webkit-shard-${{ matrix.shard }} +- path: playwright-output/webkit-shard-${{ matrix.shard }}/ +- retention-days: 7 +- - name: Upload WebKit coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + @@ -1299,6 +1424,25 @@ jobs: path: test-results/**/*.zip retention-days: 7 -+ - name: Collect diagnostics -+ if: always() -+ run: | -+ mkdir -p diagnostics -+ uptime > diagnostics/uptime.txt -+ free -m > diagnostics/free-m.txt -+ df -h > diagnostics/df-h.txt -+ ps aux > diagnostics/ps-aux.txt -+ docker ps -a > diagnostics/docker-ps.txt || true -+ docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true -+ -+ - name: Upload diagnostics -+ if: always() -+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 -+ with: -+ name: e2e-diagnostics-webkit-shard-${{ matrix.shard }} -+ path: diagnostics/ -+ retention-days: 7 -+ +- - name: Collect diagnostics +- if: always() +- run: | +- mkdir -p diagnostics +- uptime > diagnostics/uptime.txt +- free -m > diagnostics/free-m.txt +- df -h > diagnostics/df-h.txt +- ps aux > diagnostics/ps-aux.txt +- docker ps -a > diagnostics/docker-ps.txt || true +- docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true +- +- - name: Upload diagnostics +- if: always() +- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 +- with: +- name: e2e-diagnostics-webkit-shard-${{ matrix.shard }} +- path: diagnostics/ +- retention-days: 7 +- - name: Collect Docker logs on failure if: failure() run: | diff --git a/docs/performance/feature-flags-endpoint.md b/docs/performance/feature-flags-endpoint.md index f63a31ff..c61ef10f 100644 --- a/docs/performance/feature-flags-endpoint.md +++ b/docs/performance/feature-flags-endpoint.md @@ -31,6 +31,7 @@ for _, s := range settings { ``` **Key Improvements:** + - **Single Query:** `WHERE key IN (?, ?, ?)` fetches all flags in one database round-trip - **O(1) Lookups:** Map-based access eliminates linear search overhead - **Error Handling:** Explicit error logging and HTTP 500 response on failure @@ -56,6 +57,7 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { ``` **Key Improvements:** + - **Atomic Updates:** All flag changes commit or rollback together - **Error Recovery:** Transaction rollback prevents partial state - **Improved Logging:** Explicit error messages for debugging @@ -65,10 +67,12 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { ### Before Optimization (Baseline - N+1 Pattern) **Architecture:** + - GetFlags(): 3 sequential `WHERE key = ?` queries (one per flag) - UpdateFlags(): Multiple separate transactions **Measured Latency (Expected):** + - **GET P50:** 300ms (CI environment) - **GET P95:** 500ms - **GET P99:** 600ms @@ -77,20 +81,24 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { - **PUT P99:** 600ms **Query Count:** + - GET: 3 queries (N+1 pattern, N=3 flags) - PUT: 1-3 queries depending on flag count **CI Impact:** + - Test flakiness: ~30% failure rate due to timeouts - E2E test pass rate: ~70% ### After Optimization (Current - Batch Query + Transaction) **Architecture:** + - GetFlags(): 1 batch query `WHERE key IN (?, ?, ?)` - UpdateFlags(): 1 transaction wrapping all updates **Measured Latency (Target):** + - **GET P50:** 100ms (3x faster) - **GET P95:** 150ms (3.3x faster) - **GET P99:** 200ms (3x faster) @@ -99,10 +107,12 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { - **PUT P99:** 200ms (3x faster) **Query Count:** + - GET: 1 batch query (N+1 eliminated) - PUT: 1 transaction (atomic) **CI Impact (Expected):** + - Test flakiness: 0% (with retry logic + polling) - E2E test pass rate: 100% @@ -125,11 +135,13 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { **Status:** Complete **Changes:** + - Added `defer` timing to GetFlags() and UpdateFlags() - Log format: `[METRICS] GET/PUT /feature-flags: {duration}ms` - CI pipeline captures P50/P95/P99 metrics **Files Modified:** + - `backend/internal/api/handlers/feature_flags_handler.go` ### Phase 1: Backend Optimization - N+1 Query Fix @@ -139,16 +151,19 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { **Priority:** P0 - Critical CI Blocker **Changes:** + - **GetFlags():** Replaced N+1 loop with batch query `WHERE key IN (?)` - **UpdateFlags():** Wrapped updates in single transaction - **Tests:** Added batch query and transaction rollback tests - **Benchmarks:** Added BenchmarkGetFlags and BenchmarkUpdateFlags **Files Modified:** + - `backend/internal/api/handlers/feature_flags_handler.go` - `backend/internal/api/handlers/feature_flags_handler_test.go` **Expected Impact:** + - 3-6x latency reduction (600ms → 200ms P99) - Elimination of N+1 query anti-pattern - Atomic updates with rollback on error @@ -159,32 +174,38 @@ if err := h.DB.Transaction(func(tx *gorm.DB) error { ### Test Helpers Used **Polling Helper:** `waitForFeatureFlagPropagation()` + - Polls `/api/v1/feature-flags` until expected state confirmed - Default interval: 500ms - Default timeout: 30s (150x safety margin over 200ms P99) **Retry Helper:** `retryAction()` + - 3 max attempts with exponential backoff (2s, 4s, 8s) - Handles transient network/DB failures ### Timeout Strategy **Helper Defaults:** + - `clickAndWaitForResponse()`: 30s timeout - `waitForAPIResponse()`: 30s timeout - No explicit timeouts in test files (rely on helper defaults) **Typical Poll Count:** + - Local: 1-2 polls (50-200ms response + 500ms interval) - CI: 1-3 polls (50-200ms response + 500ms interval) ### Test Files **E2E Tests:** + - `tests/settings/system-settings.spec.ts` - Feature toggle tests - `tests/utils/wait-helpers.ts` - Polling and retry helpers **Backend Tests:** + - `backend/internal/api/handlers/feature_flags_handler_test.go` - `backend/internal/api/handlers/feature_flags_handler_coverage_test.go` @@ -205,11 +226,13 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Benchmark Analysis **GetFlags Benchmark:** + - Measures single batch query performance - Tests with 3 flags in database - Includes JSON serialization overhead **UpdateFlags Benchmark:** + - Measures transaction wrapping performance - Tests atomic update of 3 flags - Includes JSON deserialization and validation @@ -219,14 +242,17 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Why Batch Query Over Individual Queries? **Problem:** N+1 pattern causes linear latency scaling + - 3 flags = 3 queries × 200ms = 600ms total - 10 flags = 10 queries × 200ms = 2000ms total **Solution:** Single batch query with IN clause + - N flags = 1 query × 200ms = 200ms total - Constant time regardless of flag count **Trade-offs:** + - ✅ 3-6x latency reduction - ✅ Scales to more flags without performance degradation - ⚠️ Slightly more complex code (map-based lookup) @@ -234,14 +260,17 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Why Transaction Wrapping? **Problem:** Multiple separate writes risk partial state + - Flag 1 succeeds, Flag 2 fails → inconsistent state - No rollback mechanism for failed updates **Solution:** Single transaction for all updates + - All succeed together or all rollback - ACID guarantees for multi-flag updates **Trade-offs:** + - ✅ Atomic updates with rollback on error - ✅ Prevents partial state corruption - ⚠️ Slightly longer locks (mitigated by fast SQLite) @@ -253,11 +282,13 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ **Status:** Not implemented (not needed after Phase 1 optimization) **Rationale:** + - Current latency (50-200ms) is acceptable for feature flags - Feature flags change infrequently (not a hot path) - Adding cache increases complexity without significant benefit **If Needed:** + - Use Redis or in-memory cache with TTL=60s - Invalidate on PUT operations - Expected improvement: 50-200ms → 10-50ms @@ -267,11 +298,13 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ **Status:** SQLite default indexes sufficient **Rationale:** + - `settings.key` column used in WHERE clauses - SQLite automatically indexes primary key - Query plan analysis shows index usage **If Needed:** + - Add explicit index: `CREATE INDEX idx_settings_key ON settings(key)` - Expected improvement: Minimal (already fast) @@ -280,11 +313,13 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ **Status:** GORM default pooling sufficient **Rationale:** + - GORM uses `database/sql` pool by default - Current concurrency limits adequate - No connection exhaustion observed **If Needed:** + - Tune `SetMaxOpenConns()` and `SetMaxIdleConns()` - Expected improvement: 10-20% under high load @@ -293,12 +328,14 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Metrics to Track **Backend Metrics:** + - P50/P95/P99 latency for GET and PUT operations - Query count per request (should remain 1 for GET) - Transaction count per PUT (should remain 1) - Error rate (target: <0.1%) **E2E Metrics:** + - Test pass rate for feature toggle tests - Retry attempt frequency (target: <5%) - Polling iteration count (typical: 1-3) @@ -307,11 +344,13 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Alerting Thresholds **Backend Alerts:** + - P99 > 500ms → Investigate regression (2.5x slower than optimized) - Error rate > 1% → Check database health - Query count > 1 for GET → N+1 pattern reintroduced **E2E Alerts:** + - Test pass rate < 95% → Check for new flakiness - Timeout errors > 0 → Investigate CI environment - Retry rate > 10% → Investigate transient failure source @@ -319,10 +358,12 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Dashboard **CI Metrics:** + - Link: `.github/workflows/e2e-tests.yml` artifacts - Extracts `[METRICS]` logs for P50/P95/P99 analysis **Backend Logs:** + - Docker container logs with `[METRICS]` tag - Example: `[METRICS] GET /feature-flags: 120ms` @@ -331,15 +372,18 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### High Latency (P99 > 500ms) **Symptoms:** + - E2E tests timing out - Backend logs show latency spikes **Diagnosis:** + 1. Check query count: `grep "SELECT" backend/logs/query.log` 2. Verify batch query: Should see `WHERE key IN (...)` 3. Check transaction wrapping: Should see single `BEGIN ... COMMIT` **Remediation:** + - If N+1 pattern detected: Verify batch query implementation - If transaction missing: Verify transaction wrapping - If database locks: Check concurrent access patterns @@ -347,15 +391,18 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### Transaction Rollback Errors **Symptoms:** + - PUT requests return 500 errors - Backend logs show transaction failure **Diagnosis:** + 1. Check error message: `grep "Failed to update feature flags" backend/logs/app.log` 2. Verify database constraints: Unique key constraints, foreign keys 3. Check database connectivity: Connection pool exhaustion **Remediation:** + - If constraint violation: Fix invalid flag key or value - If connection issue: Tune connection pool settings - If deadlock: Analyze concurrent access patterns @@ -363,15 +410,18 @@ go test ./internal/api/handlers/ -bench=Benchmark.*Flags -benchmem -run=^$ ### E2E Test Flakiness **Symptoms:** + - Tests pass locally, fail in CI - Timeout errors in Playwright logs **Diagnosis:** + 1. Check backend latency: `grep "[METRICS]" ci-logs.txt` 2. Verify retry logic: Should see retry attempts in logs 3. Check polling behavior: Should see multiple GET requests **Remediation:** + - If backend slow: Investigate CI environment (disk I/O, CPU) - If no retries: Verify `retryAction()` wrapper in test - If no polling: Verify `waitForFeatureFlagPropagation()` usage diff --git a/docs/plans/rate_limit_ci_fix_spec.md b/docs/plans/rate_limit_ci_fix_spec.md index 13ba1215..4a740a85 100644 --- a/docs/plans/rate_limit_ci_fix_spec.md +++ b/docs/plans/rate_limit_ci_fix_spec.md @@ -11,6 +11,7 @@ ### Issue 1: `rate_limit` handler never appears in running Caddy config **Observed symptom** (from CI log): + ``` Attempt 10/10: rate_limit handler not found, waiting... ✗ rate_limit handler verification failed after 10 attempts @@ -22,6 +23,7 @@ Rate limit enforcement test FAILED #### Code path trace The `verify_rate_limit_config` function in `scripts/rate_limit_integration.sh` (lines ~35–58) executes: + ```bash caddy_config=$(curl -s http://localhost:2119/config 2>/dev/null || echo "") if echo "$caddy_config" | grep -q '"handler":"rate_limit"'; then @@ -48,6 +50,7 @@ The handler is absent from Caddy's running config because `ApplyConfig` in `back **Root cause A — silent failure of the security config POST step** (contributing): The security config POST step in the script discards stdout only; curl exits 0 for HTTP 4xx without -f flag, so auth failures are invisible: + ```bash # scripts/rate_limit_integration.sh, ~line 248 curl -s -X POST -H "Content-Type: application/json" \ @@ -55,9 +58,11 @@ curl -s -X POST -H "Content-Type: application/json" \ -b ${TMP_COOKIE} \ http://localhost:8280/api/v1/security/config >/dev/null ``` + No HTTP status check is performed. If this returns 4xx (e.g., `403 Forbidden` because the requesting user lacks the admin role, or `401 Unauthorized` because the cookie was not accepted), the config is never saved to DB, `ApplyConfig` is never called with the rate_limit values, and the handler is never injected. The route is protected by `middleware.RequireRole(models.RoleAdmin)` (routes.go:572–573): + ```go securityAdmin := management.Group("/security") securityAdmin.Use(middleware.RequireRole(models.RoleAdmin)) @@ -69,6 +74,7 @@ A non-admin authenticated user, or an unauthenticated request, returns `403` sil **Root cause B — warn-and-proceed instead of fail-hard** (amplifier): `verify_rate_limit_config` returns `1` on failure, but the calling site in the script treats the failure as non-fatal: + ```bash # scripts/rate_limit_integration.sh, ~line 269 if ! verify_rate_limit_config; then @@ -76,11 +82,13 @@ if ! verify_rate_limit_config; then echo "Proceeding with test anyway..." fi ``` + The enforcement test that follows is guaranteed to fail when the handler is absent (all requests pass through with HTTP 200, never hitting 429), yet the test proceeds unconditionally. The verification failure should be a hard exit. **Root cause C — no response code check for proxy host creation** (contributing): The proxy host creation at step 5 checks the status code (`201` vs other), but allows non-201 with a soft log message: + ```bash if [ "$CREATE_STATUS" = "201" ]; then echo "✓ Proxy host created successfully" @@ -88,11 +96,13 @@ else echo " Proxy host may already exist (status: $CREATE_STATUS)" fi ``` + If this returns `401` (auth failure), no proxy host is registered. Requests to `http://localhost:8180/get` with `Host: ratelimit.local` then hit Caddy's catch-all route returning HTTP 200 (the Charon frontend), not the backend. No 429 will ever appear regardless of rate limit configuration. **Root cause D — `ApplyConfig` failure is swallowed; Caddy not yet ready when config is posted** (primary): In `UpdateConfig` (`security_handler.go:289–292`): + ```go if h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { @@ -101,6 +111,7 @@ if h.caddyManager != nil { } c.JSON(http.StatusOK, gin.H{"config": payload}) ``` + If `ApplyConfig` fails (Caddy not yet fully initialized, config validation error), the error is logged as a warning but the HTTP response is still `200 OK`. The test script sees 200, assumes success, and proceeds. --- @@ -110,11 +121,13 @@ If `ApplyConfig` fails (Caddy not yet fully initialized, config validation error **Observed symptom**: During non-CI Docker builds, the GeoIP download step prints `⚠️ Checksum failed` and creates a `.placeholder` file, but the downloaded `.mmdb` is left on disk alongside the placeholder. **Code location**: `Dockerfile`, lines that contain: + ```dockerfile ARG GEOLITE2_COUNTRY_SHA256=aa154fc6bcd712644de232a4abcdd07dac1f801308c0b6f93dbc2b375443da7b ``` **Non-CI verification block** (Dockerfile, local build path): + ```dockerfile if [ -s /app/data/geoip/GeoLite2-Country.mmdb ] && \ echo "${GEOLITE2_COUNTRY_SHA256} /app/data/geoip/GeoLite2-Country.mmdb" | sha256sum -c -; then @@ -146,6 +159,7 @@ fi; **Required change**: Capture the HTTP status code from the login response. Fail fast if login returns non-200. Exact change — replace: + ```bash curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"ratelimit@example.local","password":"password123"}' \ @@ -156,6 +170,7 @@ echo "✓ Authentication complete" ``` With: + ```bash LOGIN_STATUS=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \ -d '{"email":"ratelimit@example.local","password":"password123"}' \ @@ -174,6 +189,7 @@ echo "✓ Authentication complete (HTTP $LOGIN_STATUS)" **Current behavior**: Non-201 responses are treated as "may already exist" and execution continues — including `401`/`403` auth failures. Required change — replace: + ```bash if [ "$CREATE_STATUS" = "201" ]; then echo "✓ Proxy host created successfully" @@ -183,6 +199,7 @@ fi ``` With: + ```bash if [ "$CREATE_STATUS" = "201" ]; then echo "✓ Proxy host created successfully" @@ -201,6 +218,7 @@ fi **Rationale**: Root Cause D is the primary driver of handler-not-found failures. If Caddy's admin API is not yet fully initialized when the security config is POSTed, `ApplyConfig` fails silently (logged as a warning only), the rate_limit handler is never injected into Caddy's running config, and the verification loop times out. The readiness gate ensures Caddy is accepting admin API requests before any config change is attempted. **Required change** — insert before the security config POST: + ```bash echo "Waiting for Caddy admin API to be ready..." for i in {1..20}; do @@ -224,6 +242,7 @@ done **Current behavior**: Response is discarded with `>/dev/null`. No status check. Required change — replace: + ```bash curl -s -X POST -H "Content-Type: application/json" \ -d "${SEC_CFG_PAYLOAD}" \ @@ -234,6 +253,7 @@ echo "✓ Rate limiting configured" ``` With: + ```bash SEC_CONFIG_RESP=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \ -d "${SEC_CFG_PAYLOAD}" \ @@ -258,6 +278,7 @@ echo "✓ Rate limiting configured (HTTP $SEC_CONFIG_STATUS)" **Current behavior**: Failed verification logs a warning and continues. Required change — replace: + ```bash echo "Waiting for Caddy to apply configuration..." sleep 5 @@ -270,6 +291,7 @@ fi ``` With: + ```bash echo "Waiting for Caddy to apply configuration..." sleep 8 @@ -307,6 +329,7 @@ local wait=5 # was: 3 #### Change 7 — Use trailing slash on Caddy admin API URL in `verify_rate_limit_config` **Location**: `verify_rate_limit_config`, line ~42: + ```bash caddy_config=$(curl -s http://localhost:2119/config 2>/dev/null || echo "") ``` @@ -314,11 +337,13 @@ caddy_config=$(curl -s http://localhost:2119/config 2>/dev/null || echo "") Caddy's admin API specification defines `GET /config/` (with trailing slash) as the canonical endpoint for the full running config. Omitting the slash works in practice because Caddy does not redirect, but using the canonical form is more correct and avoids any future behavioral change: Replace: + ```bash caddy_config=$(curl -s http://localhost:2119/config 2>/dev/null || echo "") ``` With: + ```bash caddy_config=$(curl -s http://localhost:2119/config/ 2>/dev/null || echo "") ``` @@ -377,6 +402,7 @@ fi **Important**: Do NOT remove the `ARG GEOLITE2_COUNTRY_SHA256` declaration from the Dockerfile. The `update-geolite2.yml` workflow uses `sed` to update that ARG. If the ARG disappears, the workflow's `sed` command will silently no-op and fail to update the Dockerfile on next run, leaving the stale hash in source while the workflow reports success. Keeping the ARG (even unused) preserves Renovate/workflow compatibility. Keep: + ```dockerfile ARG GEOLITE2_COUNTRY_SHA256=aa154fc6bcd712644de232a4abcdd07dac1f801308c0b6f93dbc2b375443da7b ``` @@ -402,6 +428,7 @@ This ARG is now only referenced by the `update-geolite2.yml` workflow (to know i ### Validating Issue 1 fix **Step 1 — Build and run the integration test locally:** + ```bash # From /projects/Charon chmod +x scripts/rate_limit_integration.sh @@ -409,6 +436,7 @@ scripts/rate_limit_integration.sh 2>&1 | tee /tmp/ratelimit-test.log ``` **Expected output sequence (key lines)**: + ``` ✓ Charon API is ready ✓ Authentication complete (HTTP 200) @@ -428,16 +456,20 @@ Sending request 3+1 (should return 429 Too Many Requests)... **Step 2 — Deliberately break auth to verify the new guard fires:** Temporarily change `password123` in the login curl to a wrong password. The test should now print: + ``` ✗ Login failed (HTTP 401) — aborting ``` + and exit with code 1, rather than proceeding to a confusing 429-enforcement failure. **Step 3 — Verify Caddy config contains the handler before enforcement:** + ```bash # After security config step and sleep 8: curl -s http://localhost:2119/config/ | python3 -m json.tool | grep -A2 '"handler": "rate_limit"' ``` + Expected: handler block with `"rate_limits"` sub-key containing `"static"` zone. **Step 4 — CI validation:** Push to a PR and observe the `Rate Limiting Integration` workflow. The workflow now exits at the first unmissable error rather than proceeding to a deceptive "enforcement test FAILED" message. @@ -445,21 +477,27 @@ Expected: handler block with `"rate_limits"` sub-key containing `"static"` zone. ### Validating Issue 2 fix **Step 1 — Local build without CI flag:** + ```bash docker build -t charon:geolip-test --build-arg CI=false . 2>&1 | grep -E "GeoIP|GeoLite|checksum|✅|⚠️" ``` + Expected: `✅ GeoIP downloaded` (no mention of checksum failure). **Step 2 — Verify file is present and readable:** + ```bash docker run --rm charon:geolip-test stat /app/data/geoip/GeoLite2-Country.mmdb ``` + Expected: file exists with non-zero size, no `.placeholder` alongside. **Step 3 — Confirm ARG still exists for workflow compatibility:** + ```bash grep "GEOLITE2_COUNTRY_SHA256" Dockerfile ``` + Expected: `ARG GEOLITE2_COUNTRY_SHA256=` line is present. --- diff --git a/docs/plans/telegram_implementation_spec.md b/docs/plans/telegram_implementation_spec.md index 57c12d69..e1cef77f 100644 --- a/docs/plans/telegram_implementation_spec.md +++ b/docs/plans/telegram_implementation_spec.md @@ -37,6 +37,7 @@ Content-Type: application/json ``` **Key design decisions:** + - **Token storage:** The bot token is stored in `NotificationProvider.Token` (`json:"-"`, encrypted at rest) — never in the URL field. This mirrors the Gotify pattern where secrets are separated from endpoints. - **URL field:** Stores only the `chat_id` (e.g., `987654321`). At dispatch time, the full API URL is constructed dynamically: `https://api.telegram.org/bot` + decryptedToken + `/sendMessage`. The `chat_id` is passed in the POST body alongside the message text. This prevents token leakage via API responses since URL is `json:"url"`. - **SSRF mitigation:** Before dispatching, validate that the constructed URL hostname is exactly `api.telegram.org`. This prevents SSRF if stored data is tampered with. @@ -475,6 +476,7 @@ Request/response schemas are unchanged. The `type` field now accepts `"telegram" Modeled after `tests/settings/email-notification-provider.spec.ts`. Test scenarios: + 1. Create a Telegram provider (name, chat_id in URL field, bot token in token field, enable events) 2. Verify provider appears in the list 3. Edit the Telegram provider (change name, verify token preservation) @@ -611,6 +613,7 @@ Add telegram to the payload matrix test scenarios. **Scope:** Feature flags, service layer, handler layer, all Go unit tests **Files changed:** + - `backend/internal/notifications/feature_flags.go` - `backend/internal/api/handlers/feature_flags_handler.go` - `backend/internal/notifications/router.go` @@ -624,6 +627,7 @@ Add telegram to the payload matrix test scenarios. **Dependencies:** None (self-contained backend change) **Validation gates:** + - `go test ./...` passes - `make lint-fast` passes - Coverage ≥ 85% @@ -636,6 +640,7 @@ Add telegram to the payload matrix test scenarios. **Scope:** Frontend API client, Notifications page, i18n strings, frontend unit tests, Playwright E2E tests **Files changed:** + - `frontend/src/api/notifications.ts` - `frontend/src/pages/Notifications.tsx` - `frontend/src/locales/en/translation.json` @@ -648,6 +653,7 @@ Add telegram to the payload matrix test scenarios. **Dependencies:** PR-1 must be merged first (backend must accept `type: "telegram"`) **Validation gates:** + - `npm test` passes - `npm run type-check` passes - `npx playwright test --project=firefox` passes diff --git a/docs/plans/telegram_remediation_spec.md b/docs/plans/telegram_remediation_spec.md index 12f1e701..7b4eeaf9 100644 --- a/docs/plans/telegram_remediation_spec.md +++ b/docs/plans/telegram_remediation_spec.md @@ -55,6 +55,7 @@ disabled={testMutation.isPending || (isNew && !isEmail)} **Why it was added:** The backend `Test` handler at `notification_provider_handler.go` (L333-336) requires a saved provider ID for all non-email types. For Gotify/Telegram, the server needs the stored token. For Discord/Webhook, the server still fetches the provider from DB. Without a saved provider, the backend returns `MISSING_PROVIDER_ID`. **Why it breaks tests:** Many existing E2E and unit tests click the test button from a **new (unsaved) provider form** using mocked endpoints. With the new guard: + 1. The `
` — Header Checkbox - -Insert a new `` as the **first column** in the `
- -` to `colSpan={7}`. - -#### 3.2.2 New Leftmost `` — Per-Row Checkbox - -For each row in the `sortedCertificates.map(...)`, insert a new `` as the first cell. -Three mutually exclusive cases: - -**Case A — `isDeletable && !isInUse` (enabled checkbox):** - -```tsx - - handleSelectRow(cert.id!)} - aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })} - /> - - - - - - - - - {t('certificates.deleteInUse')} - - -` (§3.2.1); update empty-state `colSpan` 6 → 7. -9. Insert leftmost `` for each row with the three cases A/B/C (§3.2.2). -10. Mount `` at the end of the fragment (§3.4). - -**Invariant**: `isDeletable` and `isInUse` exported function signatures must not change. -All pre-existing assertions in `CertificateList.test.tsx` must continue to pass. - -### Phase 4 — Unit Tests - -#### 4.1 New file: `BulkDeleteCertificateDialog.test.tsx` - -**File**: `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` - -Mock `react-i18next` using the `t: (key, opts?) => opts ? JSON.stringify(opts) : key` -pattern used throughout the test suite. - -| # | Test description | -|---|---| -| 1 | renders dialog with count in title when 3 certs supplied | -| 2 | lists each certificate name in the scrollable list | -| 3 | calls `onConfirm` when the Delete button is clicked | -| 4 | calls `onCancel` when the Cancel button is clicked | -| 5 | Delete button is loading/disabled when `isDeleting={true}` | -| 6 | returns null when `certificates` array is empty | - -#### 4.2 Additions to `CertificateList.test.tsx` - -Extend the existing `describe('CertificateList', ...)` in -`frontend/src/components/__tests__/CertificateList.test.tsx`. - -The existing fixture (`createCertificatesValue`) already supplies: -- `id: 1` custom expired, not in use → `isDeletable = true` → enabled checkbox -- `id: 2` letsencrypt-staging, not in use → `isDeletable = true` → enabled checkbox -- `id: 4` custom valid, not in use → `isDeletable = true` → enabled checkbox -- `id: 5` expired LE, not in use → `isDeletable = true` → enabled checkbox -- `id: 3` custom valid, in use (host has `certificate_id: 3`) → disabled checkbox -- `id: 6` valid LE, not in use → `isDeletable = false` → no checkbox - -| # | Test description | -|---|---| -| 1 | renders enabled checkboxes for ids 1, 2, 4, 5 (deletable, not in use) | -| 2 | renders disabled checkbox (with `aria-disabled`) for id 3 (in-use) | -| 3 | renders no checkbox in id 6's row (valid production LE) | -| 4 | selecting one cert makes the bulk action toolbar visible | -| 5 | header select-all selects only ids 1, 2, 4, 5 — not id 3 (in-use) | -| 6 | clicking the toolbar Delete button opens `BulkDeleteCertificateDialog` | -| 7 | confirming in the bulk dialog calls `deleteCertificate` for each selected ID | - -### Phase 5 — Playwright E2E Tests - -**File to create**: `tests/certificate-bulk-delete.spec.ts` - -Reuse `createCustomCertViaAPI` from `tests/certificate-delete.spec.ts`. Import shared -test helpers from: -- `tests/fixtures/auth-fixtures` — `test`, `expect`, `loginUser` -- `tests/utils/wait-helpers` — `waitForLoadingComplete`, `waitForDialog`, - `waitForAPIResponse` -- `tests/fixtures/test-data` — `generateUniqueId` -- `tests/constants` — `STORAGE_STATE` - -Seed three custom certs via `beforeAll`, clean up with `afterAll`. Each `test.beforeEach` -navigates to `/certificates` and calls `waitForLoadingComplete`. - -| # | Test scenario | -|---|---| -| 1 | **Checkbox column present**: checkboxes appear for each deletable cert | -| 2 | **No checkbox for valid LE**: valid production LE cert row has no checkbox | -| 3 | **Select one → toolbar appears**: checking one cert shows the count and Delete button | -| 4 | **Select-all**: header checkbox selects all three seeded certs; toolbar shows count 3 | -| 5 | **Dialog shows correct count**: opening bulk dialog shows "Delete 3 Certificate(s)" | -| 6 | **Cancel preserves certs**: cancelling the dialog leaves all three certs in the list | -| 7 | **Confirm deletes all selected**: confirming removes all selected certs from the table | - -The "confirm deletes" test awaits the success or failure toast appearance (toast appearance -confirms all requests have settled via `Promise.allSettled`) before asserting the cert -names are no longer visible in the table. - ---- - -## 5. Backend Considerations - -### 5.1 No New Endpoint - -The existing `DELETE /api/v1/certificates/:id` route at -`backend/internal/api/routes/routes.go:673` is the only backend touch point. Bulk deletion -is orchestrated entirely in the frontend using `Promise.allSettled`. This is intentional: - -- The volume of certificates eligible for bulk deletion is small in practice. -- Each deletion independently creates a server-side backup. A batch endpoint would need - N individual backups anyway, yielding no efficiency gain. -- Concurrent `Promise.allSettled` provides natural per-item error isolation — a 409 on - one cert (race: cert becomes in-use between checkbox selection and confirmation) surfaces - as a failed count in the toast rather than an unhandled rejection. - -### 5.2 No Backend Tests Required - -The handler tests added in the single-delete PR already cover: success, in-use 409, auth -guard, invalid ID, not-found, and backup-failure paths. Bulk deletion calls the same handler -N times with no new code paths. Nothing new at the backend layer warrants new tests. - ---- - -## 6. Security Considerations - -- Bulk delete inherits all security properties of the individual delete endpoint: - authentication required, in-use guard server-side, numeric ID validation in the handler. -- The client-side `isDeletable` check is a UX gate, not a security gate; the server is the - authoritative enforcer. -- `Promise.allSettled` does not short-circuit — a 409 on one cert becomes a failed count, - not an unhandled exception, preserving the remaining deletions. - ---- - -## 7. Commit Slicing Strategy - -**Single PR.** The entire feature — `BulkDeleteCertificateDialog`, i18n keys in 5 locales, -selection layer in `CertificateList.tsx`, unit tests, and E2E tests — is one cohesive -change. The diff is small (< 400 lines of production code across ~6 files) and all parts -are interdependent. Splitting would temporarily ship a broken feature mid-PR. - -Suggested commit title: -``` -feat(certificates): add bulk deletion with checkbox selection +"noteText": "You can delete custom certificates, staging certificates, and expired or expiring production certificates that are not attached to any proxy host. Active production certificates are automatically renewed by Caddy.", ``` --- -## 8. File Change Summary +## 3. Unit Test Updates -| File | Change | Description | -|------|--------|-------------| -| `frontend/src/components/CertificateList.tsx` | Modified | Selection state, checkbox column, bulk toolbar, bulk mutation | -| `frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx` | Created | New dialog for bulk confirmation | -| `frontend/src/locales/en/translation.json` | Modified | 10 new i18n keys under `certificates` | -| `frontend/src/locales/de/translation.json` | Modified | 10 new i18n keys (DE) | -| `frontend/src/locales/es/translation.json` | Modified | 10 new i18n keys (ES) | -| `frontend/src/locales/fr/translation.json` | Modified | 10 new i18n keys (FR) | -| `frontend/src/locales/zh/translation.json` | Modified | 10 new i18n keys (ZH) | -| `frontend/src/components/__tests__/CertificateList.test.tsx` | Modified | 7 new unit test cases | -| `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` | Created | 6 unit test cases for the new dialog | -| `tests/certificate-bulk-delete.spec.ts` | Created | E2E test suite (7 scenarios) | +### 3a. `frontend/src/components/__tests__/CertificateList.test.tsx` -**Dependencies**: None — the backend API is already complete. No database migrations. +**Existing test at line 152–155 — update assertion:** -**Validation Gates**: -- `npx vitest run` — all existing and new unit tests pass -- Playwright E2E on Firefox — all 7 new scenarios pass -- `npx tsc --noEmit` — 0 errors -- `make lint-fast` — 0 new warnings +The test currently documents the wrong behavior: + +```ts +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) // wrong — must become true +}) +``` + +**Changes:** +- Rename description to `'returns true for expiring LE cert not in use'` +- Change assertion to `toBe(true)` + +**Add** a new test case to guard the in-use path: +```ts +it('returns false for expiring LE cert in use', () => { + const cert: Certificate = { id: 8, name: 'ExpUsed', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' } + expect(isDeletable(cert, withHost(8))).toBe(false) +}) +``` + +### 3b. `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` + +The current `certs` fixture (lines 28–30) covers: `custom/valid`, `letsencrypt-staging/untrusted`, `letsencrypt/expired`. No `expiring` fixture exists. + +**Add** a standalone test verifying `providerLabel` renders the correct label for an expiring cert: +```ts +it('renders "Expiring LE" label for expiring letsencrypt certificate', () => { + render( + + ) + expect(screen.getByText('Expiring LE')).toBeInTheDocument() +}) +``` + +--- + +## 4. Backend Verification + +**No backend change required.** + +`DELETE /api/v1/certificates/:id` is handled by `CertificateHandler.Delete` in `backend/internal/api/handlers/certificate_handler.go` (line 140). The only guard is `IsCertificateInUse` (line 156). There is no status-based check — the handler invokes `service.DeleteCertificate(uint(id))` unconditionally once the in-use check passes. + +`CertificateService.DeleteCertificate` in `backend/internal/services/certificate_service.go` (line 396) likewise only inspects `IsCertificateInUse` before proceeding. The cert's `Status` field is never read or compared during deletion. + +A cert with `status = 'expiring'` that is not in use is deleted successfully by the backend today; the bug is frontend-only. + +--- + +## 5. E2E Consideration + +**File:** `tests/certificate-bulk-delete.spec.ts` + +The spec currently has no fixture that places a cert into `expiring` status, because status is computed server-side at query time from the actual expiry date. Manufacturing an `expiring` cert in Playwright requires inserting a certificate whose expiry falls within the renewal window (≈30 days out). + +**Recommended addition:** Add a test scenario that uploads a custom certificate with an expiry date 15 days from now and is not attached to any proxy host, then asserts: +1. Its row checkbox is enabled and its per-row delete button is present. +2. It can be selected and bulk-deleted via `BulkDeleteCertificateDialog`. + +If producing a near-expiry cert at the E2E layer is not feasible, coverage from §3a and §3b is sufficient for this fix and the E2E test may be deferred to a follow-up. + +--- + +## 6. Commit Slicing Strategy + +**Single PR.** This is a self-contained bug fix with no API contract changes and no migration. + +Suggested commit message: +``` +fix(certificates): allow deletion of expiring certs not in use + +Expiring Let's Encrypt certs not attached to any proxy host were +undeletable because isDeletable only permitted deletion when +status === 'expired'. Extends the condition to also allow +status === 'expiring', updates providerLabel and getWarningKey for +consistent UX, and corrects the existing unit test that was asserting +the wrong behavior. +``` + +**Files changed:** +- `frontend/src/components/CertificateList.tsx` +- `frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx` +- `frontend/src/components/dialogs/DeleteCertificateDialog.tsx` +- `frontend/src/locales/en/translation.json` +- `frontend/src/components/__tests__/CertificateList.test.tsx` +- `frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx` diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index e4802324..cd35e821 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -20,6 +20,7 @@ type SortColumn = 'name' | 'expires' type SortDirection = 'asc' | 'desc' export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean { + if (!cert.id) return false return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id) } @@ -29,7 +30,8 @@ export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean { return ( cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || - cert.status === 'expired' + cert.status === 'expired' || + cert.status === 'expiring' ) } @@ -234,7 +236,7 @@ export default function CertificateList() { 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') + const isInUseDeletableCategory = inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired' || cert.status === 'expiring') return (