test + docs: close 12 test gaps (~250 new tests) and expand testing guide to 34 parts

Implements all P0-P2 test gaps from docs/test-gap-prompt.md:
- Deployment service tests (20), target service tests (18), scheduler tests (8)
- Agent binary tests (48), CSR renewal tests (8), short-lived cert tests (7)
- Domain model tests (25), context cancellation tests (9), concurrency tests (7)
- Handler negative-path tests (23 across 5 files)
- Frontend error handling tests (86) and API client tests (7)

Expands testing-guide.md from 28 to 34 parts covering certificate export,
S/MIME/EKU, OCSP/DER CRL, body size limits, Apache/HAProxy connectors,
and sub-CA mode. Fixes stale profile count (4->5) and updates sign-off table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-28 17:57:25 -04:00
parent 63e6f3ef91
commit 03472072b8
30 changed files with 7422 additions and 23 deletions
+480
View File
@@ -0,0 +1,480 @@
# certctl Test Gap Attack Prompt
**Purpose:** Self-contained prompt for a future Claude session to systematically close all identified test gaps. Copy this entire document into a new session along with CLAUDE.md.
**Estimated effort:** 250-350 new test functions across 12-15 new/modified test files.
---
## Context
You are working on certctl, a self-hosted certificate lifecycle platform. The project has ~1100 tests but a comprehensive audit identified 12 gaps across 4 priority tiers. Your job is to close ALL of them in order (P0 first, then P1, then P2). After each file you create or modify, run the specific test file to verify it passes, then run `go vet ./...` to catch issues early.
**Key conventions:**
- Package-level tests (e.g., `package service` not `package service_test`) so you can access unexported fields
- Mock repositories use function-field injection pattern (see `internal/service/testutil_test.go` for all mocks)
- Mocks available: `mockCertRepo`, `mockJobRepo`, `mockNotifRepo`, `mockAuditRepo`, `mockPolicyRepo`, `mockRenewalPolicyRepo`, `mockAgentRepo`, `mockTargetRepo`, `mockIssuerConnector`, `mockIssuerRepository`, `mockRevocationRepo`, `mockNotifier`
- Constructor helpers: `newMockCertificateRepository()`, `newMockJobRepository()`, etc.
- Test naming: `TestServiceName_MethodName_Scenario` (e.g., `TestDeploymentService_CreateDeploymentJobs_Success`)
- All tests use `context.Background()` unless testing cancellation
- The `generateID(prefix)` function exists in the service package for creating IDs
---
## P0-1: `internal/service/deployment_test.go` (NEW FILE)
**File to test:** `internal/service/deployment.go`
Create `internal/service/deployment_test.go` in `package service`.
### DeploymentService struct dependencies:
```go
type DeploymentService struct {
jobRepo repository.JobRepository // mockJobRepo
targetRepo repository.TargetRepository // mockTargetRepo
agentRepo repository.AgentRepository // mockAgentRepo
certRepo repository.CertificateRepository // mockCertRepo
auditService *AuditService // real AuditService with mockAuditRepo
notificationSvc *NotificationService // real NotificationService with mockNotifRepo + mockNotifier
}
```
### Setup helper:
```go
func newTestDeploymentService() (*DeploymentService, *mockJobRepo, *mockTargetRepo, *mockAgentRepo, *mockCertRepo, *mockAuditRepo) {
jobRepo := newMockJobRepository()
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
agentRepo := newMockAgentRepository()
certRepo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
notifRepo := newMockNotificationRepository()
notifier := newMockNotifier()
notifSvc := NewNotificationService(notifRepo, auditSvc)
notifSvc.RegisterNotifier(notifier)
svc := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditSvc, notifSvc)
return svc, jobRepo, targetRepo, agentRepo, certRepo, auditRepo
}
```
### Required tests (~20 functions):
**CreateDeploymentJobs:**
1. `TestDeploymentService_CreateDeploymentJobs_Success` — 2 targets for cert, verify 2 jobs created with correct CertificateID, Type=Deployment, Status=Pending, TargetID set
2. `TestDeploymentService_CreateDeploymentJobs_NoTargets` — empty targets list, expect error "no targets found"
3. `TestDeploymentService_CreateDeploymentJobs_TargetListError` — targetRepo.ListByCertErr set, expect wrapped error
4. `TestDeploymentService_CreateDeploymentJobs_AllJobCreationsFail` — jobRepo.CreateErr set, expect error "failed to create any deployment jobs"
5. `TestDeploymentService_CreateDeploymentJobs_PartialFailure` — first job create fails (use a counter-based mock or accept that current mock fails all), verify at least error handling
6. `TestDeploymentService_CreateDeploymentJobs_AuditEvent` — verify auditRepo.Events contains "deployment_jobs_created" event with target_count and job_count
**ProcessDeploymentJob:**
7. `TestDeploymentService_ProcessDeploymentJob_Success` — job with TargetID, target has AgentID, agent has recent heartbeat. Verify job status updated to Running, audit event recorded
8. `TestDeploymentService_ProcessDeploymentJob_CertNotFound` — certRepo.GetErr set, verify job marked Failed
9. `TestDeploymentService_ProcessDeploymentJob_NoTargetID` — job.TargetID is nil, verify job marked Failed with "target_id not found"
10. `TestDeploymentService_ProcessDeploymentJob_TargetNotFound` — targetRepo.GetErr set, verify job marked Failed
11. `TestDeploymentService_ProcessDeploymentJob_AgentNotFound` — agentRepo.GetErr set, verify job marked Failed
12. `TestDeploymentService_ProcessDeploymentJob_AgentOffline` — agent.LastHeartbeatAt is 10 minutes ago, verify job marked Failed with "agent is offline", notification sent
**ValidateDeployment:**
13. `TestDeploymentService_ValidateDeployment_Completed` — deployment job exists with Status=Completed, expect (true, nil)
14. `TestDeploymentService_ValidateDeployment_Failed` — deployment job with Status=Failed and LastError, expect (false, error with message)
15. `TestDeploymentService_ValidateDeployment_InProgress` — deployment job with Status=Running, expect (false, "deployment in progress")
16. `TestDeploymentService_ValidateDeployment_NoJob` — no matching deployment job, expect (false, "no deployment job found")
17. `TestDeploymentService_ValidateDeployment_ListError` — jobRepo returns error
**MarkDeploymentComplete:**
18. `TestDeploymentService_MarkDeploymentComplete_Success` — verify job status -> Completed, notification sent (success=true), audit event
19. `TestDeploymentService_MarkDeploymentComplete_JobNotFound` — jobRepo.GetErr set
20. `TestDeploymentService_MarkDeploymentComplete_NoTargetID` — job.TargetID is nil, still completes without notification
**MarkDeploymentFailed:**
21. `TestDeploymentService_MarkDeploymentFailed_Success` — verify job status -> Failed, error message stored, notification sent (success=false), audit event
22. `TestDeploymentService_MarkDeploymentFailed_JobNotFound` — jobRepo.GetErr set
---
## P0-2: `internal/service/target_test.go` (NEW FILE)
**File to test:** `internal/service/target.go`
### Setup:
```go
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
}
```
### Required tests (~15 functions):
**Context-aware methods (List, Get, Create, Update, Delete):**
1. `TestTargetService_List_Success` — 3 targets, page=1 perPage=2, expect 2 returned with total=3
2. `TestTargetService_List_DefaultPagination` — page=0 perPage=0, expect defaults to 1/50
3. `TestTargetService_List_EmptyPage` — page=2 perPage=10 with only 3 targets, expect empty slice, total=3
4. `TestTargetService_List_RepoError` — ListErr set
5. `TestTargetService_Get_Success` — target exists
6. `TestTargetService_Get_NotFound` — target doesn't exist
7. `TestTargetService_Create_Success` — verify target stored, ID generated, timestamps set, audit event
8. `TestTargetService_Create_MissingName` — empty name, expect error
9. `TestTargetService_Create_RepoError` — CreateErr set
10. `TestTargetService_Update_Success` — verify target updated, audit event
11. `TestTargetService_Update_MissingName` — empty name, expect error
12. `TestTargetService_Delete_Success` — verify target removed, audit event
13. `TestTargetService_Delete_RepoError` — DeleteErr set
**Legacy handler interface methods:**
14. `TestTargetService_ListTargets_Success` — verify returns dereferenced targets
15. `TestTargetService_GetTarget_Success`
16. `TestTargetService_CreateTarget_Success` — verify ID generation
17. `TestTargetService_UpdateTarget_Success`
18. `TestTargetService_DeleteTarget_Success`
---
## P0-3: Scheduler Loop Execution Tests
**File to modify:** `internal/scheduler/scheduler_test.go`
The existing tests cover idempotency and graceful shutdown. Add tests that verify each loop actually calls its service method.
### Required tests (~8 functions):
1. `TestSchedulerRenewalLoopCallsService` — start scheduler with 50ms interval, wait 150ms, verify renewalMock.callCount >= 1
2. `TestSchedulerJobProcessorLoopCallsService` — same pattern for jobMock
3. `TestSchedulerAgentHealthCheckLoopCallsService` — same for agentMock
4. `TestSchedulerNotificationLoopCallsService` — same for notificationMock
5. `TestSchedulerNetworkScanLoopCallsService` — same for networkMock
6. `TestSchedulerShortLivedExpiryLoopCallsService` — verify ExpireShortLivedCertificates is called (need to add callCount tracking to mockRenewalService.ExpireShortLivedCertificates)
7. `TestSchedulerLoopErrorRecovery` — set shouldError=true on renewalMock, verify scheduler continues (doesn't crash), subsequent calls still happen
8. `TestSchedulerLoopContextCancellation` — cancel context mid-execution, verify no panics, WaitForCompletion succeeds
**Note:** You'll need to add `expireCallCount` and `expireCallTimes` fields to `mockRenewalService` and track calls in `ExpireShortLivedCertificates`.
---
## P0-4: Agent Binary Tests
**File to create:** `cmd/agent/agent_test.go` (NEW FILE, `package main`)
This is the hardest gap. The agent binary's methods (`executeCSRJob`, `executeDeploymentJob`, heartbeat loop, discovery loop) need a mock HTTP server.
### Setup:
```go
func newTestServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
// Register mock endpoints
mux.HandleFunc("/api/v1/agents/", func(w http.ResponseWriter, r *http.Request) {
// Handle heartbeat (POST /agents/{id}/heartbeat), work (GET /agents/{id}/work),
// CSR submission (POST /agents/{id}/csr), job status (POST /agents/{id}/jobs/{job_id}/status),
// discoveries (POST /agents/{id}/discoveries)
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
})
return httptest.NewServer(mux)
}
```
### Required tests (~10 functions):
1. `TestAgentHeartbeat_Success` — mock server returns 200, verify request has correct headers
2. `TestAgentHeartbeat_ServerDown` — connection refused, verify error handling (no panic)
3. `TestAgentCSRGeneration` — verify ECDSA P-256 key generation, CSR contains correct CN and SANs
4. `TestAgentCSRGeneration_EmailSAN` — verify email SANs route to EmailAddresses (not DNSNames)
5. `TestAgentWorkPolling_NoWork` — server returns empty work list
6. `TestAgentWorkPolling_DeploymentJob` — server returns deployment work item
7. `TestAgentWorkPolling_CSRJob` — server returns AwaitingCSR work item
8. `TestAgentKeyStorage` — verify keys written to temp dir with 0600 permissions
9. `TestAgentDiscoveryScan` — scan a temp directory with a test PEM file, verify correct extraction
10. `TestAgentDiscoveryScan_EmptyDir` — scan empty directory, verify empty results (no error)
**Important:** The agent code uses global variables and `main()` package patterns. You may need to extract testable functions or use `TestMain` for setup. If the agent's methods are on a struct, mock the HTTP client. If they're standalone functions, use httptest.
---
## P1-1: `CompleteAgentCSRRenewal` Tests
**File to modify:** `internal/service/renewal_test.go`
### Required tests (~8 functions):
The method signature is:
```go
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error
```
You need a `RenewalService` with: certRepo, jobRepo, auditService, notificationSvc, issuerConnector (mock), profileRepo (mock), keygenMode="agent".
1. `TestCompleteAgentCSRRenewal_Success` — valid job (AwaitingCSR), valid cert, valid CSR PEM. Verify: issuer.IssueCertificate called, cert version created, job status -> Completed, deployment jobs created
2. `TestCompleteAgentCSRRenewal_IssuerError` — issuerConnector.Err set, verify job status -> Failed
3. `TestCompleteAgentCSRRenewal_InvalidCSR` — garbage CSR PEM, verify error
4. `TestCompleteAgentCSRRenewal_WithEKUs` — cert has certificate_profile_id, profile has allowed_ekus=["emailProtection"], verify EKUs forwarded to issuer
5. `TestCompleteAgentCSRRenewal_NoProfile` — cert has no profile ID, verify default EKUs (nil)
6. `TestCompleteAgentCSRRenewal_CreateVersionError` — certRepo.CreateVersionErr set
7. `TestCompleteAgentCSRRenewal_AuditRecorded` — verify audit event with correct details
8. `TestCompleteAgentCSRRenewal_DeploymentJobsCreated` — after successful signing, verify deployment jobs exist in jobRepo
**Note:** You'll need a `mockProfileRepo` if one doesn't exist in testutil_test.go. Check if `internal/repository/interfaces.go` has `ProfileRepository` and create a mock.
---
## P1-2: `ExpireShortLivedCertificates` Tests
**File to modify:** `internal/service/renewal_test.go`
```go
func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error
```
1. `TestExpireShortLivedCertificates_NoShortLived` — no active certs with short-lived profiles, no changes
2. `TestExpireShortLivedCertificates_ExpiresActiveCert` — cert with profile TTL < 1h, cert active, cert's NotAfter is in the past. Verify status -> Expired
3. `TestExpireShortLivedCertificates_SkipsNonExpired` — cert with short-lived profile but NotAfter is in the future, no change
4. `TestExpireShortLivedCertificates_SkipsNonShortLived` — cert with normal profile (TTL > 1h), even if expired. Verify not touched by this method
5. `TestExpireShortLivedCertificates_RepoError` — certRepo.ListErr set
**Note:** This method needs access to profiles to determine TTL. Read the actual implementation to understand how it queries — it may iterate all active certs and check their profile's max_ttl.
---
## P1-3: Domain Model Tests
### `internal/domain/job_test.go` (NEW FILE)
```go
package domain
import "testing"
```
1. `TestJobType_Constants` — verify all 4 JobType constants have expected string values
2. `TestJobStatus_Constants` — verify all 7 JobStatus constants
3. `TestVerificationStatus_Constants` — verify all 4 VerificationStatus constants (pending, success, failed, skipped)
### `internal/domain/certificate_test.go` (NEW FILE)
1. `TestCertificateStatus_Constants` — verify all 8 CertificateStatus constants
2. `TestRenewalPolicy_EffectiveAlertThresholds_Custom` — policy with custom thresholds returns them
3. `TestRenewalPolicy_EffectiveAlertThresholds_Default` — policy with nil thresholds returns DefaultAlertThresholds()
4. `TestDefaultAlertThresholds` — returns [30, 14, 7, 0]
### `internal/domain/agent_group_test.go` (NEW FILE)
1. `TestAgentGroup_HasDynamicCriteria_True` — group with MatchOS set
2. `TestAgentGroup_HasDynamicCriteria_False` — all criteria empty
3. `TestAgentGroup_MatchesAgent_AllMatch` — all 4 criteria set, agent matches all
4. `TestAgentGroup_MatchesAgent_OSMismatch` — MatchOS="linux", agent.OS="windows"
5. `TestAgentGroup_MatchesAgent_ArchMismatch` — MatchArchitecture="amd64", agent.Architecture="arm64"
6. `TestAgentGroup_MatchesAgent_VersionMismatch` — MatchVersion="1.0", agent.Version="2.0"
7. `TestAgentGroup_MatchesAgent_IPMismatch` — MatchIPCIDR doesn't match agent.IPAddress
8. `TestAgentGroup_MatchesAgent_EmptyCriteriaMatchesAll` — all criteria empty, any agent matches
9. `TestAgentGroup_MatchesAgent_PartialCriteria` — only MatchOS set, agent matches OS, other fields irrelevant
10. `TestAgentGroup_MatchesAgent_NilAgent` — if agent is nil, should not panic (add nil guard or verify behavior)
### `internal/domain/notification_test.go` (NEW FILE)
1. `TestNotificationType_Constants` — verify all 7 types
2. `TestNotificationChannel_Constants` — verify all 6 channels
3. `TestNotificationEvent_ZeroValue` — default struct has empty strings, nil pointers
### `internal/domain/policy_test.go` (NEW FILE)
1. `TestPolicyType_Constants` — verify all 5 policy types
2. `TestPolicySeverity_Constants` — verify all 3 severities
3. `TestPolicyViolation_Fields` — create a violation, verify all fields accessible
---
## P1-4: Handler Gap Tests
### Modify `internal/api/handler/agent_group_handler_test.go`
Add:
1. `TestUpdateAgentGroup_Success` — PUT with valid body, verify 200
2. `TestUpdateAgentGroup_InvalidJSON` — malformed body, verify 400
3. `TestUpdateAgentGroup_MissingName` — empty name field, verify 400
4. `TestUpdateAgentGroup_NotFound` — service returns not found error, verify 404
### Modify `internal/api/handler/issuer_handler_test.go`
Add:
1. `TestUpdateIssuer_Success` — PUT with valid body, verify 200
2. `TestUpdateIssuer_InvalidJSON` — verify 400
3. `TestUpdateIssuer_NotFound` — verify 404
### Modify `internal/api/handler/network_scan_handler_test.go`
Add:
1. `TestGetNetworkScanTarget_Success` — GET by ID, verify 200
2. `TestGetNetworkScanTarget_NotFound` — verify 404
3. `TestUpdateNetworkScanTarget_Success` — PUT with valid body, verify 200
4. `TestUpdateNetworkScanTarget_InvalidJSON` — verify 400
5. `TestUpdateNetworkScanTarget_NotFound` — verify 404
---
## P2-1: Frontend Error Handling Tests
**File to modify:** `web/src/api/client.test.ts`
Add error scenario tests for the 65+ API functions that lack them. Group by resource:
### Pattern:
```typescript
it('listCertificates handles 500 error', async () => {
fetchMock.mockResponseOnce('', { status: 500 });
await expect(listCertificates()).rejects.toThrow();
});
it('getCertificate handles 404 error', async () => {
fetchMock.mockResponseOnce('', { status: 404 });
await expect(getCertificate('nonexistent')).rejects.toThrow();
});
```
### Required (~40 tests):
Add at minimum a 500 error test and a 404 test (where applicable) for each resource group:
- Certificates (list 500, get 404, renew 404, revoke 404, export 404)
- Agents (list 500, get 404)
- Jobs (list 500, get 404, cancel 404, approve 404, reject 404)
- Policies (list 500, get 404, create 400, update 404, delete 404)
- Profiles (list 500, get 404, create 400)
- Owners (list 500, get 404)
- Teams (list 500, get 404)
- Agent Groups (list 500, get 404)
- Issuers (list 500, get 404)
- Targets (list 500, get 404, create 400)
- Discovery (list 500, claim 404, dismiss 404)
- Network Scans (list 500, create 400, trigger 404)
- Stats/Metrics (500 errors)
- Health (500 error)
---
## P2-2: Context Cancellation Tests
**File to create:** `internal/service/context_test.go` (NEW FILE)
Test that long-running service methods respect context cancellation.
### Pattern:
```go
func TestDeploymentService_CreateDeploymentJobs_ContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
svc, _, targetRepo, _, _, _ := newTestDeploymentService()
targetRepo.AddTarget(&domain.DeploymentTarget{ID: "t1", Name: "test"})
_, err := svc.CreateDeploymentJobs(ctx, "cert-1")
// Depending on implementation, may get context.Canceled or proceed normally
// The key assertion: no panic, no goroutine leak
t.Logf("result with cancelled context: %v", err)
}
```
### Required (~8 tests):
1. `TestDeploymentService_ProcessDeploymentJob_ContextTimeout` — context with 1ms timeout
2. `TestNetworkScanService_ScanAllTargets_ContextCancelled` — cancel mid-scan
3. `TestDiscoveryService_ProcessDiscoveryReport_ContextCancelled`
4. `TestESTService_SimpleEnroll_ContextCancelled`
5. `TestExportService_ExportPKCS12_ContextCancelled`
6. `TestRenewalService_ProcessRenewalJob_ContextTimeout`
7. `TestCertificateService_RevokeCertificateWithActor_ContextCancelled`
8. `TestVerificationService_RecordVerificationResult_ContextCancelled`
---
## P2-3: Concurrent Operation Tests
**File to create:** `internal/service/concurrent_test.go` (NEW FILE)
Use `sync.WaitGroup` and goroutines to test concurrent access patterns.
### Required (~6 tests):
```go
func TestConcurrentRevocation(t *testing.T) {
// Setup service with a certificate
// Launch 5 goroutines all trying to revoke the same cert simultaneously
// Verify: exactly 1 succeeds (or all succeed idempotently), no panics, no data corruption
var wg sync.WaitGroup
errors := make([]error, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errors[idx] = svc.RevokeCertificateWithActor(ctx, certID, "keyCompromise", "test-actor")
}(i)
}
wg.Wait()
// Assert at most 1 "already revoked" error
}
```
1. `TestConcurrentRevocation` — 5 goroutines revoke same cert
2. `TestConcurrentDeploymentJobCreation` — 3 goroutines create deployment jobs for same cert
3. `TestConcurrentDiscoveryReports` — 3 goroutines submit discovery reports simultaneously
4. `TestConcurrentCertificateList` — 10 goroutines list certificates simultaneously (no race)
5. `TestConcurrentJobStatusUpdate` — 5 goroutines update same job status
6. `TestConcurrentTargetCRUD` — create, update, delete targets concurrently
---
## Execution Order
Run these in order, verifying each step:
```bash
# P0 — Critical
go test ./internal/service/ -run TestDeploymentService -v -count=1
go test ./internal/service/ -run TestTargetService -v -count=1
go test ./internal/scheduler/ -run TestScheduler -v -count=1
# P1 — High Priority
go test ./internal/service/ -run TestCompleteAgentCSR -v -count=1
go test ./internal/service/ -run TestExpireShortLived -v -count=1
go test ./internal/domain/ -v -count=1
go test ./internal/api/handler/ -run "TestUpdateAgentGroup|TestUpdateIssuer|TestGetNetworkScan|TestUpdateNetworkScan" -v -count=1
# P2 — Medium Priority
cd web && npx vitest run
go test ./internal/service/ -run TestContext -v -count=1
go test ./internal/service/ -run TestConcurrent -v -count=1
# Full suite verification
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
go vet ./...
cd web && npx vitest run
```
## Final CI Gate
After all tests pass locally, verify the full CI pipeline would pass:
```bash
# Coverage check
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -count=1 -cover -coverprofile=coverage.out
# Check thresholds
go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Service: %.1f%%\n", sum/n}'
go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Handler: %.1f%%\n", sum/n}'
go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Domain: %.1f%%\n", sum/n}'
# Targets: service >= 60%, handler >= 60%, domain >= 40%
```
---
## What NOT To Do
- Do NOT modify any production code (only test files)
- Do NOT add new dependencies to go.mod
- Do NOT create mocks that duplicate existing ones in testutil_test.go — reuse them
- Do NOT use `testing.Short()` skips — all these tests should run in CI
- Do NOT use `time.Sleep` for synchronization — use channels, WaitGroups, or atomic counters
- Do NOT write tests that are flaky due to timing — if testing scheduler loops, use generous timeouts and verify "at least 1 call" rather than exact counts
+711 -3
View File
@@ -33,6 +33,12 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
- [Part 26: EST Server (RFC 7030)](#part-26-est-server-rfc-7030)
- [Part 27: Post-Deployment TLS Verification](#part-27-post-deployment-tls-verification)
- [Part 28: Traefik & Caddy Target Connectors](#part-28-traefik--caddy-target-connectors)
- [Part 29: Certificate Export (PEM & PKCS#12)](#part-29-certificate-export-pem--pkcs12)
- [Part 30: S/MIME & EKU Support](#part-30-smime--eku-support)
- [Part 31: OCSP Responder & DER CRL](#part-31-ocsp-responder--der-crl)
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
- [Release Sign-Off](#release-sign-off)
---
@@ -1985,8 +1991,8 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
curl -s -H "$AUTH" "$SERVER/api/v1/profiles" | jq '{total, ids: [.items[].id]}'
```
**Expected:** `total` = 4 (seed profiles).
**PASS if** total = 4. **FAIL** otherwise.
**Expected:** `total` = 5 (seed profiles: prof-standard-tls, prof-internal-mtls, prof-short-lived, prof-wildcard, prof-smime).
**PASS if** total = 5. **FAIL** otherwise.
---
@@ -4367,9 +4373,705 @@ go test ./internal/connector/target/caddy/... -v
---
## Part 29: Certificate Export (PEM & PKCS#12)
**What:** certctl lets operators export managed certificates in two formats — PEM (JSON or file download) and PKCS#12 (.p12 bundle). Private keys are **never** included in exports since they live exclusively on agents. This section verifies both export paths, the audit trail they produce, and the GUI integration.
**Why:** Certificate export is a daily operational task — feeding certs into load balancers that lack agent support, importing into Java trust stores, or handing off to external teams. If export silently produces malformed output or fails to audit, operators lose trust in the platform.
### 29.1: Export PEM (JSON Response)
**What:** `GET /api/v1/certificates/{id}/export/pem` returns a JSON object with the leaf certificate PEM, the CA chain PEM, and the full concatenated PEM. This is the default response format when no `?download=true` query parameter is present.
**Why:** The JSON format lets automation scripts programmatically extract the leaf cert separately from the chain — a common need for split-file deployments (Apache, custom TLS termination).
```bash
# Use an existing certificate ID from seed data
CERT_ID="mc-api-prod"
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pem" | jq .
```
**Expected:** 200 OK with JSON body containing `cert_pem` (leaf), `chain_pem` (CA certs), and `full_pem` (concatenated).
**PASS if:**
- Response Content-Type is `application/json`
- `cert_pem` contains exactly one `-----BEGIN CERTIFICATE-----` block
- `full_pem` starts with the same block as `cert_pem` (leaf is first in chain)
- `chain_pem` is empty for self-signed CA or contains the issuing CA cert
**FAIL if:** Response is non-JSON, fields are missing, or `full_pem` doesn't equal `cert_pem` + `chain_pem`.
### 29.2: Export PEM (File Download)
**What:** Adding `?download=true` to the PEM export endpoint returns the raw PEM file with `Content-Type: application/x-pem-file` and a `Content-Disposition: attachment` header, suitable for browser "Save As" workflows.
**Why:** The GUI uses this mode when operators click the "Export PEM" button — the browser should trigger a file download, not show JSON in the tab.
```bash
curl -s -D - -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pem?download=true" \
-o /tmp/exported.pem
# Verify the downloaded file is valid PEM
openssl x509 -in /tmp/exported.pem -noout -subject
```
**Expected:** 200 OK, headers include `Content-Type: application/x-pem-file` and `Content-Disposition: attachment; filename="certificate.pem"`.
**PASS if:**
- The response headers match the expected Content-Type and Content-Disposition
- The saved file parses successfully with `openssl x509`
- The subject CN matches the certificate's common name
**FAIL if:** Headers are wrong (JSON Content-Type), file is empty, or `openssl` rejects the PEM.
### 29.3: Export PEM — Not Found
**What:** Requesting export for a nonexistent certificate ID returns 404.
```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-nonexistent/export/pem"
```
**Expected:** 404 Not Found with error message.
**PASS if** status code is 404 and body contains "not found".
### 29.4: Export PKCS#12
**What:** `POST /api/v1/certificates/{id}/export/pkcs12` returns a binary PKCS#12 (.p12) file containing the certificate chain (no private key). An optional `password` field in the JSON body encrypts the bundle.
**Why:** PKCS#12 is the standard format for importing certificates into Java keystores (`keytool`), Windows certificate stores, and many commercial load balancers. The cert-only bundle (no private key) is safe to share with teams that only need trust anchors.
```bash
# Export with a password
curl -s -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"password": "export-test-2024"}' \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pkcs12" \
-o /tmp/exported.p12
# Verify the PKCS#12 file (openssl should parse it)
openssl pkcs12 -in /tmp/exported.p12 -nokeys -passin pass:export-test-2024 -info
```
**Expected:** 200 OK, Content-Type `application/x-pkcs12`, Content-Disposition `attachment; filename="certificate.p12"`.
**PASS if:**
- Binary .p12 file is returned (non-empty)
- `openssl pkcs12` successfully parses the file with the correct password
- No private key is present in the output (cert-only trust store)
**FAIL if:** Response is JSON instead of binary, file is empty, or `openssl` rejects the PKCS#12 format.
### 29.5: Export PKCS#12 — Empty Password
**What:** The password field is optional. Omitting it (or sending an empty body) should still produce a valid PKCS#12 bundle encrypted with an empty password.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
-X POST \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pkcs12" \
-o /tmp/exported-nopass.p12
openssl pkcs12 -in /tmp/exported-nopass.p12 -nokeys -passin pass: -info
```
**Expected:** 200 OK with valid PKCS#12.
**PASS if** `openssl pkcs12` parses with an empty password.
### 29.6: Export Audit Trail
**What:** Both PEM and PKCS#12 exports record audit events (`export_pem` and `export_pkcs12`) with the certificate's serial number.
**Why:** Export operations are security-sensitive — knowing who exported what and when is critical for incident response and compliance (SOC 2 CC7, PCI-DSS Req 10).
```bash
# Export a cert (triggers audit event)
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pem" > /dev/null
# Check audit trail for the export event
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/audit?resource_type=certificate&action=export_pem" | jq '.items[-1]'
```
**Expected:** Audit event with action `export_pem`, resource_type `certificate`, resource_id matching the cert ID.
**PASS if** the audit event exists with serial number in metadata.
**FAIL if** no audit event is recorded for the export.
### 29.7: Export Unit Tests
```bash
go test ./internal/service/ -run TestExport -v
go test ./internal/api/handler/ -run TestExport -v
```
**Expected:** All export service tests (9 tests) and handler tests (11 tests) pass.
**PASS if** exit code 0 for both.
### 29.8: GUI Export Buttons
**What:** The certificate detail page shows "Export PEM" and "Export PKCS#12" buttons. PEM triggers a file download. PKCS#12 opens a password modal, then triggers a binary download.
**How to test (manual browser test):**
1. Navigate to a certificate detail page (e.g., `/certificates/mc-api-prod`)
2. Click "Export PEM" — browser should download `certificate.pem`
3. Click "Export PKCS#12" — password modal appears
4. Enter a password and confirm — browser should download `certificate.p12`
**PASS if** both downloads complete with non-empty files.
**FAIL if** buttons are missing, modal doesn't appear, or downloads fail.
---
## Part 30: S/MIME & EKU Support
**What:** Certificate profiles can specify Extended Key Usage (EKU) constraints — `serverAuth`, `clientAuth`, `codeSigning`, `emailProtection`, `timeStamping`. The Local CA respects these EKUs during issuance, adapting the X.509 `KeyUsage` flags accordingly (TLS uses `DigitalSignature|KeyEncipherment`; S/MIME uses `DigitalSignature|ContentCommitment`). A demo `prof-smime` profile ships in seed data.
**Why:** S/MIME certificates protect email with digital signatures and encryption. They require the `emailProtection` EKU and `ContentCommitment` (formerly NonRepudiation) key usage flag. If the platform treats all certs as TLS certs, S/MIME certs will be rejected by mail clients.
### 30.1: S/MIME Profile Exists in Seed Data
**What:** The demo seed creates 5 profiles including `prof-smime` with `emailProtection` EKU.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/profiles/prof-smime" | jq '{name, allowed_ekus}'
```
**Expected:** 200 OK. Profile name is "S/MIME Email" and `allowed_ekus` contains `["emailProtection"]`.
**PASS if** the profile exists and EKUs match.
**FAIL if** 404 or EKUs are wrong/missing.
### 30.2: All Five Profiles Present
**What:** The seed data creates 5 profiles total. Previous versions of this guide referenced 4 — the `prof-smime` profile was added in M27.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/profiles" | jq '.total'
```
**Expected:** `total` is 5 (prof-standard-tls, prof-internal-mtls, prof-short-lived, prof-wildcard, prof-smime).
**PASS if** count is 5.
**FAIL if** count is 4 or fewer (missing prof-smime).
### 30.3: EKU Strings in Profile API
**What:** The profile API accepts and returns EKU names as human-readable strings rather than OID numbers. The supported values are: `serverAuth`, `clientAuth`, `codeSigning`, `emailProtection`, `timeStamping`.
```bash
# Create a profile with codeSigning EKU
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "prof-test-codesign",
"name": "Code Signing Test",
"description": "Test profile for code signing",
"allowed_key_algorithms": [{"algorithm": "ECDSA", "min_size": 256}],
"max_ttl_seconds": 7776000,
"allowed_ekus": ["codeSigning"]
}' \
"http://localhost:8443/api/v1/profiles" | jq '{id, allowed_ekus}'
```
**Expected:** 201 Created with `allowed_ekus: ["codeSigning"]`.
**PASS if** the EKU round-trips correctly through create/get.
### 30.4: Agent CSR SAN Splitting (Email vs DNS)
**What:** When generating CSRs for S/MIME certificates, the agent splits SANs by type: values containing `@` are placed in `EmailAddresses` (not `DNSNames`). This prevents mail clients from rejecting the cert due to incorrect SAN encoding.
**Why:** An email SAN like `alice@example.com` must appear in the X.509 `rfc822Name` SAN field, not the `dNSName` field. Incorrect encoding causes S/MIME validation failures.
This is tested via unit tests:
```bash
go test ./cmd/agent/ -run TestSAN -v
```
**Expected:** Tests pass showing email-type SANs are routed to `EmailAddresses`.
**PASS if** exit code 0.
### 30.5: EKU Service-Layer Tests
```bash
go test ./internal/service/ -run TestEKU -v
go test ./internal/service/ -run TestCSRRenewal -v
```
**Expected:** Tests covering EKU resolution from profiles and issuance with non-default EKUs pass.
**PASS if** exit code 0.
---
## Part 31: OCSP Responder & DER CRL
**What:** certctl includes an embedded OCSP responder and a DER-encoded CRL generator, both operating per-issuer. These are the standard online (OCSP) and offline (CRL) methods for checking certificate revocation status. Short-lived certificates (profile TTL < 1 hour) are exempt from both — their natural expiry is sufficient revocation.
**Why:** TLS clients need to verify that certificates haven't been revoked. Without OCSP/CRL, a compromised certificate remains trusted until it expires. The short-lived exemption avoids bloating the CRL with certs that expire before distribution.
### 31.1: DER-Encoded CRL
**What:** `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA. Content-Type is `application/pkix-crl`. The CRL has 24-hour validity.
**Why:** This is the standard CRL format that browsers, TLS libraries, and LDAP directories consume. The existing JSON CRL at `GET /api/v1/crl` is certctl-specific; the DER CRL is interoperable.
```bash
# Request DER CRL for the local issuer
curl -s -D - -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-local" \
-o /tmp/crl.der
# Verify it's valid DER CRL with openssl
openssl crl -in /tmp/crl.der -inform DER -noout -text
```
**Expected:** 200 OK, Content-Type `application/pkix-crl`, Cache-Control `public, max-age=3600`.
**PASS if:**
- `openssl crl` parses the DER file successfully
- Issuer field shows the Local CA's common name
- Validity period is present (thisUpdate / nextUpdate)
- If any certs have been revoked, they appear in the revocation list with serial + reason
**FAIL if:** Response is JSON (wrong endpoint), `openssl` rejects the DER format, or headers are wrong.
### 31.2: DER CRL — Nonexistent Issuer
```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-nonexistent"
```
**Expected:** 404 Not Found.
**PASS if** status code is 404 and body contains "not found".
### 31.3: OCSP Responder — Good Status
**What:** `GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response. For a non-revoked certificate, the status is "good".
**Why:** OCSP is the real-time revocation check that TLS clients perform during the handshake. A "good" response tells the client the cert is still valid.
```bash
# First, get a certificate's serial number
SERIAL=$(curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-api-prod" | jq -r '.latest_version.serial_number // empty')
# If serial is available, query OCSP
if [ -n "$SERIAL" ]; then
curl -s -D - -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
-o /tmp/ocsp.der
# Parse OCSP response
openssl ocsp -respin /tmp/ocsp.der -text -noverify
fi
```
**Expected:** 200 OK, Content-Type `application/ocsp-response`. OCSP response shows `Cert Status: good`.
**PASS if:**
- OCSP response parses successfully
- Certificate status is "good" for a non-revoked cert
- Response is signed (producedAt timestamp present)
**FAIL if:** Response is JSON, OCSP status is wrong, or `openssl` rejects the response.
### 31.4: OCSP Responder — Revoked Status
**What:** After revoking a certificate, the OCSP responder should return "revoked" with the revocation reason and timestamp.
```bash
# Revoke a certificate first (see Part 5 for revocation)
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "keyCompromise"}' \
"http://localhost:8443/api/v1/certificates/$CERT_ID/revoke"
# Then query OCSP
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
-o /tmp/ocsp-revoked.der
openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
```
**Expected:** OCSP response shows `Cert Status: revoked`, revocation time, and reason code (1 = keyCompromise).
**PASS if** status is "revoked" with correct reason.
**FAIL if** status is still "good" after revocation.
### 31.5: OCSP — Unknown Certificate
**What:** Querying a serial number that doesn't exist in the inventory returns an "unknown" OCSP status (not an error — this is the correct OCSP behavior per RFC 6960).
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/ocsp/iss-local/DEADBEEF" \
-o /tmp/ocsp-unknown.der
openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
```
**Expected:** OCSP response with `Cert Status: unknown`.
**PASS if** status is "unknown" (not a 404 HTTP error).
### 31.6: Short-Lived Certificate CRL Exemption
**What:** Certificates issued under a profile with TTL < 1 hour are excluded from both CRL and OCSP responses. Their natural expiry is considered sufficient revocation.
**Why:** Short-lived certs (used in mTLS, CI/CD pipelines) would bloat the CRL with entries that expire within minutes. The crypto community consensus (per Google's Certificate Transparency policy) is that short-lived certs don't need revocation infrastructure.
To test: revoke a cert that was issued under the `prof-short-lived` profile, then check the DER CRL. The revoked short-lived cert should NOT appear.
```bash
# After revoking a short-lived cert (serial SHORT_SERIAL):
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/crl.der
openssl crl -in /tmp/crl.der -inform DER -text | grep -i "$SHORT_SERIAL"
```
**Expected:** The short-lived cert's serial does NOT appear in the CRL.
**PASS if** short-lived cert is absent from CRL despite being revoked.
### 31.7: OCSP / CRL Unit Tests
```bash
go test ./internal/service/ -run "TestGenerateDERCRL|TestGetOCSPResponse" -v
go test ./internal/api/handler/ -run "TestDERCRL|TestOCSP" -v
go test ./internal/connector/issuer/local/ -run "TestGenerateCRL|TestSignOCSP" -v
```
**Expected:** All tests pass (8 service tests, handler tests, connector tests).
**PASS if** exit code 0 for all three test suites.
---
## Part 32: Request Body Size Limits
**What:** The `NewBodyLimit` middleware wraps request bodies with `http.MaxBytesReader`, enforcing a configurable maximum payload size (default 1MB). Oversized requests receive a 413 Request Entity Too Large response. This protects against memory exhaustion and denial of service (CWE-400).
**Why:** Without body limits, an attacker could send a multi-gigabyte POST to exhaust server memory. The 1MB default is generous for certificate API payloads (a typical CSR is ~1KB, a PKCS#12 export request is <100 bytes) while blocking abuse.
### 32.1: Default 1MB Limit
**What:** With default configuration (`CERTCTL_MAX_BODY_SIZE` unset), the server rejects request bodies larger than 1MB.
```bash
# Generate a payload slightly over 1MB
dd if=/dev/urandom bs=1024 count=1025 2>/dev/null | base64 > /tmp/big-payload.txt
curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$(cat /tmp/big-payload.txt)\"}" \
"http://localhost:8443/api/v1/certificates"
```
**Expected:** The server returns an error (likely 400 or 413) when the body exceeds 1MB.
**PASS if** the request is rejected and does not cause server memory issues.
**FAIL if** the server accepts the oversized payload or crashes.
### 32.2: Normal-Sized Requests Work
**What:** Standard API requests well under the limit work normally.
```bash
curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "mc-test-bodylimit", "common_name": "bodylimit.test.local", "issuer_id": "iss-local"}' \
"http://localhost:8443/api/v1/certificates"
```
**Expected:** 201 Created — normal payloads are unaffected by the body limit.
**PASS if** status code is 201.
### 32.3: Custom Body Size via Environment Variable
**What:** Set `CERTCTL_MAX_BODY_SIZE` to a custom value (e.g., `2097152` for 2MB) and verify the new limit is respected.
**How:** Restart the server with the env var set, then repeat test 32.1. A 1.1MB payload should now be accepted; a 2.1MB payload should be rejected.
**PASS if** the configured limit is enforced instead of the 1MB default.
### 32.4: Requests Without Bodies Are Unaffected
**What:** GET requests and other methods without request bodies pass through the body limit middleware without interference.
```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates" | tail -1
```
**Expected:** 200 OK — body limit middleware only applies to requests with bodies.
**PASS if** GET requests are unaffected.
---
## Part 33: Apache & HAProxy Target Connectors
**What:** certctl ships two additional target connectors beyond NGINX: Apache httpd (separate cert/chain/key files, `apachectl configtest` + graceful reload) and HAProxy (combined PEM file with cert+chain+key, config validation, reload). Both run on the agent side and follow the same pattern as the NGINX connector.
**Why:** Apache and HAProxy are the second and third most common reverse proxies in enterprise environments. Supporting them out of the box removes a common adoption blocker.
### 33.1: Create Apache Target
**What:** Create a deployment target of type `apache` with the required configuration fields.
```bash
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "t-test-apache",
"name": "Test Apache Server",
"type": "apache",
"agent_id": "agent-demo-1",
"config": {
"cert_path": "/etc/apache2/ssl/cert.pem",
"key_path": "/etc/apache2/ssl/key.pem",
"chain_path": "/etc/apache2/ssl/chain.pem",
"reload_command": "apachectl graceful",
"validate_command": "apachectl configtest"
}
}' \
"http://localhost:8443/api/v1/targets" | jq '{id, name, type}'
```
**Expected:** 201 Created with type `apache`.
**PASS if:**
- Target is created successfully
- Type is `apache`
- Config fields are persisted (verify via GET)
**FAIL if** type is rejected or config fields are missing in the response.
### 33.2: Apache Config — Separate Files
**What:** Apache uses three separate files (cert, chain, key) unlike NGINX's dual-file or HAProxy's combined PEM. Verify that `cert_path`, `chain_path`, and `key_path` are all required.
```bash
# Missing chain_path should fail validation
curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "t-test-apache-bad",
"name": "Bad Apache",
"type": "apache",
"agent_id": "agent-demo-1",
"config": {
"cert_path": "/etc/apache2/ssl/cert.pem",
"reload_command": "apachectl graceful",
"validate_command": "apachectl configtest"
}
}' \
"http://localhost:8443/api/v1/targets"
```
**Expected:** The target is created (config validation happens at deploy time on the agent), but when the agent attempts to deploy, it will fail if required fields are missing.
**PASS if** the validation behavior matches the connector's `ValidateConfig``cert_path` and `chain_path` are both required.
### 33.3: Create HAProxy Target
**What:** Create a deployment target of type `haproxy`. HAProxy uses a single combined PEM file (cert + chain + key concatenated), not separate files.
```bash
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "t-test-haproxy",
"name": "Test HAProxy",
"type": "haproxy",
"agent_id": "agent-demo-1",
"config": {
"pem_path": "/etc/haproxy/certs/site.pem",
"reload_command": "systemctl reload haproxy",
"validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
}
}' \
"http://localhost:8443/api/v1/targets" | jq '{id, name, type}'
```
**Expected:** 201 Created with type `haproxy`.
**PASS if** target created with correct type and config persisted.
### 33.4: HAProxy Combined PEM Requirement
**What:** HAProxy's `pem_path` is the single file where cert+chain+key are concatenated. The `pem_path` field is required; `reload_command` is also required.
**Why:** HAProxy's `bind ssl crt` directive expects one file per certificate. The combined PEM format eliminates the need for multiple `SSLCertificate*` directives.
This is verified in the connector's `ValidateConfig`:
```bash
go test ./internal/connector/target/haproxy/... -v
```
**Expected:** Tests validate that missing `pem_path` and missing `reload_command` both produce errors.
**PASS if** all haproxy connector tests pass.
### 33.5: Shell Command Injection Prevention
**What:** Both Apache and HAProxy connectors validate `reload_command` and `validate_command` against the shell injection prevention logic in `internal/validation/command.go`. Commands containing shell metacharacters (`;`, `|`, `&`, `$()`, backticks) are rejected.
**Why:** An attacker who controls target configuration could inject arbitrary commands if the reload/validate commands aren't sanitized. This was remediated in the security hardening pass (TICKET-001).
```bash
go test ./internal/validation/ -run TestValidateShellCommand -v
```
**Expected:** All 80+ adversarial test cases pass — commands with injection attempts are rejected, safe commands are accepted.
**PASS if** exit code 0.
### 33.6: Connector Unit Tests
```bash
go test ./internal/connector/target/apache/... -v
go test ./internal/connector/target/haproxy/... -v
```
**Expected:** All Apache and HAProxy connector tests pass (config validation, deployment logic).
**PASS if** exit code 0 for both.
---
## Part 34: Sub-CA Mode
**What:** The Local CA issuer connector can operate in two modes: self-signed root (default) or sub-CA. In sub-CA mode, set `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` to point at a pre-signed CA certificate and its private key. The CA cert must have `IsCA=true` and `KeyUsageCertSign`. All issued certificates then chain to the upstream root (e.g., Active Directory Certificate Services). Supports RSA, ECDSA, and PKCS#8 key formats.
**Why:** Enterprise environments already have a root CA (ADCS, Vault, etc.). Sub-CA mode lets certctl operate as a subordinate CA without replacing the existing trust hierarchy. Users' browsers and devices already trust the enterprise root, so certctl-issued certs are automatically trusted.
### 34.1: Self-Signed Mode (Default)
**What:** Without `CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH`, the Local CA generates its own self-signed root on startup. This is the default for development and demos.
```bash
# Verify the CA cert is self-signed (issuer == subject)
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-api-prod/export/pem?download=true" \
-o /tmp/chain.pem
# Extract the last cert in the chain (the CA cert)
csplit -f /tmp/cert- -z /tmp/chain.pem '/-----BEGIN CERTIFICATE-----/' '{*}' 2>/dev/null
LAST_CERT=$(ls /tmp/cert-* | tail -1)
openssl x509 -in "$LAST_CERT" -noout -subject -issuer
```
**Expected:** For self-signed mode, the CA cert's Subject and Issuer are identical.
**PASS if** Subject == Issuer (self-signed root).
### 34.2: Sub-CA Mode — Configuration
**What:** Setting `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` environment variables switches the Local CA to sub-CA mode. The server logs the mode at startup.
**How to test:**
1. Generate a test CA hierarchy (root CA + sub-CA):
```bash
# Generate root CA
openssl req -x509 -newkey rsa:2048 -keyout /tmp/root-key.pem -out /tmp/root-cert.pem \
-days 3650 -nodes -subj "/CN=Test Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# Generate sub-CA key and CSR
openssl req -newkey rsa:2048 -keyout /tmp/subca-key.pem -out /tmp/subca-csr.pem \
-nodes -subj "/CN=CertCtl Sub-CA"
# Sign sub-CA cert with root
openssl x509 -req -in /tmp/subca-csr.pem -CA /tmp/root-cert.pem -CAkey /tmp/root-key.pem \
-CAcreateserial -out /tmp/subca-cert.pem -days 1825 \
-extfile <(echo -e "basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign")
```
2. Start the server with sub-CA config:
```bash
CERTCTL_CA_CERT_PATH=/tmp/subca-cert.pem \
CERTCTL_CA_KEY_PATH=/tmp/subca-key.pem \
./certctl-server
```
3. Check startup logs for sub-CA mode indication.
**PASS if** the server starts successfully and logs indicate sub-CA mode with the loaded cert path.
**FAIL if** the server fails to start or falls back to self-signed mode.
### 34.3: Sub-CA Chain Construction
**What:** In sub-CA mode, issued certificates should chain to the sub-CA, which chains to the root. The PEM chain in certificate versions should include the leaf, the sub-CA cert, and optionally the root.
```bash
# Issue a certificate (after starting in sub-CA mode)
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "mc-subca-test", "common_name": "subca.test.local", "issuer_id": "iss-local"}' \
"http://localhost:8443/api/v1/certificates"
# Export and verify chain
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-subca-test/export/pem" | jq -r '.full_pem' > /tmp/subca-chain.pem
openssl verify -CAfile /tmp/root-cert.pem -untrusted /tmp/subca-cert.pem /tmp/subca-chain.pem
```
**Expected:** Certificate chain validates against the root CA. The leaf cert's Issuer matches the sub-CA's Subject.
**PASS if** `openssl verify` returns "OK".
**FAIL if** chain is broken or leaf is signed by self-signed root instead of sub-CA.
### 34.4: Sub-CA Validation — Non-CA Cert Rejected
**What:** If `CERTCTL_CA_CERT_PATH` points to a certificate without `IsCA=true` or `KeyUsageCertSign`, the server should reject it at startup.
```bash
# Generate a non-CA cert (leaf cert, not a CA)
openssl req -x509 -newkey rsa:2048 -keyout /tmp/leaf-key.pem -out /tmp/leaf-cert.pem \
-days 365 -nodes -subj "/CN=Not A CA"
# Try to start server with non-CA cert — should fail
CERTCTL_CA_CERT_PATH=/tmp/leaf-cert.pem \
CERTCTL_CA_KEY_PATH=/tmp/leaf-key.pem \
./certctl-server
```
**Expected:** Server fails to start (or logs a fatal error) because the loaded cert is not a CA.
**PASS if** server rejects the non-CA certificate.
**FAIL if** server starts and silently uses the non-CA cert for signing.
### 34.5: Sub-CA Key Format Support
**What:** The sub-CA key can be RSA, ECDSA, or PKCS#8 encoded. All three formats should load successfully.
```bash
go test ./internal/connector/issuer/local/ -run "TestSubCA" -v
```
**Expected:** All 7 sub-CA tests pass (RSA, ECDSA, config validation, invalid cert, non-CA cert, renewal, chain construction).
**PASS if** exit code 0.
### 34.6: CRL Signing in Sub-CA Mode
**What:** In sub-CA mode, the DER CRL (Part 31.1) should be signed by the sub-CA key, not a self-signed root.
```bash
# After starting in sub-CA mode and revoking a cert:
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/subca-crl.der
openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
```
**Expected:** CRL issuer matches the sub-CA's subject (not the self-signed CA).
**PASS if** issuer is the sub-CA distinguished name.
---
## Release Sign-Off
All 28 parts must pass before tagging v2.0.7.
All 34 parts must pass before tagging v2.0.7.
| Section | Pass? | Tester | Date | Notes |
|---------|-------|--------|------|-------|
@@ -4401,6 +5103,12 @@ All 28 parts must pass before tagging v2.0.7.
| Part 26: EST Server (RFC 7030) | ☐ | | | |
| Part 27: Post-Deployment TLS Verification | ☐ | | | |
| Part 28: Traefik & Caddy Target Connectors | ☐ | | | |
| Part 29: Certificate Export (PEM & PKCS#12) | ☐ | | | |
| Part 30: S/MIME & EKU Support | ☐ | | | |
| Part 31: OCSP Responder & DER CRL | ☐ | | | |
| Part 32: Request Body Size Limits | ☐ | | | |
| Part 33: Apache & HAProxy Target Connectors | ☐ | | | |
| Part 34: Sub-CA Mode | ☐ | | | |
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
+481
View File
@@ -0,0 +1,481 @@
# certctl Test Suite Audit & Manual Testing Guide
Last updated: March 28, 2026
This document covers the automated test suite inventory, identified gaps, and a complete manual testing guide for v2.1 release validation.
## Contents
1. [Automated Test Suite Inventory](#automated-test-suite-inventory)
2. [Test Gap Analysis](#test-gap-analysis)
3. [Manual Testing Guide](#manual-testing-guide)
4. [Pre-Release Checklist](#pre-release-checklist)
---
## Automated Test Suite Inventory
### Summary
| Layer | Test Files | Test Functions | Subtests | Coverage Target | Notes |
|-------|-----------|---------------|----------|-----------------|-------|
| Service | 12 | ~120 | ~185 | 60% (CI gate) | Best-covered layer |
| Handler | 12 | ~140 | ~145 | 60% (CI gate) | Near-complete endpoint coverage |
| Domain | 3 | ~16 | ~12 | 40% (CI gate) | Only revocation, discovery, verification tested |
| Middleware | 2 | ~14 | ~10 | 50% (CI gate) | Audit + CORS tested |
| Integration | 2 | ~15 | ~25 | — | Lifecycle + negative paths |
| Connector (Issuer) | 4 | ~41 | — | — | Local CA, ACME DNS, step-ca, OpenSSL |
| Connector (Target) | 2 | ~12 | — | — | Traefik, Caddy |
| Connector (Notifier) | 4 | ~20 | — | — | Slack, Teams, PagerDuty, OpsGenie |
| Validation | 2 | ~10 | ~80 | — | command.go + fuzz tests |
| Scheduler | 1 | ~5 | — | — | Startup/shutdown only |
| CLI | 1 | ~14 | — | — | All 10 subcommands |
| Repository | 2 | ~24 | ~50 | — | testcontainers-go, skipped in CI |
| Agent | 1 | ~5 | — | — | verify.go only |
| Frontend (API) | 1 | 89 | — | — | 96% API function coverage |
| Frontend (Utils) | 1 | 18 | — | — | 100% utility coverage |
| **Total** | **~50** | **~835+** | **~200+** | — | **1100+ total test points** |
### CI Pipeline
Every push runs (`.github/workflows/ci.yml`):
- `go vet ./...`
- `golangci-lint` (11 linters including gosec, bodyclose, errcheck)
- `govulncheck` (dependency CVE scanning)
- `go test -race` (race detection across service, handler, middleware, scheduler, connector, domain, validation)
- `go test -cover` with per-layer thresholds (service 55%, handler 60%, domain 40%, middleware 30%)
- Frontend: `tsc --noEmit`, `vitest run`, `vite build`
### What's Well-Tested
**Service layer** — renewal flows (server + agent keygen modes), revocation (all 8 RFC 5280 reasons), CRL/OCSP generation, discovery (process report, claim, dismiss, summary), network scan (CIDR expansion, validation, CRUD), stats (5 aggregations), EST enrollment (GetCACerts, SimpleEnroll/ReEnroll, CSRAttrs), export (PEM split, PKCS#12 encoding), verification (record/get results), issuer adapter (issue, renew, revoke with EKU forwarding).
**Handler layer** — all 12 resource handlers tested with success paths, 404/400/405/500 error paths, input validation (required fields, type checks, JSON parsing), query parameter parsing (pagination, filters, sort, cursor, sparse fields). CRUD endpoints, revocation, CRL, OCSP, EST, export, verification handlers all covered.
**Connectors** — Local CA (self-signed, sub-CA with RSA/ECDSA, renewal, config validation), ACME DNS solver (present, cleanup, DNS-PERSIST-01), step-ca (issue, renew, revoke via mock HTTP), OpenSSL (config validation, script execution, timeout), Traefik (file write, directory validation), Caddy (API mode, file mode, config validation), all 4 notifiers (webhook payloads, HTTP errors, auth headers, config defaults).
**Validation** — shell injection prevention with 80+ adversarial patterns (fuzz tests), domain validation, ACME token validation.
**Frontend** — 107 Vitest tests: all API client functions (certificates, agents, jobs, policies, profiles, owners, teams, agent groups, discovery, network scans, stats, metrics, export, health), utility functions (date formatting, time-ago, expiry color), both happy path and some error scenarios.
---
## Test Gap Analysis
### P0 — Critical Gaps (Production Risk)
**1. No tests for `service/deployment.go`** — deployment orchestration (creating deployment jobs, target resolution, deployment execution) is completely untested. This is the core path that actually puts certificates onto servers.
- Missing: `CreateDeploymentJobs`, `ProcessDeploymentJob`, target connector dispatch
- Risk: silent deployment failures, wrong cert deployed to wrong target
- Effort: 15-20 test functions, 1-2 days
**2. Agent binary (`cmd/agent/main.go`) largely untested** — only `verify.go` has tests. The agent's registration, heartbeat loop, work polling, CSR generation, discovery scanning, and deployment execution have no automated tests.
- Missing: heartbeat error handling, CSR generation edge cases, deployment with local keys, discovery scan error paths
- Risk: agent fails silently in production, key material handling bugs
- Effort: significant — needs mock control plane HTTP server, 3-5 days
- Mitigation: the manual testing guide below covers these flows
**3. `service/target.go` untested** — target CRUD operations (Create, List, Get, Update, Delete) have service-layer tests missing.
- Risk: target configuration errors not caught
- Effort: 8-10 test functions, 0.5 days
**4. Scheduler loop execution untested**`scheduler_test.go` only tests startup and graceful shutdown. The 6 actual loops (renewal check, job processing, health check, notifications, short-lived expiry, network scanning) are not tested for correct execution behavior.
- Risk: scheduler silently stops processing without detection
- Effort: complex — needs time manipulation and mock services, 2-3 days
### P1 — High-Priority Gaps
**5. `CompleteAgentCSRRenewal()` not tested** — this is the critical path where agent-submitted CSRs are signed by the issuer. EKU resolution from profiles, deployment job creation after signing, and CSR validation are all untested at the service layer.
- Effort: 5-8 test functions, 1 day
**6. `ExpireShortLivedCertificates()` not tested** — scheduler operation that marks short-lived certs as expired. No test coverage.
- Effort: 3-4 test functions, 0.5 days
**7. Domain models mostly untested** — only `revocation.go`, `discovery.go`, and `verification.go` have test files. Missing: `job.go` (state machine transitions), `certificate.go` (status validation), `agent_group.go` (MatchesAgent criteria), `notification.go`, `policy.go`.
- Effort: 20-30 test functions across 5 files, 2-3 days
**8. Handler gaps**`UpdateAgentGroup`, `UpdateIssuer`, `GetNetworkScanTarget`, `UpdateNetworkScanTarget` are untested handler methods.
- Effort: ~12 test functions, 0.5 days
### P2 — Medium-Priority Gaps
**9. Frontend: zero component/page render tests** — no React component tests exist. All 22 pages and 8 shared components are untested for rendering, user interaction, modal behavior, and form validation.
- Risk: UI regressions go undetected
- Effort: significant — needs React Testing Library setup, 3-5 days for core pages
**10. Frontend: weak error handling tests** — only 13 of 78 API functions have error scenario tests. Missing: 404 errors, network timeouts, 429 rate limiting, malformed JSON responses.
- Effort: 1-2 days
**11. Context cancellation / timeout tests** — no service or handler tests verify correct behavior when contexts are cancelled or time out. Long-running operations (network scan, EST enrollment) should gracefully handle cancellation.
- Effort: 1-2 days
**12. Concurrent operation tests** — two simultaneous revocations of the same certificate, concurrent discovery reports from multiple agents, parallel deployment jobs. Race detector catches some of this but not logic bugs.
- Effort: 1-2 days
### Docker Compose Bug Found During Audit
**`migrations/000008_verification.up.sql` is NOT mounted in `deploy/docker-compose.yml`**. The verification migration exists on disk but the Docker Compose file only mounts migrations 000001-000007. This means the demo environment is missing the `verification_status`, `verified_at`, `verification_fingerprint`, and `verification_error` columns on the jobs table.
Fix: add to docker-compose.yml:
```yaml
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
```
---
## Manual Testing Guide
This guide covers end-to-end manual validation of all certctl features against the Docker Compose demo environment. Use this for v2.1 release validation.
### Setup
```bash
# Clean start (removes old data)
docker compose -f deploy/docker-compose.yml down -v
docker compose -f deploy/docker-compose.yml up -d --build
# Wait for healthy
docker compose -f deploy/docker-compose.yml ps
# All three services should show "Up (healthy)" or "Up"
# Verify
curl -s http://localhost:8443/health | jq .
# {"status":"healthy"}
```
### 1. Dashboard & Navigation
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 1.1 | Dashboard loads | Open http://localhost:8443 | Stats cards show (total certs, expiring, expired, agents). 4 charts render (heatmap, trends, distribution, issuance rate) |
| 1.2 | Sidebar navigation | Click each sidebar item | All 16 nav items load without errors: Dashboard, Certificates, Agents, Fleet Overview, Jobs, Notifications, Policies, Profiles, Issuers, Targets, Owners, Teams, Agent Groups, Audit Trail, Short-Lived, Discovery, Network Scans |
| 1.3 | Auth disabled notice | Check for login prompt | No login screen (demo runs with `CERTCTL_AUTH_TYPE=none`) |
### 2. Certificate Lifecycle
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 2.1 | List certificates | Certificates page | 15 demo certificates with status badges, names, expiry dates |
| 2.2 | Certificate detail | Click any certificate | Detail page shows: Certificate Details card, Lifecycle card, Lifecycle Timeline (4 steps), Policy & Profile editor, Version History, Tags |
| 2.3 | Trigger renewal | Click "Trigger Renewal" on `mc-api-prod` | Success banner. Jobs page shows new Renewal job |
| 2.4 | Trigger deployment | Click "Deploy" → select a target → "Deploy" | Success banner. Jobs page shows new Deployment job |
| 2.5 | Revoke certificate | Click "Revoke" on an active cert → select "Key Compromise" → confirm | Red revocation banner appears on cert detail. Status changes to "Revoked" |
| 2.6 | Archive certificate | Click "Archive" → confirm | Redirect to certificates list. Cert no longer shows (or shows as Archived) |
| 2.7 | Export PEM | Click "Export PEM" on cert detail | Browser downloads a .pem file. File contains valid PEM certificate |
| 2.8 | Export PKCS#12 | Click "Export PKCS#12" → enter password → download | Browser downloads a .p12 file |
| 2.9 | Deployment timeline | View cert detail for a cert with deployment jobs | Timeline shows: Requested (green) → Issued (green) → Deploying (status) → Active |
| 2.10 | Version history | View cert detail with multiple versions | Version list with "Current" badge on latest. Rollback button on previous versions |
| 2.11 | Inline policy editor | Click "Edit" on Policy & Profile card → change policy → Save | Policy updates. Card shows new values |
### 3. Bulk Operations
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 3.1 | Multi-select | On Certificates page, check 3 certificates | Bulk action bar appears with count |
| 3.2 | Bulk renew | Select 3 certs → "Renew Selected" | Progress bar. 3 renewal jobs created |
| 3.3 | Bulk revoke | Select 2 certs → "Revoke Selected" → choose reason → confirm | Progress bar. Both certs revoked |
| 3.4 | Bulk reassign | Select 2 certs → "Reassign Owner" → enter new owner ID → confirm | Owner updated on both certificates |
| 3.5 | Select all | Click header checkbox | All visible certs selected |
### 4. Agent & Fleet
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 4.1 | Agent list | Agents page | 5 demo agents with status (Online/Offline), OS, Architecture, IP |
| 4.2 | Agent detail | Click an agent | System Information card (OS, arch, IP, version), recent jobs, capabilities |
| 4.3 | Fleet overview | Fleet Overview page | OS distribution chart, architecture chart, version breakdown, per-platform agent listing |
| 4.4 | Agent heartbeat | Check docker-agent status | `docker-agent` shows recent heartbeat timestamp, status Online |
### 5. Jobs & Approval Workflows
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 5.1 | Job list | Jobs page | Jobs with status badges. Type and status filters work |
| 5.2 | Pending approval banner | Jobs page (if AwaitingApproval jobs exist) | Amber banner: "N jobs awaiting approval" with "Show only" link |
| 5.3 | Approve renewal | Click "Approve" on an AwaitingApproval job | Job status changes to Pending or Running |
| 5.4 | Reject renewal | Click "Reject" → enter reason → confirm | Job status changes to Cancelled. Reason recorded |
| 5.5 | Cancel job | Click "Cancel" on a Pending/Running job | Job status changes to Cancelled |
| 5.6 | Status filter | Select "AwaitingApproval" from status dropdown | Only AwaitingApproval jobs shown |
| 5.7 | Type filter | Select "Deployment" from type dropdown | Only Deployment jobs shown |
### 6. Discovery & Network Scanning
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 6.1 | Discovery page | Discovery nav item | Summary stats bar (Unmanaged/Managed/Dismissed counts), certificate table |
| 6.2 | Claim cert | Click "Claim" on an unmanaged cert → enter managed cert ID → confirm | Status changes to Managed |
| 6.3 | Dismiss cert | Click "Dismiss" on an unmanaged cert | Status changes to Dismissed |
| 6.4 | Discovery filters | Filter by status (Unmanaged) | Only unmanaged certs shown |
| 6.5 | Scan history | Expand scan history panel | List of past scans with timestamps, cert counts |
| 6.6 | Network scan list | Network Scans page | Demo scan targets with CIDRs, ports, intervals |
| 6.7 | Create scan target | Click "+ New Target" → fill form → create | New target appears in list |
| 6.8 | Trigger scan | Click "Scan Now" on a target | Scan triggered (may timeout in demo if targets unreachable — that's OK) |
| 6.9 | Delete scan target | Click "Delete" on a target → confirm | Target removed from list |
### 7. Target Connector Wizard
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 7.1 | Open wizard | Targets page → "+ New Target" | 3-step wizard opens: Select Type → Configure → Review |
| 7.2 | NGINX type | Select NGINX → Next | Config fields: Certificate Path*, Key Path*, Chain Path, Reload Command |
| 7.3 | Apache type | Select Apache → Next | Config fields: Certificate Path*, Key Path*, Chain Path, Reload Command |
| 7.4 | HAProxy type | Select HAProxy → Next | Config fields: Combined PEM Path*, Reload Command, Validate Command |
| 7.5 | Traefik type | Select Traefik → Next | Config fields: Certificate Directory*, Certificate Filename, Key Filename |
| 7.6 | Caddy type | Select Caddy → Next | Config fields: Deployment Mode*, Admin API URL, Certificate Directory, Certificate Filename, Key Filename |
| 7.7 | F5 BIG-IP type | Select F5 BIG-IP → Next | Config fields: Management IP*, Partition, Proxy Agent ID |
| 7.8 | IIS type | Select IIS → Next | Config fields: IIS Site Name*, Binding IP, Binding Port, Certificate Store |
| 7.9 | Review & create | Fill required fields → Review → Create Target | Target appears in list with correct type and config |
| 7.10 | Validation | Leave required fields empty → try to proceed | "Next" / "Review" button disabled |
### 8. Policies, Profiles & Ownership
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 8.1 | Policy list | Policies page | 5 demo policies with severity bar |
| 8.2 | Create policy | Create a new policy with name, type, severity, config | Policy appears in list |
| 8.3 | Profile list | Profiles page | Demo profiles with allowed key types, max TTL, EKUs |
| 8.4 | S/MIME profile | Check `prof-smime` profile | Shows `emailProtection` EKU, 365-day max TTL |
| 8.5 | Owner list | Owners page | Demo owners with email and team assignment |
| 8.6 | Team list | Teams page | Demo teams |
| 8.7 | Agent groups | Agent Groups page | Demo groups with dynamic criteria badges (OS, arch, CIDR, version) |
### 9. Observability
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 9.1 | Audit trail | Audit Trail page | Events with actor, action, resource, timestamp. Time range filter works |
| 9.2 | Audit export CSV | Click "Export CSV" | Downloads .csv file with filtered audit events |
| 9.3 | Audit export JSON | Click "Export JSON" | Downloads .json file with filtered audit events |
| 9.4 | Short-lived creds | Short-Lived page | Filtered view of certs with TTL < 1 hour. Live countdown timers |
| 9.5 | Notifications | Notifications page | Grouped by certificate. Read/unread state. Mark as read works |
| 9.6 | JSON metrics | `curl http://localhost:8443/api/v1/metrics \| jq .` | Returns gauges (cert totals, agent counts), counters (jobs), uptime |
| 9.7 | Prometheus metrics | `curl http://localhost:8443/api/v1/metrics/prometheus` | Returns text/plain with `certctl_` prefixed metrics, `# HELP` and `# TYPE` lines |
| 9.8 | Stats summary | `curl http://localhost:8443/api/v1/stats/summary \| jq .` | Returns total_certificates, expiring, expired, agent counts, job counts |
### 10. API Endpoints (curl)
Run these against the demo environment to verify the API layer:
```bash
# Health
curl -s http://localhost:8443/health | jq .
# Certificate CRUD
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq '.common_name'
curl -s "http://localhost:8443/api/v1/certificates?status=Active&sort=-notAfter&fields=id,common_name,status,expires_at" | jq .
curl -s "http://localhost:8443/api/v1/certificates?page_size=3" | jq '.next_cursor'
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq '.total'
# Certificate deployments
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
# Renewal
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/renew | jq .
# Revocation
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-internal-staging/revoke \
-H "Content-Type: application/json" \
-d '{"reason": "superseded"}' | jq .
# CRL (JSON)
curl -s http://localhost:8443/api/v1/crl | jq .
# Export PEM
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/export/pem | jq .
curl -s "http://localhost:8443/api/v1/certificates/mc-api-prod/export/pem?download=true" -o cert.pem
# Export PKCS#12
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/export/pkcs12 \
-H "Content-Type: application/json" \
-d '{"password": "test123"}' -o cert.p12
# Agents
curl -s http://localhost:8443/api/v1/agents | jq '.total'
curl -s http://localhost:8443/api/v1/agents/ag-web-prod | jq '.os, .architecture, .ip_address'
curl -s http://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
# Jobs
curl -s http://localhost:8443/api/v1/jobs | jq '.total'
curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.total'
# Approval
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID_HERE/approve | jq .
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID_HERE/reject \
-H "Content-Type: application/json" \
-d '{"reason": "Not approved for this window"}' | jq .
# Discovery
curl -s http://localhost:8443/api/v1/discovered-certificates | jq '.total'
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
curl -s http://localhost:8443/api/v1/discovery-scans | jq '.total'
# Network scan targets
curl -s http://localhost:8443/api/v1/network-scan-targets | jq '.total'
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
-H "Content-Type: application/json" \
-d '{"name": "test-scan", "cidrs": ["192.168.1.0/24"], "ports": [443, 8443]}' | jq .
# Policies, profiles, teams, owners, agent groups
curl -s http://localhost:8443/api/v1/policies | jq '.total'
curl -s http://localhost:8443/api/v1/profiles | jq '.data[] | {id, name, allowed_ekus}'
curl -s http://localhost:8443/api/v1/teams | jq '.total'
curl -s http://localhost:8443/api/v1/owners | jq '.total'
curl -s http://localhost:8443/api/v1/agent-groups | jq '.total'
# Stats
curl -s http://localhost:8443/api/v1/stats/summary | jq .
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
curl -s "http://localhost:8443/api/v1/stats/issuance-rate?days=30" | jq .
# Metrics
curl -s http://localhost:8443/api/v1/metrics | jq .
curl -s http://localhost:8443/api/v1/metrics/prometheus
# Audit
curl -s http://localhost:8443/api/v1/audit | jq '.total'
curl -s "http://localhost:8443/api/v1/audit?resource_type=certificate&action=revoke" | jq .
# Notifications
curl -s http://localhost:8443/api/v1/notifications | jq '.total'
# Issuers and targets
curl -s http://localhost:8443/api/v1/issuers | jq '.data[] | {id, name, type}'
curl -s http://localhost:8443/api/v1/targets | jq '.data[] | {id, name, type, hostname}'
```
### 11. EST Server (RFC 7030)
EST requires `CERTCTL_EST_ENABLED=true` in the server environment. Add it to docker-compose and restart:
```bash
# Get CA certs (PKCS#7)
curl -s http://localhost:8443/.well-known/est/cacerts
# Get CSR attributes
curl -s http://localhost:8443/.well-known/est/csrattrs
# Simple enroll (requires a valid CSR in base64 DER or PEM format)
# Generate a test CSR:
openssl req -new -newkey rsa:2048 -nodes -keyout /tmp/test.key -subj "/CN=test.example.com" | \
base64 -w0 | \
curl -s -X POST http://localhost:8443/.well-known/est/simpleenroll \
-H "Content-Type: application/pkcs10" \
-d @-
```
### 12. CLI Tool
```bash
# Build CLI (requires Go)
go build -o certctl-cli ./cmd/cli/
# Configure
export CERTCTL_SERVER_URL=http://localhost:8443
# Test all subcommands
./certctl-cli health
./certctl-cli metrics
./certctl-cli certs list
./certctl-cli certs list --format json
./certctl-cli certs get mc-api-prod
./certctl-cli certs renew mc-api-prod
./certctl-cli certs revoke mc-internal-staging --reason superseded
./certctl-cli agents list
./certctl-cli jobs list
# Bulk import
echo "-----BEGIN CERTIFICATE-----
... (paste a valid PEM cert) ...
-----END CERTIFICATE-----" > /tmp/test-import.pem
./certctl-cli import /tmp/test-import.pem
```
### 13. Auth Flow (requires restart with auth enabled)
```bash
# Restart with auth
docker compose -f deploy/docker-compose.yml down
CERTCTL_AUTH_TYPE=api-key CERTCTL_AUTH_SECRET=test-secret-key \
docker compose -f deploy/docker-compose.yml up -d --build
# API should reject without key
curl -s http://localhost:8443/api/v1/certificates
# 401 Unauthorized
# API works with key
curl -s -H "Authorization: Bearer test-secret-key" http://localhost:8443/api/v1/certificates | jq '.total'
# GUI should show login screen
# Open http://localhost:8443 — enter "test-secret-key" — dashboard loads
# Logout button in sidebar should clear auth and redirect to login
```
---
## Pre-Release Checklist
### Automated (CI must pass)
- [ ] `go vet ./...` — no issues
- [ ] `golangci-lint run ./...` — no issues
- [ ] `govulncheck ./...` — no known vulnerabilities
- [ ] `go test -race` — no race conditions detected
- [ ] Coverage thresholds met (service 55%+, handler 60%+, domain 40%+, middleware 30%+)
- [ ] `npx tsc --noEmit` — no TypeScript errors
- [ ] `npx vitest run` — all frontend tests pass (107+)
- [ ] `npx vite build` — production build succeeds
### Manual (v2.1 release gate)
- [ ] Docker Compose starts cleanly from scratch (`down -v` then `up --build`)
- [ ] All 16 sidebar navigation items load without console errors
- [ ] Dashboard charts render with demo data
- [ ] Certificate CRUD: list, detail, renew, deploy, revoke, archive all work
- [ ] Bulk operations: multi-select, bulk renew, bulk revoke with progress bars
- [ ] Export: PEM download and PKCS#12 download both produce valid files
- [ ] Target wizard: all 7 target types show correct config fields (NGINX, Apache, HAProxy, Traefik, Caddy, F5, IIS)
- [ ] Deployment timeline shows correct step progression
- [ ] Jobs page: status/type filters, approval workflow (approve/reject with reason)
- [ ] Discovery page: summary stats, claim/dismiss, scan history
- [ ] Network scans: CRUD, trigger scan
- [ ] Audit trail: time range filter, CSV export, JSON export
- [ ] Prometheus endpoint returns valid exposition format
- [ ] CLI: `health`, `certs list`, `certs get`, `agents list` all return data
- [ ] Auth flow: login screen appears with auth enabled, API rejects without key
### Known Limitations
- EST enrollment requires `CERTCTL_EST_ENABLED=true` (off by default in demo)
- Network scans will timeout scanning demo CIDRs (no real hosts) — this is expected
- Agent keygen mode is `server` in demo (production uses `agent` for key isolation)
- OCSP/CRL endpoints require the Local CA to have been used for issuance (demo uses seeded certs, not issued via Local CA — OCSP/CRL may return empty results)
- Post-deployment TLS verification requires a real TLS endpoint to probe — not testable in basic demo setup
- Verification migration (000008) needs to be added to docker-compose.yml for full feature availability
---
## Prioritized Test Backlog
For the engineering team to close gaps over the next 2-3 sprints:
**Sprint 1 (1 week):**
1. Fix docker-compose migration gap (000008_verification)
2. Add `service/deployment_test.go` — 15 tests for deployment orchestration
3. Add `service/target_test.go` — 8 tests for target CRUD
4. Add missing handler tests: UpdateAgentGroup, UpdateIssuer, Get/UpdateNetworkScanTarget
**Sprint 2 (1 week):**
5. Add `CompleteAgentCSRRenewal` service tests — 8 tests
6. Add `ExpireShortLivedCertificates` service tests — 4 tests
7. Add domain model tests for `job.go`, `certificate.go`, `agent_group.go` — 20 tests
8. Frontend: add error scenario tests for API client (404, 429, timeout) — 15 tests
**Sprint 3 (1-2 weeks):**
9. Expand scheduler tests — test loop execution with mocked time
10. Add agent binary tests — mock HTTP control plane, test heartbeat + CSR + deploy flows
11. Frontend: add React component tests for LoginPage, CertificateDetailPage, TargetsPage wizard
12. Context cancellation tests for long-running service operations