Files
Charon/docs/plans/current_spec.md
GitHub Actions 8239a94938 chore: Add tests for CertificateList and CertificateUploadDialog components
- Implement test to deselect a row checkbox in CertificateList by clicking it a second time.
- Add test to close detail dialog via the close button in CertificateList.
- Add test to close export dialog via the cancel button in CertificateList.
- Add test to show KEY format badge when a .key file is uploaded in CertificateUploadDialog.
- Add test to ensure no format badge is shown for unknown file extensions in CertificateUploadDialog.
2026-04-15 11:35:10 +00:00

461 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Coverage Improvement Plan — Patch Coverage ≥ 90%
**Date**: 2026-05-02
**Status**: Draft — Awaiting Approval
**Priority**: High
**Archived Previous Plan**: Custom Certificate Upload & Management (Issue #22) → `docs/plans/archive/custom-cert-upload-management-spec-2026-05-02.md`
---
## 1. Introduction
This plan identifies exact uncovered branches across the six highest-gap backend source files and two frontend components, and specifies new test cases to close those gaps. The target is to raise overall patch coverage from **85.61% (206 missing lines)** to **≥ 90%**.
**Constraints**:
- No source file modifications — test files only
- Go tests placed in `*_patch_coverage_test.go` (same package as source)
- Frontend tests extend existing `__tests__/*.test.tsx` files
- Use testify (Go) and Vitest + React Testing Library (frontend)
---
## 2. Research Findings
### 2.1 Coverage Gap Summary
| Package | File | Missing Lines | Current Coverage |
|---|---|---|---|
| `handlers` | `certificate_handler.go` | ~54 | 70.28% |
| `services` | `certificate_service.go` | ~54 | 82.85% |
| `services` | `certificate_validator.go` | ~18 | 88.68% |
| `handlers` | `proxy_host_handler.go` | ~12 | 55.17% |
| `config` | `config.go` | ~8 | ~92% |
| `caddy` | `manager.go` | ~10 | ~88% |
| Frontend | `CertificateList.tsx` | moderate | — |
| Frontend | `CertificateUploadDialog.tsx` | moderate | — |
### 2.2 Test Infrastructure (Confirmed)
- **In-memory DB**: `gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})`
- **Mock auth**: `mockAuthMiddleware()` from `coverage_helpers_test.go`
- **Mock backup service**: `&mockBackupService{createFunc: ..., availableSpaceFunc: ...}`
- **Manager test hooks**: package-level `generateConfigFunc`, `validateConfigFunc`, `writeFileFunc` vars with `defer` restore pattern
- **Frontend mocks**: `vi.mock('../../hooks/...', ...)` and `vi.mock('react-i18next', ...)`
### 2.3 Existing Patch Test Files
| File | Existing Tests |
|---|---|
| `certificate_handler_patch_coverage_test.go` | `TestDelete_UUID_WithBackup_Success`, `_NotFound`, `_InUse` |
| `certificate_service_patch_coverage_test.go` | `TestExportCertificate_DER`, `_PFX`, `_P12`, `_UnsupportedFormat` |
| `certificate_validator_extra_coverage_test.go` | ECDSA/Ed25519 key match, `ConvertDERToPEM` valid/invalid |
| `manager_patch_coverage_test.go` | DNS provider encryption key paths |
| `proxy_host_handler_test.go` | Full CRUD + BulkUpdateACL + BulkUpdateSecurityHeaders |
| `proxy_host_handler_update_test.go` | Update edge cases, `ParseForwardPortField`, `ParseNullableUintField` |
---
## 3. Technical Specifications — Per-File Gap Analysis
### 3.1 `certificate_handler.go` — Export Re-Auth Path (~18 lines)
The `Export` handler re-authenticates the user when `include_key=true`. All six guard branches are uncovered.
**Gap location**: Lines ~260320 (password empty check, `user` context key extraction, `map[string]any` cast, `id` field lookup, DB user lookup, bcrypt check)
**New tests** (append to `certificate_handler_patch_coverage_test.go`):
| Test Name | Scenario | Expected |
|---|---|---|
| `TestExport_IncludeKey_MissingPassword` | POST with `include_key=true`, no `password` field | 403 |
| `TestExport_IncludeKey_NoUserContext` | No `"user"` key in gin context | 403 |
| `TestExport_IncludeKey_InvalidClaimsType` | `"user"` set to a plain string | 403 |
| `TestExport_IncludeKey_UserIDNotInClaims` | `user = map[string]any{}` with no `"id"` key | 403 |
| `TestExport_IncludeKey_UserNotFoundInDB` | Valid claims, no matching user row | 403 |
| `TestExport_IncludeKey_WrongPassword` | User in DB, wrong plaintext password submitted | 403 |
### 3.2 `certificate_handler.go` — Export Service Errors (~4 lines)
**Gap location**: After `ExportCertificate` call — ErrCertNotFound and generic error branches
| Test Name | Scenario | Expected |
|---|---|---|
| `TestExport_CertNotFound` | Unknown UUID | 404 |
| `TestExport_ServiceError` | Service returns non-not-found error | 500 |
### 3.3 `certificate_handler.go` — Delete Numeric-ID Error Paths (~12 lines)
**Gap location**: `IsCertificateInUse` error, disk space check, backup error, `DeleteCertificateByID` returning `ErrCertInUse` or generic error
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDelete_NumericID_UsageCheckError` | `IsCertificateInUse` returns error | 500 |
| `TestDelete_NumericID_LowDiskSpace` | `availableSpaceFunc` returns 0 | 507 |
| `TestDelete_NumericID_BackupError` | `createFunc` returns error | 500 |
| `TestDelete_NumericID_CertInUse_FromService` | `DeleteCertificateByID``ErrCertInUse` | 409 |
| `TestDelete_NumericID_DeleteError` | `DeleteCertificateByID` → generic error | 500 |
### 3.4 `certificate_handler.go` — Delete UUID Additional Error Paths (~8 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDelete_UUID_UsageCheckInternalError` | `IsCertificateInUseByUUID` returns non-ErrCertNotFound error | 500 |
| `TestDelete_UUID_LowDiskSpace` | `availableSpaceFunc` returns 0 | 507 |
| `TestDelete_UUID_BackupCreationError` | `createFunc` returns error | 500 |
| `TestDelete_UUID_CertInUse_FromService` | `DeleteCertificate``ErrCertInUse` | 409 |
### 3.5 `certificate_handler.go` — Upload/Validate File Open Errors (~8 lines)
**Gap location**: `file.Open()` calls on multipart key and chain form files returning errors
| Test Name | Scenario | Expected |
|---|---|---|
| `TestUpload_KeyFile_OpenError` | Valid cert file, malformed key multipart entry | 500 |
| `TestUpload_ChainFile_OpenError` | Valid cert+key, malformed chain multipart entry | 500 |
| `TestValidate_KeyFile_OpenError` | Valid cert, malformed key multipart entry | 500 |
| `TestValidate_ChainFile_OpenError` | Valid cert+key, malformed chain multipart entry | 500 |
### 3.6 `certificate_handler.go` — `sendDeleteNotification` Rate-Limit (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestSendDeleteNotification_RateLimit` | Call `sendDeleteNotification` twice within 10-second window | Second call is a no-op |
---
### 3.7 `certificate_service.go` — `SyncFromDisk` Branches (~14 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestSyncFromDisk_StagingToProductionUpgrade` | DB has staging cert, disk has production cert for same domain | DB cert updated to production provider |
| `TestSyncFromDisk_ExpiryOnlyUpdate` | Disk cert content matches DB cert, only expiry changed | Only `expires_at` column updated |
| `TestSyncFromDisk_CertRootStatPermissionError` | `os.Chmod(certRoot, 0)` before sync; add skip guard `if os.Getuid() == 0 { t.Skip("chmod permission test cannot run as root") }` | No panic; logs error; function completes |
### 3.8 `certificate_service.go` — `ListCertificates` Background Goroutine (~4 lines)
**Gap location**: `initialized=true` && TTL expired path → spawns background goroutine
| Test Name | Scenario | Expected |
|---|---|---|
| `TestListCertificates_StaleCache_TriggersBackgroundSync` | `initialized=true`, `lastScan` = 10 min ago | Returns cached list without blocking; background sync completes |
*Use `require.Eventually(t, func() bool { return svc.lastScan.After(before) }, 2*time.Second, 10*time.Millisecond, "background sync did not update lastScan")` after the call — avoids flaky fixed sleeps.*
### 3.9 `certificate_service.go` — `GetDecryptedPrivateKey` Nil encSvc and Decrypt Failure (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestGetDecryptedPrivateKey_NoEncSvc` | Service with `nil` encSvc, cert has non-empty `PrivateKeyEncrypted` | Returns error |
| `TestGetDecryptedPrivateKey_DecryptFails` | encSvc configured, corrupted ciphertext in DB | Returns wrapped error |
### 3.10 `certificate_service.go` — `MigratePrivateKeys` Branches (~6 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestMigratePrivateKeys_NoEncSvc` | `encSvc == nil` | Returns nil; logs warning |
| `TestMigratePrivateKeys_WithRows` | DB has cert with `private_key` populated, valid encSvc | Row migrated: `private_key` cleared, `private_key_enc` set |
### 3.11 `certificate_service.go` — `UpdateCertificate` Errors (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestUpdateCertificate_NotFound` | Non-existent UUID | Returns `ErrCertNotFound` |
| `TestUpdateCertificate_DBSaveError` | Valid UUID, DB closed before Save | Returns wrapped error |
### 3.12 `certificate_service.go` — `DeleteCertificate` ACME File Cleanup (~8 lines)
**Gap location**: `cert.Provider == "letsencrypt"` branch → Walk certRoot and remove `.crt`/`.key`/`.json` files
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDeleteCertificate_LetsEncryptProvider_FileCleanup` | Create temp `.crt` matching cert domain, delete cert | `.crt` removed from disk |
| `TestDeleteCertificate_StagingProvider_FileCleanup` | Provider = `"letsencrypt-staging"` | Same cleanup behavior triggered |
### 3.13 `certificate_service.go` — `CheckExpiringCertificates` (~8 lines)
**Implementation** (lines ~9661020): queries `provider = 'custom'` certs expiring before `threshold`, iterates and sends notification for certs with `daysLeft <= warningDays`.
| Test Name | Scenario | Expected |
|---|---|---|
| `TestCheckExpiringCertificates_ExpiresInRange` | Custom cert `expires_at = now+5d`, warningDays=30 | Returns slice with 1 cert |
| `TestCheckExpiringCertificates_AlreadyExpired` | Custom cert `expires_at = yesterday` | Result contains cert with negative days |
| `TestCheckExpiringCertificates_DBError` | DB closed before query | Returns error |
---
### 3.14 `certificate_validator.go` — `DetectFormat` Password-Protected PFX (~2 lines)
**Gap location**: PFX where `pkcs12.DecodeAll("")` fails but first byte is `0x30` (ASN.1 SEQUENCE), DER parse also fails → returns `FormatPFX`
**New file**: `certificate_validator_patch_coverage_test.go`
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDetectFormat_PasswordProtectedPFX` | Generate PFX with non-empty password, call `DetectFormat` | Returns `FormatPFX` |
### 3.15 `certificate_validator.go` — `parsePEMPrivateKey` Additional Block Types (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestParsePEMPrivateKey_PKCS1RSA` | PEM block type `"RSA PRIVATE KEY"` (x509.MarshalPKCS1PrivateKey) | Returns RSA key |
| `TestParsePEMPrivateKey_EC` | PEM block type `"EC PRIVATE KEY"` (x509.MarshalECPrivateKey) | Returns ECDSA key |
### 3.16 `certificate_validator.go` — `detectKeyType` P-384 and Unknown Curves (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDetectKeyType_ECDSAP384` | P-384 ECDSA key | Returns `"ECDSA-P384"` |
| `TestDetectKeyType_ECDSAUnknownCurve` | ECDSA key with custom/unknown curve (e.g. P-224) | Returns `"ECDSA"` |
### 3.17 `certificate_validator.go` — `ConvertPEMToPFX` Empty Chain (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestConvertPEMToPFX_EmptyChain` | Valid cert+key PEM, empty chain string | Returns PFX bytes without error |
### 3.18 `certificate_validator.go` — `ConvertPEMToDER` Non-Certificate Block (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestConvertPEMToDER_NonCertBlock` | PEM block type `"PRIVATE KEY"` | Returns nil data and error |
### 3.19 `certificate_validator.go` — `formatSerial` Nil BigInt (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestFormatSerial_Nil` | `formatSerial(nil)` | Returns `""` |
---
### 3.20 `proxy_host_handler.go` — `generateForwardHostWarnings` Private IP (~2 lines)
**Gap location**: `net.ParseIP(forwardHost) != nil && network.IsPrivateIP(ip)` branch (non-Docker private IP)
**New file**: `proxy_host_handler_patch_coverage_test.go`
| Test Name | Scenario | Expected |
|---|---|---|
| `TestGenerateForwardHostWarnings_PrivateIP` | forwardHost = `"192.168.1.100"` (RFC-1918, non-Docker) | Returns warning with field `"forward_host"` |
### 3.21 `proxy_host_handler.go` — `BulkUpdateSecurityHeaders` Edge Cases (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestBulkUpdateSecurityHeaders_AllFail_Rollback` | All UUIDs not found → `updated == 0` at end | 400, transaction rolled back |
| `TestBulkUpdateSecurityHeaders_ProfileDB_NonNotFoundError` | Profile lookup returns wrapped DB error | 500 |
---
### 3.22 Frontend: `CertificateList.tsx` — Untested Branches
**File**: `frontend/src/components/__tests__/CertificateList.test.tsx`
| Gap | New Test |
|---|---|
| `bulkDeleteMutation` success | `'calls bulkDeleteMutation.mutate with selected UUIDs on confirm'` |
| `bulkDeleteMutation` error | `'shows error toast on bulk delete failure'` |
| Sort direction toggle | `'toggles sort direction when same column clicked twice'` |
| `selectedIds` reconciliation | `'reconciles selectedIds when certificate list shrinks'` |
| Export dialog open | `'opens export dialog when export button clicked'` |
### 3.23 Frontend: `CertificateUploadDialog.tsx` — Untested Branches
**File**: `frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx`
| Gap | New Test |
|---|---|
| PFX hides key/chain zones | `'hides key and chain file inputs when PFX file selected'` |
| Upload success closes dialog | `'calls onOpenChange(false) on successful upload'` |
| Upload error shows toast | `'shows error toast when upload mutation fails'` |
| Validate result shown | `'displays validation result after validate clicked'` |
---
## 4. Implementation Plan
### Phase 1: Playwright Smoke Tests (Acceptance Gating)
Add smoke coverage to confirm certificate export and delete flows reach the backend.
**File**: `tests/certificate-coverage-smoke.spec.ts`
```typescript
import { test, expect } from '@playwright/test'
test.describe('Certificate Coverage Smoke', () => {
test('export dialog opens when export button clicked', async ({ page }) => {
await page.goto('/')
// navigate to Certificates, click export on a cert
// assert dialog visible
})
test('delete dialog opens for deletable certificate', async ({ page }) => {
await page.goto('/')
// assert delete confirmation dialog appears
})
})
```
### Phase 2: Backend — Handler Tests
**File**: `backend/internal/api/handlers/certificate_handler_patch_coverage_test.go`
**Action**: Append all tests from sections 3.13.6.
Setup pattern for handler tests:
```go
func setupCertHandlerTest(t *testing.T) (*gin.Engine, *CertificateHandler, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.User{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
certSvc := services.NewCertificateService(tmpDir, db, nil)
backup := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 1 << 30, nil },
createFunc: func(string) (string, error) { return "/tmp/backup.db", nil },
}
h := NewCertificateHandler(certSvc, backup, nil)
h.SetDB(db)
r := gin.New()
r.Use(mockAuthMiddleware())
h.RegisterRoutes(r.Group("/api"))
return r, h, db
}
```
For `TestExport_IncludeKey_*` tests: inject user into gin context directly using a custom middleware wrapper that sets `"user"` (type `map[string]any`, field `"id"`) to the desired value.
### Phase 3: Backend — Service Tests
**File**: `backend/internal/services/certificate_service_patch_coverage_test.go`
**Action**: Append all tests from sections 3.73.13.
Setup pattern:
```go
func newTestSvc(t *testing.T) (*CertificateService, *gorm.DB, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
return NewCertificateService(tmpDir, db, nil), db, tmpDir
}
```
For `TestMigratePrivateKeys_WithRows`: use `db.Exec("INSERT INTO ssl_certificates (..., private_key) VALUES (...)` raw SQL to bypass GORM's `gorm:"-"` tag.
### Phase 4: Backend — Validator Tests
**File**: `backend/internal/services/certificate_validator_patch_coverage_test.go` (new)
Key helpers needed:
```go
// generatePKCS1RSAKeyPEM returns an RSA key in PKCS#1 "RSA PRIVATE KEY" PEM format.
func generatePKCS1RSAKeyPEM(t *testing.T) []byte {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
}
// generateECKeyPEM returns an EC key in "EC PRIVATE KEY" (SEC1) PEM format.
func generateECKeyPEM(t *testing.T, curve elliptic.Curve) []byte {
key, err := ecdsa.GenerateKey(curve, rand.Reader)
require.NoError(t, err)
b, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
}
```
### Phase 5: Backend — Proxy Host Handler Tests
**File**: `backend/internal/api/handlers/proxy_host_handler_patch_coverage_test.go` (new)
Setup pattern mirrors existing `proxy_host_handler_test.go` — use in-memory SQLite, `mockAuthMiddleware`, and `mockCaddyManager` (already available via test hook vars).
### Phase 6: Frontend Tests
**Files**:
- `frontend/src/components/__tests__/CertificateList.test.tsx`
- `frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx`
Use existing mock structure; add new `it(...)` blocks inside existing `describe` blocks.
Frontend bulk delete success test pattern:
```typescript
it('calls bulkDeleteMutation.mutate with selected UUIDs on confirm', async () => {
const bulkDeleteFn = vi.fn()
mockUseBulkDeleteCertificates.mockReturnValue({
mutate: bulkDeleteFn,
isPending: false,
})
render(<CertificateList />)
// select checkboxes, click bulk delete, confirm dialog
expect(bulkDeleteFn).toHaveBeenCalledWith(['uuid-1', 'uuid-2'])
})
```
### Phase 7: Validation
1. `cd /projects/Charon && bash scripts/go-test-coverage.sh`
2. `cd /projects/Charon && bash scripts/frontend-test-coverage.sh`
3. `bash scripts/local-patch-report.sh` → verify `test-results/local-patch-report.md` shows ≥ 90%
4. `bash scripts/scan-gorm-security.sh --check` → zero CRITICAL/HIGH
---
## 5. Commit Slicing Strategy
**Decision**: One PR with 5 ordered, independently-reviewable commits.
**Rationale**: Four packages touched across two build systems (Go + Node). Atomic commits allow targeted revert if a mock approach proves brittle for a specific file, without rolling back unrelated coverage gains.
| # | Scope | Files | Dependencies | Validation Gate |
|---|---|---|---|---|
| **Commit 1** | Handler re-auth + delete + file-open errors | `certificate_handler_patch_coverage_test.go` (extend) | None | `go test ./backend/internal/api/handlers/...` |
| **Commit 2** | Service SyncFromDisk, ListCerts, GetDecryptedKey, Migrate, Update, Delete, CheckExpiring | `certificate_service_patch_coverage_test.go` (extend) | None | `go test ./backend/internal/services/...` |
| **Commit 3** | Validator DetectFormat, parsePEMPrivateKey, detectKeyType, ConvertPEMToPFX/DER, formatSerial | `certificate_validator_patch_coverage_test.go` (new) | Commit 2 not required (separate file) | `go test ./backend/internal/services/...` |
| **Commit 4** | Proxy host warnings + BulkUpdateSecurityHeaders edge cases | `proxy_host_handler_patch_coverage_test.go` (new) | None | `go test ./backend/internal/api/handlers/...` |
| **Commit 5** | Frontend CertificateList + CertificateUploadDialog | `CertificateList.test.tsx`, `CertificateUploadDialog.test.tsx` (extend) | None | `npm run test` |
**Rollback**: Any commit is safe to revert independently — all changes are additive test-only files.
**Contingency**: If the `Export` handler's re-auth tests require gin context injection that the current router wiring doesn't support cleanly, use a sub-router with a custom test middleware that pre-populates `"user"` (`map[string]any{"id": uint(1)}`) with the specific value under test, bypassing `mockAuthMiddleware` for those cases only.
---
## 6. Acceptance Criteria
- [ ] `go test -race ./backend/...` — all tests pass, no data races
- [ ] Backend patch coverage ≥ 90% for all modified Go files per `test-results/local-patch-report.md`
- [ ] `npm run test` — all Vitest tests pass
- [ ] Frontend patch coverage ≥ 90% for `CertificateList.tsx` and `CertificateUploadDialog.tsx`
- [ ] GORM security scan: zero CRITICAL/HIGH findings
- [ ] No new `//nolint` or `//nosec` directives introduced
- [ ] No source file modifications — test files only
- [ ] All new Go test names follow `TestFunctionName_Scenario` convention
- [ ] Previous spec archived to `docs/plans/archive/`
---
## 7. Estimated Coverage Impact
| File | Current | Estimated After | Lines Recovered |
|---|---|---|---|
| `certificate_handler.go` | 70.28% | ~85% | ~42 lines |
| `certificate_service.go` | 82.85% | ~92% | ~44 lines |
| `certificate_validator.go` | 88.68% | ~96% | ~18 lines |
| `proxy_host_handler.go` | 55.17% | ~60% | ~8 lines |
| `CertificateList.tsx` | moderate | high | ~15 lines |
| `CertificateUploadDialog.tsx` | moderate | high | ~12 lines |
| **Overall patch** | **85.61%** | **≥ 90%** | **~139 lines** |
> **Note**: Proxy host handler remains below 90% after this plan because the `Create`/`Update`/`Delete` handler paths require full Caddy manager mock integration. A follow-up plan should address these with a dedicated `mockCaddyManager` interface.