feat: M25 post-deployment TLS verification + M26 Traefik/Caddy targets

M25: After deploying a certificate, the agent probes the live TLS
endpoint and compares SHA-256 fingerprints to verify the correct cert
is being served. Best-effort — failures don't block deployments.
New endpoints: POST /jobs/{id}/verify, GET /jobs/{id}/verification.
Migration 000008 adds verification columns to jobs table.

M26: Traefik target connector (file provider, auto-reload) and Caddy
target connector (dual-mode: admin API hot-reload or file-based).
Both wired into agent dispatch.

Also: restructured README to highlight supported integrations (issuers,
targets, notifiers) earlier, moved API/CLI/MCP sections lower. Updated
all docs (features, connectors, architecture, testing guide, why-certctl)
and fixed integration tests for 18-param RegisterHandlers signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-27 21:07:16 -04:00
parent 369a674619
commit 492a392e52
28 changed files with 3365 additions and 177 deletions
+169
View File
@@ -0,0 +1,169 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// VerificationService defines the service interface for verification operations.
type VerificationService interface {
// RecordVerificationResult records the outcome of TLS endpoint verification.
RecordVerificationResult(ctx interface{}, result *domain.VerificationResult) error
// GetVerificationResult retrieves the verification status for a job.
GetVerificationResult(ctx interface{}, jobID string) (*domain.VerificationResult, error)
}
// VerificationHandler handles HTTP requests for certificate deployment verification.
type VerificationHandler struct {
svc VerificationService
}
// NewVerificationHandler creates a new VerificationHandler.
func NewVerificationHandler(svc VerificationService) VerificationHandler {
return VerificationHandler{svc: svc}
}
// VerifyDeploymentRequest represents the request body for POST /api/v1/jobs/{id}/verify
type VerifyDeploymentRequest struct {
TargetID string `json:"target_id"`
ExpectedFingerprint string `json:"expected_fingerprint"`
ActualFingerprint string `json:"actual_fingerprint"`
Verified bool `json:"verified"`
Error string `json:"error,omitempty"`
}
// VerifyDeployment handles POST /api/v1/jobs/{id}/verify
// Agents submit verification results after attempting to probe the live TLS endpoint.
// This endpoint records the verification outcome (success or failure) and updates the job status.
func (h VerificationHandler) VerifyDeployment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract job ID from URL path: /api/v1/jobs/{id}/verify
jobID, err := extractIDFromPath(r.URL.Path, "/api/v1/jobs/", "/verify")
if err != nil || jobID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid job ID", middleware.GetRequestID(r.Context()))
return
}
// Parse request body
var req VerifyDeploymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err), middleware.GetRequestID(r.Context()))
return
}
// Validate required fields
if req.TargetID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "target_id is required", middleware.GetRequestID(r.Context()))
return
}
if req.ExpectedFingerprint == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "expected_fingerprint is required", middleware.GetRequestID(r.Context()))
return
}
if req.ActualFingerprint == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "actual_fingerprint is required", middleware.GetRequestID(r.Context()))
return
}
// Build verification result
result := &domain.VerificationResult{
JobID: jobID,
TargetID: req.TargetID,
ExpectedFingerprint: req.ExpectedFingerprint,
ActualFingerprint: req.ActualFingerprint,
Verified: req.Verified,
VerifiedAt: time.Now().UTC(),
Error: req.Error,
}
// Record result
if err := h.svc.RecordVerificationResult(r.Context(), result); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record verification result: %v", err), middleware.GetRequestID(r.Context()))
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"job_id": jobID,
"verified": req.Verified,
"verified_at": result.VerifiedAt,
})
}
// GetVerificationStatus handles GET /api/v1/jobs/{id}/verification
// Returns the current verification status for a job.
func (h VerificationHandler) GetVerificationStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract job ID from URL path: /api/v1/jobs/{id}/verification
jobID, err := extractIDFromPath(r.URL.Path, "/api/v1/jobs/", "/verification")
if err != nil || jobID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid job ID", middleware.GetRequestID(r.Context()))
return
}
// Get verification result
result, err := h.svc.GetVerificationResult(r.Context(), jobID)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get verification result: %v", err), middleware.GetRequestID(r.Context()))
return
}
// Return result
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}
// extractIDFromPath extracts the resource ID from a path like /api/v1/jobs/{id}/verify
// prefix: "/api/v1/jobs/" suffix: "/verify"
// Returns the extracted ID between prefix and suffix.
func extractIDFromPath(path, prefix, suffix string) (string, error) {
if len(path) <= len(prefix)+len(suffix) {
return "", fmt.Errorf("path too short")
}
if !HasPrefix(path, prefix) {
return "", fmt.Errorf("path does not start with prefix")
}
// Remove prefix
remainder := path[len(prefix):]
// Find suffix
idx := FindLastOccurrence(remainder, suffix)
if idx == -1 {
return "", fmt.Errorf("suffix not found")
}
return remainder[:idx], nil
}
// HasPrefix checks if a string starts with a prefix.
func HasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
// FindLastOccurrence finds the last occurrence of a substring (simplified version).
func FindLastOccurrence(s, substr string) int {
if len(substr) == 0 {
return len(s)
}
for i := len(s) - len(substr); i >= 0; i-- {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
@@ -0,0 +1,263 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// mockVerificationService is a test double for VerificationService.
type mockVerificationService struct {
recordErr error
getErr error
results map[string]*domain.VerificationResult
}
func (m *mockVerificationService) RecordVerificationResult(ctx interface{}, result *domain.VerificationResult) error {
if m.recordErr != nil {
return m.recordErr
}
if m.results == nil {
m.results = make(map[string]*domain.VerificationResult)
}
m.results[result.JobID] = result
return nil
}
func (m *mockVerificationService) GetVerificationResult(ctx interface{}, jobID string) (*domain.VerificationResult, error) {
if m.getErr != nil {
return nil, m.getErr
}
if m.results == nil {
m.results = make(map[string]*domain.VerificationResult)
}
return m.results[jobID], nil
}
func TestVerifyDeployment_Success(t *testing.T) {
mockSvc := &mockVerificationService{
results: make(map[string]*domain.VerificationResult),
}
handler := NewVerificationHandler(mockSvc)
req := VerifyDeploymentRequest{
TargetID: "t-nginx1",
ExpectedFingerprint: "abc123",
ActualFingerprint: "abc123",
Verified: true,
}
body, _ := json.Marshal(req)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test1/verify", bytes.NewReader(body))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
// Verify result was recorded
result := mockSvc.results["j-test1"]
if result == nil {
t.Error("expected verification result to be recorded")
}
if !result.Verified {
t.Error("expected Verified to be true")
}
}
func TestVerifyDeployment_FingerPrintMismatch(t *testing.T) {
mockSvc := &mockVerificationService{
results: make(map[string]*domain.VerificationResult),
}
handler := NewVerificationHandler(mockSvc)
req := VerifyDeploymentRequest{
TargetID: "t-apache1",
ExpectedFingerprint: "aaa111",
ActualFingerprint: "bbb222",
Verified: false,
}
body, _ := json.Marshal(req)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test2/verify", bytes.NewReader(body))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
result := mockSvc.results["j-test2"]
if result == nil {
t.Error("expected verification result to be recorded")
}
if result.Verified {
t.Error("expected Verified to be false")
}
}
func TestVerifyDeployment_MissingTargetID(t *testing.T) {
mockSvc := &mockVerificationService{}
handler := NewVerificationHandler(mockSvc)
req := VerifyDeploymentRequest{
ExpectedFingerprint: "abc123",
ActualFingerprint: "abc123",
Verified: true,
}
body, _ := json.Marshal(req)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test3/verify", bytes.NewReader(body))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestVerifyDeployment_MissingExpectedFingerprint(t *testing.T) {
mockSvc := &mockVerificationService{}
handler := NewVerificationHandler(mockSvc)
req := VerifyDeploymentRequest{
TargetID: "t-nginx1",
ActualFingerprint: "abc123",
Verified: true,
}
body, _ := json.Marshal(req)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test4/verify", bytes.NewReader(body))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestVerifyDeployment_InvalidMethod(t *testing.T) {
mockSvc := &mockVerificationService{}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test5/verify", nil)
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status 405, got %d", w.Code)
}
}
func TestVerifyDeployment_InvalidJSON(t *testing.T) {
mockSvc := &mockVerificationService{}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test6/verify", bytes.NewBufferString("invalid json"))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestGetVerificationStatus_Success(t *testing.T) {
now := time.Now().UTC()
fp := "xyz789"
mockSvc := &mockVerificationService{
results: map[string]*domain.VerificationResult{
"j-test7": {
JobID: "j-test7",
TargetID: "t-haproxy1",
ExpectedFingerprint: "xyz789",
ActualFingerprint: fp,
Verified: true,
VerifiedAt: now,
},
},
}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test7/verification", nil)
w := httptest.NewRecorder()
handler.GetVerificationStatus(w, httpReq)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var result domain.VerificationResult
json.NewDecoder(w.Body).Decode(&result)
if result.JobID != "j-test7" {
t.Errorf("expected job ID j-test7, got %s", result.JobID)
}
if !result.Verified {
t.Error("expected Verified to be true")
}
}
func TestGetVerificationStatus_InvalidMethod(t *testing.T) {
mockSvc := &mockVerificationService{}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test8/verification", nil)
w := httptest.NewRecorder()
handler.GetVerificationStatus(w, httpReq)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status 405, got %d", w.Code)
}
}
func TestVerifyDeployment_ServiceError(t *testing.T) {
mockSvc := &mockVerificationService{
recordErr: ErrServiceUnavailable,
}
handler := NewVerificationHandler(mockSvc)
req := VerifyDeploymentRequest{
TargetID: "t-nginx1",
ExpectedFingerprint: "abc123",
ActualFingerprint: "abc123",
Verified: true,
}
body, _ := json.Marshal(req)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test9/verify", bytes.NewReader(body))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
var ErrServiceUnavailable = NewServiceError("service unavailable")
func NewServiceError(msg string) error {
return &serviceError{msg: msg}
}
type serviceError struct {
msg string
}
func (e *serviceError) Error() string {
return e.msg
}
+5
View File
@@ -62,6 +62,7 @@ func (r *Router) RegisterHandlers(
health handler.HealthHandler,
discovery handler.DiscoveryHandler,
networkScan handler.NetworkScanHandler,
verification handler.VerificationHandler,
) {
// Health endpoints (no auth middleware — must always be accessible)
r.mux.Handle("GET /health", middleware.Chain(
@@ -207,6 +208,10 @@ func (r *Router) RegisterHandlers(
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.UpdateNetworkScanTarget))
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.DeleteNetworkScanTarget))
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan))
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(verification.VerifyDeployment))
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(verification.GetVerificationStatus))
}
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
+13
View File
@@ -23,6 +23,7 @@ type Config struct {
Notifiers NotifierConfig
NetworkScan NetworkScanConfig
EST ESTConfig
Verification VerificationConfig
}
// NotifierConfig contains configuration for notification connectors.
@@ -97,6 +98,13 @@ type NetworkScanConfig struct {
ScanInterval time.Duration // How often to run network scans (default 6h)
}
// VerificationConfig controls post-deployment TLS verification behavior.
type VerificationConfig struct {
Enabled bool // Enable verification (default true)
Timeout time.Duration // Timeout for TLS probe (default 10s)
Delay time.Duration // Wait before verification after deployment (default 2s)
}
// ServerConfig contains HTTP server configuration.
type ServerConfig struct {
Host string
@@ -204,6 +212,11 @@ func Load() (*Config, error) {
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
},
Verification: VerificationConfig{
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
},
}
if err := cfg.Validate(); err != nil {
+303
View File
@@ -0,0 +1,303 @@
package caddy
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the Caddy deployment target configuration.
// Caddy supports both API-based and file-based certificate deployment.
// In API mode, certificates are posted to the Caddy admin API.
// In file mode, certificates are written to a directory and Caddy reloads.
type Config struct {
AdminAPI string `json:"admin_api"` // Caddy admin API URL (e.g., http://localhost:2019, default: http://localhost:2019)
CertDir string `json:"cert_dir"` // Directory for file-based deployment (used if API fails or mode=file)
CertFile string `json:"cert_file"` // Filename for certificate in file mode (default: cert.pem)
KeyFile string `json:"key_file"` // Filename for private key in file mode (default: key.pem)
Mode string `json:"mode"` // Deployment mode: "api" (default) or "file"
}
// Connector implements the target.Connector interface for Caddy servers.
// This connector runs on the AGENT side and handles local certificate deployment.
// It supports both API-based hot reload and file-based deployment.
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new Caddy target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// ValidateConfig checks that the Caddy configuration is valid.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid Caddy config: %w", err)
}
// Set defaults
if cfg.AdminAPI == "" {
cfg.AdminAPI = "http://localhost:2019"
}
if cfg.Mode == "" {
cfg.Mode = "api"
}
if cfg.CertFile == "" {
cfg.CertFile = "cert.pem"
}
if cfg.KeyFile == "" {
cfg.KeyFile = "key.pem"
}
// Validate mode
if cfg.Mode != "api" && cfg.Mode != "file" {
return fmt.Errorf("Caddy mode must be 'api' or 'file', got: %s", cfg.Mode)
}
c.logger.Info("validating Caddy configuration",
"admin_api", cfg.AdminAPI,
"mode", cfg.Mode)
// For file mode, verify directory exists
if cfg.Mode == "file" {
if cfg.CertDir == "" {
return fmt.Errorf("Caddy cert_dir is required in file mode")
}
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
return fmt.Errorf("Caddy cert directory does not exist: %s", cfg.CertDir)
}
// Test write access
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return fmt.Errorf("Caddy cert directory is not writable: %s (%w)", cfg.CertDir, err)
}
os.Remove(testFile)
}
c.config = &cfg
c.logger.Info("Caddy configuration validated")
return nil
}
// DeployCertificate deploys a certificate to Caddy using the configured mode.
// In API mode, it posts the certificate to Caddy's admin API.
// In file mode, it writes the certificate files and relies on Caddy's file watcher.
//
// Steps:
// 1. If mode="api": POST to Caddy admin API endpoint with certificate data
// 2. If mode="file" or API fails: Write certificate and key files to cert_dir
// 3. Log deployment status
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to Caddy",
"mode", c.config.Mode,
"admin_api", c.config.AdminAPI)
startTime := time.Now()
// Try API mode if configured
if c.config.Mode == "api" {
result, err := c.deployViaAPI(ctx, request)
if err == nil {
c.logger.Info("certificate deployed to Caddy via API",
"duration", time.Since(startTime).String())
return result, nil
}
c.logger.Warn("API deployment failed, falling back to file mode", "error", err)
}
// Fall back to file mode
return c.deployViaFile(ctx, request, startTime)
}
// deployViaAPI deploys a certificate using Caddy's admin API.
func (c *Connector) deployViaAPI(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Debug("attempting API deployment", "url", c.config.AdminAPI)
// Build the certificate payload with combined cert and chain
certData := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
}
payload := map[string]string{
"cert": certData,
"key": request.KeyPEM,
}
bodyBytes, _ := json.Marshal(payload)
apiURL := c.config.AdminAPI + "/config/apps/tls/certificates/load"
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create API request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to reach Caddy API: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return nil, fmt.Errorf("Caddy API returned status %d: %s", resp.StatusCode, string(body))
}
return &target.DeploymentResult{
Success: true,
TargetAddress: c.config.AdminAPI,
DeploymentID: fmt.Sprintf("caddy-api-%d", time.Now().Unix()),
Message: "Certificate deployed via Caddy admin API",
DeployedAt: time.Now(),
Metadata: map[string]string{
"method": "api",
"admin_url": c.config.AdminAPI,
"duration_ms": fmt.Sprintf("%d", time.Since(time.Now()).Milliseconds()),
},
}, nil
}
// deployViaFile deploys a certificate by writing files to the cert directory.
func (c *Connector) deployViaFile(ctx context.Context, request target.DeploymentRequest, startTime time.Time) (*target.DeploymentResult, error) {
c.logger.Debug("deploying via file mode", "cert_dir", c.config.CertDir)
if c.config.CertDir == "" {
return &target.DeploymentResult{
Success: false,
Message: "cert_dir required for file mode deployment",
DeployedAt: time.Now(),
}, fmt.Errorf("cert_dir not configured for file mode")
}
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
// Write certificate with chain
certData := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
}
if err := os.WriteFile(certPath, []byte(certData), 0644); err != nil {
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
c.logger.Error("certificate deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: certPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Write private key
if request.KeyPEM != "" {
if err := os.WriteFile(keyPath, []byte(request.KeyPEM), 0600); err != nil {
errMsg := fmt.Sprintf("failed to write private key: %v", err)
c.logger.Error("key deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: keyPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to Caddy via file mode",
"duration", deploymentDuration.String(),
"cert_path", certPath,
"key_path", keyPath)
return &target.DeploymentResult{
Success: true,
TargetAddress: certPath,
DeploymentID: fmt.Sprintf("caddy-file-%d", time.Now().Unix()),
Message: "Certificate deployed to Caddy (file-based)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"method": "file",
"cert_path": certPath,
"key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
// For API mode, it doesn't perform additional validation.
// For file mode, it checks that the certificate and key files exist and are readable.
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating Caddy deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial,
"mode", c.config.Mode)
startTime := time.Now()
// For file mode, verify files exist
if c.config.Mode == "file" || c.config.CertDir != "" {
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
if _, err := os.Stat(certPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: certPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: keyPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
validationDuration := time.Since(startTime)
c.logger.Info("Caddy deployment validated successfully",
"duration", validationDuration.String())
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.AdminAPI,
Message: "Caddy certificate deployment validated",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"mode": c.config.Mode,
"admin_api": c.config.AdminAPI,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
@@ -0,0 +1,398 @@
package caddy_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/caddy"
)
func TestCaddyConnector_ValidateConfig_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
}
func TestCaddyConnector_ValidateConfig_InvalidJSON(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
connector := caddy.New(&caddy.Config{}, logger)
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestCaddyConnector_ValidateConfig_InvalidMode(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
Mode: "invalid",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for invalid mode")
}
}
func TestCaddyConnector_ValidateConfig_FileMode_MissingCertDir(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for missing cert_dir in file mode")
}
}
func TestCaddyConnector_ValidateConfig_DefaultsApplied(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
CertDir: tmpDir,
Mode: "file",
// Don't specify AdminAPI, CertFile, KeyFile - should use defaults
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
}
func TestCaddyConnector_DeployViaAPI_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Create a mock Caddy admin API server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
// Verify POST request with JSON body
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
var payload map[string]string
json.Unmarshal(body, &payload)
if payload["cert"] == "" {
t.Fatal("cert field missing in payload")
}
if payload["key"] == "" {
t.Fatal("key field missing in payload")
}
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := caddy.Config{
AdminAPI: server.URL,
Mode: "api",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
if !strings.Contains(result.Message, "API") {
t.Fatalf("expected API deployment message, got: %s", result.Message)
}
}
func TestCaddyConnector_DeployViaAPI_ServerError(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Create a mock Caddy admin API server that returns error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid certificate"))
}))
defer server.Close()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: server.URL,
CertDir: tmpDir,
Mode: "api",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
// API fails and falls back to file mode - should succeed
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed via file fallback, got: %s", result.Message)
}
if !strings.Contains(result.Message, "file") {
t.Fatalf("expected file deployment message after API failure, got: %s", result.Message)
}
}
func TestCaddyConnector_DeployViaFile_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
// Verify files were created
certPath := filepath.Join(tmpDir, "cert.pem")
keyPath := filepath.Join(tmpDir, "key.pem")
if _, err := os.Stat(certPath); os.IsNotExist(err) {
t.Fatalf("certificate file was not created: %s", certPath)
}
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Fatalf("key file was not created: %s", keyPath)
}
// Verify key file has correct permissions
keyInfo, _ := os.Stat(keyPath)
if keyInfo.Mode().Perm() != 0600 {
t.Fatalf("key file permissions are %o, expected 0600", keyInfo.Mode().Perm())
}
}
func TestCaddyConnector_DeployViaFile_WriteError(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: "/root/nonexistent",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err == nil {
t.Fatal("expected error for write failure")
}
if result.Success {
t.Fatal("deployment should fail")
}
}
func TestCaddyConnector_ValidateDeployment_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// Deploy a certificate
deployRequest := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
connector.DeployCertificate(ctx, deployRequest)
// Validate deployment
validateRequest := target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
}
result, err := connector.ValidateDeployment(ctx, validateRequest)
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !result.Valid {
t.Fatalf("validation should succeed, got: %s", result.Message)
}
if result.Serial != "123456" {
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
}
}
func TestCaddyConnector_ValidateDeployment_FileNotFound(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// Don't deploy, just validate
validateRequest := target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
}
result, err := connector.ValidateDeployment(ctx, validateRequest)
if err == nil {
t.Fatal("expected error for missing certificate file")
}
if result.Valid {
t.Fatal("validation should fail")
}
}
func TestCaddyConnector_APIMode_NoChain(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := caddy.Config{
AdminAPI: server.URL,
Mode: "api",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
// No ChainPEM
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
}
@@ -0,0 +1,208 @@
package traefik
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the Traefik deployment target configuration.
// Traefik uses a file provider that watches a directory for certificate files.
// When files change, Traefik automatically reloads without requiring a reload command.
type Config struct {
CertDir string `json:"cert_dir"` // Directory where Traefik watches for certificate files
CertFile string `json:"cert_file"` // Filename for certificate (default: cert.pem)
KeyFile string `json:"key_file"` // Filename for private key (default: key.pem)
}
// Connector implements the target.Connector interface for Traefik servers.
// This connector runs on the AGENT side and handles local certificate deployment.
// Traefik watches the configured directory and automatically reloads when files change.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new Traefik target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the certificate directory exists and is writable.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid Traefik config: %w", err)
}
if cfg.CertDir == "" {
return fmt.Errorf("Traefik cert_dir is required")
}
// Default filenames if not provided
if cfg.CertFile == "" {
cfg.CertFile = "cert.pem"
}
if cfg.KeyFile == "" {
cfg.KeyFile = "key.pem"
}
c.logger.Info("validating Traefik configuration",
"cert_dir", cfg.CertDir,
"cert_file", cfg.CertFile,
"key_file", cfg.KeyFile)
// Verify directory exists and is writable
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
return fmt.Errorf("Traefik cert directory does not exist: %s", cfg.CertDir)
}
// Try to write a test file to verify directory is writable
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return fmt.Errorf("Traefik cert directory is not writable: %s (%w)", cfg.CertDir, err)
}
// Clean up test file
os.Remove(testFile)
c.config = &cfg
c.logger.Info("Traefik configuration validated")
return nil
}
// DeployCertificate writes the certificate and key files to the configured directory.
// Traefik watches this directory and automatically reloads when files change.
//
// Steps:
// 1. Write certificate to cert_file with mode 0644 (readable by all)
// 2. Write private key to key_file with mode 0600 (private key permissions)
// 3. Traefik's file watcher automatically picks up the changes
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to Traefik",
"cert_dir", c.config.CertDir,
"cert_file", c.config.CertFile,
"key_file", c.config.KeyFile)
startTime := time.Now()
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
// Write certificate and chain combined with mode 0644 (readable by all)
certData := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
}
if err := os.WriteFile(certPath, []byte(certData), 0644); err != nil {
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
c.logger.Error("certificate deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: certPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Write private key with secure permissions (0600: rw-------)
if request.KeyPEM != "" {
if err := os.WriteFile(keyPath, []byte(request.KeyPEM), 0600); err != nil {
errMsg := fmt.Sprintf("failed to write private key: %v", err)
c.logger.Error("key deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: keyPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to Traefik successfully",
"duration", deploymentDuration.String(),
"cert_path", certPath,
"key_path", keyPath)
return &target.DeploymentResult{
Success: true,
TargetAddress: certPath,
DeploymentID: fmt.Sprintf("traefik-%d", time.Now().Unix()),
Message: "Certificate deployed to Traefik (file watcher will auto-reload)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"cert_path": certPath,
"key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the deployed certificate files are readable.
// It checks that both the certificate and key files exist and are accessible.
//
// Steps:
// 1. Verify certificate file exists and is readable
// 2. Verify key file exists and is readable
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating Traefik deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial)
startTime := time.Now()
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
// Verify certificate file exists and is readable
if _, err := os.Stat(certPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: certPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Verify key file exists and is readable
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: keyPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
validationDuration := time.Since(startTime)
c.logger.Info("Traefik deployment validated successfully",
"duration", validationDuration.String())
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: certPath,
Message: "Certificate and key files accessible",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"cert_path": certPath,
"key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
@@ -0,0 +1,291 @@
package traefik_test
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/traefik"
)
func TestTraefikConnector_ValidateConfig_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := traefik.Config{
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
}
func TestTraefikConnector_ValidateConfig_InvalidJSON(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
connector := traefik.New(&traefik.Config{}, logger)
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestTraefikConnector_ValidateConfig_MissingCertDir(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := traefik.Config{
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for missing cert_dir")
}
}
func TestTraefikConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := traefik.Config{
CertDir: "/nonexistent/directory",
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for non-existent directory")
}
}
func TestTraefikConnector_DeployCertificate_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := traefik.Config{
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
// Verify certificate file was created
certPath := filepath.Join(tmpDir, "cert.pem")
if _, err := os.Stat(certPath); os.IsNotExist(err) {
t.Fatalf("certificate file was not created: %s", certPath)
}
// Verify key file was created with correct permissions
keyPath := filepath.Join(tmpDir, "key.pem")
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Fatalf("key file was not created: %s", keyPath)
}
// Check key file permissions (should be 0600)
keyInfo, _ := os.Stat(keyPath)
perms := keyInfo.Mode().Perm()
if perms != 0600 {
t.Fatalf("key file permissions are %o, expected 0600", perms)
}
}
func TestTraefikConnector_DeployCertificate_WriteError(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Use a non-existent directory to trigger write error
cfg := traefik.Config{
CertDir: "/root/certctl/certs",
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err == nil {
t.Fatal("expected error for write failure")
}
if result.Success {
t.Fatal("deployment should fail")
}
}
func TestTraefikConnector_ValidateDeployment_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := traefik.Config{
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// First deploy a certificate
deployRequest := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
connector.DeployCertificate(ctx, deployRequest)
// Now validate
validateRequest := target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
}
result, err := connector.ValidateDeployment(ctx, validateRequest)
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !result.Valid {
t.Fatalf("validation should succeed, got: %s", result.Message)
}
if result.Serial != "123456" {
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
}
}
func TestTraefikConnector_ValidateDeployment_CertFileNotFound(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := traefik.Config{
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// Don't deploy anything, just validate
validateRequest := target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
}
result, err := connector.ValidateDeployment(ctx, validateRequest)
if err == nil {
t.Fatal("expected error for missing certificate file")
}
if result.Valid {
t.Fatal("validation should fail")
}
}
func TestTraefikConnector_DeployCertificate_WithoutChain(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := traefik.Config{
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// Deploy without chain
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
// Verify certificate file exists
certPath := filepath.Join(tmpDir, "cert.pem")
data, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("failed to read cert file: %v", err)
}
if string(data) != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n" {
t.Fatalf("certificate content mismatch")
}
}
func TestTraefikConnector_DefaultFilenames(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := traefik.Config{
CertDir: tmpDir,
// Don't specify CertFile and KeyFile, use defaults
}
connector := traefik.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
}
+7 -5
View File
@@ -75,9 +75,11 @@ const (
type TargetType string
const (
TargetTypeNGINX TargetType = "NGINX"
TargetTypeApache TargetType = "Apache"
TargetTypeHAProxy TargetType = "HAProxy"
TargetTypeF5 TargetType = "F5"
TargetTypeIIS TargetType = "IIS"
TargetTypeNGINX TargetType = "NGINX"
TargetTypeApache TargetType = "Apache"
TargetTypeHAProxy TargetType = "HAProxy"
TargetTypeF5 TargetType = "F5"
TargetTypeIIS TargetType = "IIS"
TargetTypeTraefik TargetType = "Traefik"
TargetTypeCaddy TargetType = "Caddy"
)
+16 -12
View File
@@ -7,18 +7,22 @@ import (
// Job represents a unit of work in the certificate control plane.
type Job struct {
ID string `json:"id"`
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
VerificationStatus VerificationStatus `json:"verification_status"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
VerificationError *string `json:"verification_error,omitempty"`
VerificationFp *string `json:"verification_fingerprint,omitempty"`
}
// JobType represents the classification of work to be performed.
+37
View File
@@ -0,0 +1,37 @@
package domain
import "time"
// VerificationStatus represents the status of certificate deployment verification.
type VerificationStatus string
const (
// VerificationPending: verification has not yet been performed.
VerificationPending VerificationStatus = "pending"
// VerificationSuccess: the live TLS endpoint serves the expected certificate.
VerificationSuccess VerificationStatus = "success"
// VerificationFailed: the live TLS endpoint does not serve the expected certificate.
VerificationFailed VerificationStatus = "failed"
// VerificationSkipped: verification was skipped (disabled or not applicable).
VerificationSkipped VerificationStatus = "skipped"
)
// VerificationResult represents the outcome of verifying a deployed certificate
// against the live TLS endpoint it should be serving.
type VerificationResult struct {
// JobID is the ID of the deployment job being verified.
JobID string `json:"job_id"`
// TargetID is the ID of the deployment target.
TargetID string `json:"target_id"`
// ExpectedFingerprint is the SHA-256 fingerprint of the certificate that was deployed.
ExpectedFingerprint string `json:"expected_fingerprint"`
// ActualFingerprint is the SHA-256 fingerprint of the certificate currently being served
// at the live TLS endpoint.
ActualFingerprint string `json:"actual_fingerprint"`
// Verified is true if expected and actual fingerprints match.
Verified bool `json:"verified"`
// VerifiedAt is the timestamp when verification was performed.
VerifiedAt time.Time `json:"verified_at"`
// Error is a non-empty error message if verification failed to complete.
Error string `json:"error,omitempty"`
}
+73
View File
@@ -0,0 +1,73 @@
package domain
import (
"testing"
"time"
)
func TestVerificationStatus_Constants(t *testing.T) {
tests := []struct {
name string
status VerificationStatus
expected string
}{
{"Pending", VerificationPending, "pending"},
{"Success", VerificationSuccess, "success"},
{"Failed", VerificationFailed, "failed"},
{"Skipped", VerificationSkipped, "skipped"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.status) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
}
})
}
}
func TestVerificationResult_Marshaling(t *testing.T) {
now := time.Now().UTC()
result := &VerificationResult{
JobID: "j-test123",
TargetID: "t-nginx1",
ExpectedFingerprint: "abc123def456",
ActualFingerprint: "abc123def456",
Verified: true,
VerifiedAt: now,
Error: "",
}
if result.JobID != "j-test123" {
t.Errorf("JobID mismatch: got %s", result.JobID)
}
if !result.Verified {
t.Error("expected Verified to be true")
}
if result.Error != "" {
t.Errorf("expected no error, got %s", result.Error)
}
}
func TestVerificationResult_WithError(t *testing.T) {
now := time.Now().UTC()
result := &VerificationResult{
JobID: "j-test456",
TargetID: "t-apache1",
ExpectedFingerprint: "aaa111bbb222",
ActualFingerprint: "ccc333ddd444",
Verified: false,
VerifiedAt: now,
Error: "connection timeout",
}
if result.Verified {
t.Error("expected Verified to be false")
}
if result.Error != "connection timeout" {
t.Errorf("expected error message, got %s", result.Error)
}
if result.ExpectedFingerprint == result.ActualFingerprint {
t.Error("expected fingerprints to differ")
}
}
+13
View File
@@ -81,6 +81,7 @@ func TestCertificateLifecycle(t *testing.T) {
healthHandler := handler.NewHealthHandler("none")
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
// EST handler — uses real Local CA issuer via ESTService
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
@@ -106,6 +107,7 @@ func TestCertificateLifecycle(t *testing.T) {
healthHandler,
discoveryHandler,
networkScanHandler,
verificationHandler,
)
r.RegisterESTHandlers(estHandler)
@@ -1208,3 +1210,14 @@ func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) er
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
return nil, nil
}
// mockVerificationService implements handler.VerificationService for integration tests.
type mockVerificationService struct{}
func (m *mockVerificationService) RecordVerificationResult(ctx interface{}, result *domain.VerificationResult) error {
return nil
}
func (m *mockVerificationService) GetVerificationResult(ctx interface{}, jobID string) (*domain.VerificationResult, error) {
return nil, fmt.Errorf("not found")
}
+2
View File
@@ -74,6 +74,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
healthHandler := handler.NewHealthHandler("none")
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
// EST handler — uses real Local CA issuer via ESTService
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
@@ -98,6 +99,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
healthHandler,
discoveryHandler,
networkScanHandler,
verificationHandler,
)
r.RegisterESTHandlers(estHandler)
+139
View File
@@ -0,0 +1,139 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// VerificationService handles recording and querying certificate deployment verification results.
type VerificationService struct {
jobRepo repository.JobRepository
auditService *AuditService
logger *slog.Logger
}
// NewVerificationService creates a new verification service.
func NewVerificationService(
jobRepo repository.JobRepository,
auditService *AuditService,
logger *slog.Logger,
) *VerificationService {
return &VerificationService{
jobRepo: jobRepo,
auditService: auditService,
logger: logger,
}
}
// RecordVerificationResult updates a job with the results of TLS endpoint verification.
// This records both success and failure results, along with timestamp and fingerprint comparison.
// An audit event is recorded for every verification result.
func (s *VerificationService) RecordVerificationResult(ctx context.Context, result *domain.VerificationResult) error {
if result == nil {
return fmt.Errorf("verification result is required")
}
if result.JobID == "" {
return fmt.Errorf("job ID is required")
}
// Get the current job to update it
job, err := s.jobRepo.Get(ctx, result.JobID)
if err != nil {
return fmt.Errorf("failed to fetch job for verification: %w", err)
}
// Determine verification status
var status domain.VerificationStatus
if result.Error != "" {
status = domain.VerificationFailed
} else if result.Verified {
status = domain.VerificationSuccess
} else {
status = domain.VerificationFailed
}
// Update job with verification results
job.VerificationStatus = status
job.VerifiedAt = &result.VerifiedAt
job.VerificationFp = &result.ActualFingerprint
if result.Error != "" {
job.VerificationError = &result.Error
}
if err := s.jobRepo.Update(ctx, job); err != nil {
if s.logger != nil {
s.logger.Error("failed to record verification result",
"job_id", result.JobID,
"error", err)
}
return fmt.Errorf("failed to update job with verification result: %w", err)
}
// Record audit event
auditEvent := "job_verification_success"
auditDetails := map[string]interface{}{
"job_id": result.JobID,
"target_id": result.TargetID,
"expected_fingerprint": result.ExpectedFingerprint,
"actual_fingerprint": result.ActualFingerprint,
"verified": result.Verified,
}
if result.Error != "" {
auditEvent = "job_verification_failed"
auditDetails["error"] = result.Error
}
s.auditService.RecordEvent(ctx, "agent", domain.ActorTypeAgent,
auditEvent, "job", result.JobID,
auditDetails)
if s.logger != nil {
s.logger.Info("recorded verification result",
"job_id", result.JobID,
"status", status,
"verified", result.Verified)
}
return nil
}
// GetVerificationResult retrieves the verification status and details for a job.
func (s *VerificationService) GetVerificationResult(ctx context.Context, jobID string) (*domain.VerificationResult, error) {
if jobID == "" {
return nil, fmt.Errorf("job ID is required")
}
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return nil, fmt.Errorf("failed to fetch job: %w", err)
}
result := &domain.VerificationResult{
JobID: job.ID,
Verified: job.VerificationStatus == domain.VerificationSuccess,
}
// If target ID is set, populate it
if job.TargetID != nil {
result.TargetID = *job.TargetID
}
// Populate fingerprints if available
if job.VerificationFp != nil {
result.ActualFingerprint = *job.VerificationFp
}
if job.VerificationError != nil {
result.Error = *job.VerificationError
}
if job.VerifiedAt != nil {
result.VerifiedAt = *job.VerifiedAt
}
return result, nil
}
+281
View File
@@ -0,0 +1,281 @@
package service
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// mockJobRepository is a test double for JobRepository.
type mockJobRepository struct {
jobs map[string]*domain.Job
err error
}
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
if m.err != nil {
return nil, m.err
}
job, ok := m.jobs[id]
if !ok {
return nil, errors.New("job not found")
}
return job, nil
}
func (m *mockJobRepository) Create(ctx context.Context, job *domain.Job) error {
m.jobs[job.ID] = job
return nil
}
func (m *mockJobRepository) Update(ctx context.Context, job *domain.Job) error {
if m.err != nil {
return m.err
}
m.jobs[job.ID] = job
return nil
}
func (m *mockJobRepository) List(ctx context.Context, filter *repository.JobFilter) ([]*domain.Job, error) {
return nil, nil
}
// mockAuditService is a test double for AuditService.
type mockAuditService struct {
events []interface{}
}
func (m *mockAuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, event string, resourceType string, resourceID string, details map[string]interface{}) {
m.events = append(m.events, map[string]interface{}{
"actor": actor,
"actor_type": actorType,
"event": event,
"resource_type": resourceType,
"resource_id": resourceID,
"details": details,
})
}
func TestVerificationService_RecordVerificationResult_Success(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{
jobs: map[string]*domain.Job{
"j-test1": {
ID: "j-test1",
Status: domain.JobStatusCompleted,
},
},
}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
result := &domain.VerificationResult{
JobID: "j-test1",
TargetID: "t-nginx1",
ExpectedFingerprint: "abc123",
ActualFingerprint: "abc123",
Verified: true,
VerifiedAt: time.Now().UTC(),
}
err := service.RecordVerificationResult(ctx, result)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Check job was updated
job, _ := mockJobRepo.Get(ctx, "j-test1")
if job.VerificationStatus != domain.VerificationSuccess {
t.Errorf("expected VerificationSuccess, got %s", job.VerificationStatus)
}
if !*job.VerifiedAt == result.VerifiedAt {
t.Errorf("verified_at mismatch")
}
// Check audit event was recorded
if len(mockAudit.events) != 1 {
t.Errorf("expected 1 audit event, got %d", len(mockAudit.events))
}
}
func TestVerificationService_RecordVerificationResult_Failed(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{
jobs: map[string]*domain.Job{
"j-test2": {
ID: "j-test2",
Status: domain.JobStatusCompleted,
},
},
}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
result := &domain.VerificationResult{
JobID: "j-test2",
TargetID: "t-apache1",
ExpectedFingerprint: "aaa111",
ActualFingerprint: "bbb222",
Verified: false,
VerifiedAt: time.Now().UTC(),
}
err := service.RecordVerificationResult(ctx, result)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
job, _ := mockJobRepo.Get(ctx, "j-test2")
if job.VerificationStatus != domain.VerificationFailed {
t.Errorf("expected VerificationFailed, got %s", job.VerificationStatus)
}
}
func TestVerificationService_RecordVerificationResult_WithError(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{
jobs: map[string]*domain.Job{
"j-test3": {
ID: "j-test3",
Status: domain.JobStatusCompleted,
},
},
}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
result := &domain.VerificationResult{
JobID: "j-test3",
TargetID: "t-haproxy1",
VerifiedAt: time.Now().UTC(),
Error: "connection refused",
}
err := service.RecordVerificationResult(ctx, result)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
job, _ := mockJobRepo.Get(ctx, "j-test3")
if job.VerificationStatus != domain.VerificationFailed {
t.Errorf("expected VerificationFailed, got %s", job.VerificationStatus)
}
if job.VerificationError == nil || *job.VerificationError != "connection refused" {
t.Error("expected verification error to be set")
}
}
func TestVerificationService_RecordVerificationResult_JobNotFound(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{
jobs: map[string]*domain.Job{},
}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
result := &domain.VerificationResult{
JobID: "j-nonexistent",
TargetID: "t-nginx1",
VerifiedAt: time.Now().UTC(),
}
err := service.RecordVerificationResult(ctx, result)
if err == nil {
t.Error("expected error for nonexistent job")
}
}
func TestVerificationService_RecordVerificationResult_MissingJobID(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{jobs: map[string]*domain.Job{}}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
result := &domain.VerificationResult{
TargetID: "t-nginx1",
VerifiedAt: time.Now().UTC(),
}
err := service.RecordVerificationResult(ctx, result)
if err == nil {
t.Error("expected error for missing job ID")
}
}
func TestVerificationService_RecordVerificationResult_NilResult(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{jobs: map[string]*domain.Job{}}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
err := service.RecordVerificationResult(ctx, nil)
if err == nil {
t.Error("expected error for nil result")
}
}
func TestVerificationService_GetVerificationResult_Success(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
targetID := "t-nginx1"
fp := "abc123"
mockJobRepo := &mockJobRepository{
jobs: map[string]*domain.Job{
"j-test1": {
ID: "j-test1",
TargetID: &targetID,
VerificationStatus: domain.VerificationSuccess,
VerifiedAt: &now,
VerificationFp: &fp,
},
},
}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
result, err := service.GetVerificationResult(ctx, "j-test1")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result.JobID != "j-test1" {
t.Errorf("expected job ID j-test1, got %s", result.JobID)
}
if !result.Verified {
t.Error("expected Verified to be true")
}
if result.ActualFingerprint != "abc123" {
t.Errorf("expected fingerprint abc123, got %s", result.ActualFingerprint)
}
}
func TestVerificationService_GetVerificationResult_NotFound(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{
jobs: map[string]*domain.Job{},
}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
_, err := service.GetVerificationResult(ctx, "j-nonexistent")
if err == nil {
t.Error("expected error for nonexistent job")
}
}
func TestVerificationService_GetVerificationResult_EmptyJobID(t *testing.T) {
ctx := context.Background()
mockJobRepo := &mockJobRepository{jobs: map[string]*domain.Job{}}
mockAudit := &mockAuditService{events: []interface{}{}}
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
_, err := service.GetVerificationResult(ctx, "")
if err == nil {
t.Error("expected error for empty job ID")
}
}