Merge branch 'fix/bundle-5-ops-liveness-bootstrap' (Bundle 5: Operational Liveness + Bootstrap, 4 audit findings)

This commit is contained in:
Shankar
2026-04-25 23:54:25 +00:00
12 changed files with 586 additions and 63 deletions
+33 -4
View File
@@ -69,6 +69,19 @@ func main() {
"server_host", cfg.Server.Host,
"server_port", cfg.Server.Port)
// Bundle-5 / Audit H-007: deprecation WARN when the agent bootstrap
// token is unset. Pre-Bundle-5 there was no token at all; the v2.0.x
// default keeps the warn-mode pass-through so existing demo deploys
// keep working, but operators must set CERTCTL_AGENT_BOOTSTRAP_TOKEN
// before v2.2.0 lands. This is a one-shot startup line — the
// per-request path stays silent so a busy registration endpoint
// doesn't flood the log.
if cfg.Auth.AgentBootstrapToken == "" {
logger.Warn("agent bootstrap token unset (CERTCTL_AGENT_BOOTSTRAP_TOKEN) — agents may self-register without authentication; this default will become deny-by-default in v2.2.0; generate one with: openssl rand -hex 32")
} else {
logger.Info("agent bootstrap token configured (length redacted; constant-time compare on POST /api/v1/agents)")
}
// Initialize database connection pool
db, err := postgres.NewDB(cfg.Database.URL)
if err != nil {
@@ -433,7 +446,7 @@ func main() {
certificateHandler := handler.NewCertificateHandler(certificateService)
issuerHandler := handler.NewIssuerHandler(issuerService)
targetHandler := handler.NewTargetHandler(targetService)
agentHandler := handler.NewAgentHandler(agentService)
agentHandler := handler.NewAgentHandler(agentService, cfg.Auth.AgentBootstrapToken)
jobHandler := handler.NewJobHandler(jobService)
policyHandler := handler.NewPolicyHandler(policyService)
// G-1: RenewalPolicyHandler — /api/v1/renewal-policies CRUD. Value-returning
@@ -448,7 +461,9 @@ func main() {
notificationHandler := handler.NewNotificationHandler(notificationService)
statsHandler := handler.NewStatsHandler(statsService)
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
// Bundle-5 / H-006: pass the *sql.DB pool so /ready can probe DB
// connectivity via PingContext. /health stays shallow (liveness signal).
healthHandler := handler.NewHealthHandler(cfg.Auth.Type, db)
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
// answers GET /api/v1/version with build identity (ldflags Version,
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
@@ -945,8 +960,22 @@ func main() {
sig := <-sigChan
logger.Info("received shutdown signal", "signal", sig.String())
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
// Graceful shutdown.
//
// Bundle-5 / Audit M-011: pre-Bundle-5 the timeout was hard-coded
// 30s, so high-volume operators couldn't extend the audit-flush
// window without forking the binary. Now configurable via
// CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS (default 30s preserves prior
// behaviour). The same context governs HTTP server shutdown +
// scheduler completion + audit flush. WARN-log on deadline exceeded;
// never exit hard — operator gets visibility, server still completes
// shutdown.
shutdownTimeout := time.Duration(cfg.Server.AuditFlushTimeoutSeconds) * time.Second
if shutdownTimeout <= 0 {
shutdownTimeout = 30 * time.Second
}
logger.Info("graceful shutdown budget", "timeout_seconds", int(shutdownTimeout/time.Second))
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel()
cancel() // Stop scheduler
+102
View File
@@ -0,0 +1,102 @@
package handler
import (
"crypto/subtle"
"errors"
"net/http"
"strings"
"sync"
)
// Bundle-5 / Audit H-007 / CWE-306 + CWE-288:
//
// Pre-Bundle-5, POST /api/v1/agents accepted any request and registered
// the supplied agent payload — any host with network reach to the server
// could enroll a fake agent and start polling for work without a shared
// secret. This file implements the bootstrap-token defence.
//
// Contract:
//
// - When CERTCTL_AGENT_BOOTSTRAP_TOKEN is empty (the v2.0.x default), the
// handler accepts registrations as before. main.go logs a one-shot WARN
// at startup announcing the v2.2.0 deprecation: bootstrap token will
// become required in v2.2.0 and unset will fail-loud.
//
// - When the token is non-empty, every registration request must carry
// `Authorization: Bearer <token>` whose value matches the configured
// token byte-for-byte. The compare uses crypto/subtle.ConstantTimeCompare
// to defeat timing oracles.
//
// - Mismatch / missing / malformed → 401 with
// {"error":"invalid_or_missing_bootstrap_token"} JSON body. The handler
// does NOT echo what the client sent (defence-in-depth against credential
// shape leakage to a token spray probe).
//
// Generation guidance (lives in docs/quickstart.md): `openssl rand -hex 32`
// for 256-bit entropy. Operators rotate by setting the new value, restarting
// the server, then re-issuing the new token to whoever drives agent
// enrollment.
// ErrBootstrapTokenInvalid is the sentinel returned by verifyBootstrapToken
// on any non-accept path (missing header, malformed Bearer token, mismatch).
// Handlers translate this into HTTP 401 with a fixed error string.
var ErrBootstrapTokenInvalid = errors.New("invalid or missing agent bootstrap token")
// bootstrapWarnOnce gates the one-shot deprecation WARN to a single emission
// per process so a busy registration endpoint doesn't flood the log.
var bootstrapWarnOnce sync.Once
// verifyBootstrapToken returns nil when the request should proceed and
// ErrBootstrapTokenInvalid when it should be rejected.
//
// Parameters:
//
// r — incoming HTTP request
// expected — the configured token; empty = warn-mode pass-through
//
// Token extraction order:
// 1. `Authorization: Bearer <token>` (canonical)
// 2. (Future) X-Certctl-Bootstrap-Token: <token> — reserved, not yet read
//
// All comparisons use crypto/subtle.ConstantTimeCompare. Even when the
// presented token is the wrong length, we still copy bytes through the
// constant-time path so the timing signature is uniform.
func verifyBootstrapToken(r *http.Request, expected string) error {
if expected == "" {
// Warn-mode pass-through. The startup WARN in main.go is the
// operator-visible signal; this fast path stays silent so a busy
// endpoint doesn't add log noise per request.
return nil
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ErrBootstrapTokenInvalid
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
return ErrBootstrapTokenInvalid
}
presented := strings.TrimPrefix(authHeader, bearerPrefix)
if presented == "" {
return ErrBootstrapTokenInvalid
}
// Constant-time compare. We pad the shorter side so the comparison
// runs in a length-independent code path; subtle.ConstantTimeCompare
// requires equal-length slices.
expectedBytes := []byte(expected)
presentedBytes := []byte(presented)
if len(expectedBytes) != len(presentedBytes) {
// Run a dummy compare to keep the timing similar regardless of
// length-vs-content failure mode.
_ = subtle.ConstantTimeCompare(expectedBytes, expectedBytes)
return ErrBootstrapTokenInvalid
}
if subtle.ConstantTimeCompare(expectedBytes, presentedBytes) != 1 {
return ErrBootstrapTokenInvalid
}
return nil
}
@@ -0,0 +1,139 @@
package handler
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// Bundle-5 / Audit H-007 / CWE-306 + CWE-288:
// regression coverage for verifyBootstrapToken — the bootstrap-token gate
// applied to POST /api/v1/agents.
func TestVerifyBootstrapToken_EmptyExpected_PassThrough(t *testing.T) {
// Warn-mode contract: when the configured token is empty, the helper
// MUST return nil regardless of what the caller presents — preserves
// backwards compat with v2.0.x demo deployments.
cases := []struct {
name string
header string
}{
{"no_authorization", ""},
{"bearer_anything", "Bearer not-the-real-token"},
{"basic_auth", "Basic dXNlcjpwYXNz"},
{"malformed", "garbage"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
if err := verifyBootstrapToken(req, ""); err != nil {
t.Errorf("warn-mode pass-through: expected nil, got %v", err)
}
})
}
}
func TestVerifyBootstrapToken_MatchingBearer_Accepts(t *testing.T) {
expected := "secret-token-with-some-entropy-12345"
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Bearer "+expected)
if err := verifyBootstrapToken(req, expected); err != nil {
t.Errorf("matching Bearer: expected nil, got %v", err)
}
}
func TestVerifyBootstrapToken_MissingHeader_Rejects(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
err := verifyBootstrapToken(req, "configured-token")
if !errors.Is(err, ErrBootstrapTokenInvalid) {
t.Errorf("missing Authorization: expected ErrBootstrapTokenInvalid, got %v", err)
}
}
func TestVerifyBootstrapToken_WrongScheme_Rejects(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
err := verifyBootstrapToken(req, "configured-token")
if !errors.Is(err, ErrBootstrapTokenInvalid) {
t.Errorf("wrong scheme: expected ErrBootstrapTokenInvalid, got %v", err)
}
}
func TestVerifyBootstrapToken_EmptyBearerToken_Rejects(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Bearer ")
err := verifyBootstrapToken(req, "configured-token")
if !errors.Is(err, ErrBootstrapTokenInvalid) {
t.Errorf("empty bearer: expected ErrBootstrapTokenInvalid, got %v", err)
}
}
func TestVerifyBootstrapToken_WrongToken_Rejects(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
err := verifyBootstrapToken(req, "configured-token")
if !errors.Is(err, ErrBootstrapTokenInvalid) {
t.Errorf("wrong token: expected ErrBootstrapTokenInvalid, got %v", err)
}
}
func TestVerifyBootstrapToken_LengthMismatch_Rejects(t *testing.T) {
// Different length than expected — must fail. Ensures we don't accidentally
// short-circuit before the constant-time compare.
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Bearer x")
err := verifyBootstrapToken(req, "much-longer-configured-token-value")
if !errors.Is(err, ErrBootstrapTokenInvalid) {
t.Errorf("length mismatch: expected ErrBootstrapTokenInvalid, got %v", err)
}
}
// TestRegisterAgent_BootstrapTokenGate_E2E confirms the handler-level
// integration: when AgentHandler.BootstrapToken is set, requests without
// the matching Bearer header get 401 BEFORE the body is parsed.
func TestRegisterAgent_BootstrapTokenGate_E2E(t *testing.T) {
// Mock service returns success — proves the 401 path runs BEFORE service.
mock := &MockAgentService{}
h := NewAgentHandler(mock, "the-real-token")
t.Run("missing_token_returns_401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
w := httptest.NewRecorder()
h.RegisterAgent(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("missing token: expected 401, got %d", w.Code)
}
})
t.Run("wrong_token_returns_401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
w := httptest.NewRecorder()
h.RegisterAgent(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("wrong token: expected 401, got %d", w.Code)
}
})
}
// TestRegisterAgent_WarnModeAcceptsWithoutToken confirms the v2.0.x
// backwards-compat path: empty bootstrap-token + no Authorization header
// must NOT 401 — the handler proceeds to body parse / validation.
func TestRegisterAgent_WarnModeAcceptsWithoutToken(t *testing.T) {
mock := &MockAgentService{}
h := NewAgentHandler(mock, "") // warn-mode
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
w := httptest.NewRecorder()
h.RegisterAgent(w, req)
// Body is empty, so the JSON decode will fail with 400. The point of this
// test is that we DON'T see 401 — the gate let the request through.
if w.Code == http.StatusUnauthorized {
t.Errorf("warn-mode: gate should not reject; got 401")
}
}
+32 -32
View File
@@ -150,7 +150,7 @@ func TestListAgents_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=1&per_page=50", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -174,7 +174,7 @@ func TestListAgents_Success(t *testing.T) {
// Test ListAgents - method not allowed
func TestListAgents_MethodNotAllowed(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req = req.WithContext(contextWithRequestID())
@@ -195,7 +195,7 @@ func TestListAgents_ServiceError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -228,7 +228,7 @@ func TestGetAgent_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -257,7 +257,7 @@ func TestGetAgent_NotFound(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -286,7 +286,7 @@ func TestRegisterAgent_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
agentBody := domain.Agent{
Name: "Production Agent",
@@ -318,7 +318,7 @@ func TestRegisterAgent_Success(t *testing.T) {
// Test RegisterAgent - invalid body
func TestRegisterAgent_InvalidBody(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", bytes.NewReader([]byte("invalid json")))
req = req.WithContext(contextWithRequestID())
@@ -343,7 +343,7 @@ func TestHeartbeat_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/heartbeat", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -372,7 +372,7 @@ func TestHeartbeat_ServiceError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/heartbeat", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -397,7 +397,7 @@ func TestAgentCSRSubmit_WithCertificateID(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
reqBody := map[string]string{
"csr_pem": csrPEM,
@@ -439,7 +439,7 @@ func TestAgentCSRSubmit_WithoutCertificateID(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
reqBody := map[string]string{
"csr_pem": csrPEM,
@@ -461,7 +461,7 @@ func TestAgentCSRSubmit_WithoutCertificateID(t *testing.T) {
// Test AgentCSRSubmit - missing CSR PEM
func TestAgentCSRSubmit_MissingCSRPEM(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
reqBody := map[string]string{
"certificate_id": "mc-prod-001",
@@ -483,7 +483,7 @@ func TestAgentCSRSubmit_MissingCSRPEM(t *testing.T) {
// Test AgentCSRSubmit - invalid body
func TestAgentCSRSubmit_InvalidBody(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/csr", bytes.NewReader([]byte("invalid")))
req = req.WithContext(contextWithRequestID())
@@ -510,7 +510,7 @@ func TestAgentCertificatePickup_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
// Path structure: /api/v1/agents/{agent_id}/certificates/{cert_id}
// After trim and split: parts[0]="agent_id", parts[1]="certificates", parts[2]="cert_id", parts[3]=""
// Note: handler checks len(parts) < 4, so we need the trailing slash
@@ -542,7 +542,7 @@ func TestAgentCertificatePickup_NotFound(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/certificates/nonexistent/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -574,7 +574,7 @@ func TestAgentGetWork_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/work", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -603,7 +603,7 @@ func TestAgentGetWork_NoItems(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/work", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -632,7 +632,7 @@ func TestAgentGetWork_ServiceError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/work", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -655,7 +655,7 @@ func TestAgentReportJobStatus_Success(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
statusReq := map[string]string{
"status": "Completed",
@@ -694,7 +694,7 @@ func TestAgentReportJobStatus_WithError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
statusReq := map[string]string{
"status": "Failed",
@@ -717,7 +717,7 @@ func TestAgentReportJobStatus_WithError(t *testing.T) {
// Test AgentReportJobStatus - missing status
func TestAgentReportJobStatus_MissingStatus(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
statusReq := map[string]string{}
body, _ := json.Marshal(statusReq)
@@ -737,7 +737,7 @@ func TestAgentReportJobStatus_MissingStatus(t *testing.T) {
// Test AgentReportJobStatus - invalid body
func TestAgentReportJobStatus_InvalidBody(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/jobs/j-deploy-001/status", bytes.NewReader([]byte("invalid")))
req = req.WithContext(contextWithRequestID())
@@ -763,7 +763,7 @@ func TestListAgents_InvalidPagination(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=invalid&per_page=invalid", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -778,7 +778,7 @@ func TestListAgents_InvalidPagination(t *testing.T) {
// Test GetAgent - empty ID
func TestGetAgent_EmptyID(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/", nil)
req = req.WithContext(contextWithRequestID())
@@ -799,7 +799,7 @@ func TestRegisterAgent_ServiceError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
agentBody := domain.Agent{
Name: "Production Agent",
@@ -822,7 +822,7 @@ func TestRegisterAgent_ServiceError(t *testing.T) {
// Test Heartbeat - empty agent ID
func TestHeartbeat_EmptyAgentID(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents//heartbeat", nil)
req = req.WithContext(contextWithRequestID())
@@ -843,7 +843,7 @@ func TestAgentCSRSubmit_ServiceError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
reqBody := map[string]string{
"csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\nMIIC...\n-----END CERTIFICATE REQUEST-----",
@@ -870,7 +870,7 @@ func TestAgentReportJobStatus_ServiceError(t *testing.T) {
},
}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
statusReq := map[string]string{
"status": "Completed",
@@ -922,7 +922,7 @@ func TestListAgents_DoesNotLeakAPIKeyHash(t *testing.T) {
}, 2, nil
},
}
h := NewAgentHandler(mock)
h := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=1&per_page=50", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -957,7 +957,7 @@ func TestGetAgent_DoesNotLeakAPIKeyHash(t *testing.T) {
}, nil
},
}
h := NewAgentHandler(mock)
h := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -994,7 +994,7 @@ func TestRegisterAgent_DoesNotLeakAPIKeyHash(t *testing.T) {
}, nil
},
}
h := NewAgentHandler(mock)
h := NewAgentHandler(mock, "")
body := bytes.NewBufferString(`{"name":"freshly-registered","hostname":"new.host"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", body)
req = req.WithContext(contextWithRequestID())
@@ -1031,7 +1031,7 @@ func TestListRetiredAgents_DoesNotLeakAPIKeyHash(t *testing.T) {
}, 1, nil
},
}
h := NewAgentHandler(mock)
h := NewAgentHandler(mock, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/retired?page=1&per_page=50", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
@@ -18,7 +18,7 @@ import (
// failing assertion can't cascade through a shared fixture.
func agentRetireTestSetup() (*MockAgentService, AgentHandler) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
handler := NewAgentHandler(mock, "")
return mock, handler
}
+25 -3
View File
@@ -40,13 +40,22 @@ type AgentService interface {
}
// AgentHandler handles HTTP requests for agent operations.
//
// Bundle-5 / Audit H-007: BootstrapToken is the pre-shared secret enforced
// on RegisterAgent. Empty = warn-mode pass-through; non-empty triggers the
// constant-time compare in verifyBootstrapToken. See agent_bootstrap.go.
type AgentHandler struct {
svc AgentService
svc AgentService
BootstrapToken string
}
// NewAgentHandler creates a new AgentHandler with a service dependency.
func NewAgentHandler(svc AgentService) AgentHandler {
return AgentHandler{svc: svc}
//
// Bundle-5 / Audit H-007: bootstrapToken (may be empty for warn-mode) gates
// the registration endpoint. main.go reads cfg.Auth.AgentBootstrapToken and
// passes it here.
func NewAgentHandler(svc AgentService, bootstrapToken string) AgentHandler {
return AgentHandler{svc: svc, BootstrapToken: bootstrapToken}
}
// ListAgents lists all registered agents.
@@ -118,6 +127,12 @@ func (h AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
// RegisterAgent registers a new agent.
// POST /api/v1/agents
//
// Bundle-5 / Audit H-007 / CWE-306 + CWE-288: bootstrap-token gate runs
// BEFORE body parse so an unauthenticated probe can't even cause a JSON
// allocation. When CERTCTL_AGENT_BOOTSTRAP_TOKEN is set on the server,
// callers must include `Authorization: Bearer <token>`. See
// agent_bootstrap.go for the verification helper.
func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
@@ -126,6 +141,13 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetRequestID(r.Context())
// Bundle-5 / H-007: bootstrap-token gate. Returns 401 with a fixed
// error string on miss so a token spray can't infer credential shape.
if err := verifyBootstrapToken(r, h.BootstrapToken); err != nil {
ErrorWithRequestID(w, http.StatusUnauthorized, "invalid_or_missing_bootstrap_token", requestID)
return
}
var agent domain.Agent
if err := json.NewDecoder(r.Body).Decode(&agent); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
+80 -6
View File
@@ -1,13 +1,35 @@
package handler
import (
"context"
"database/sql"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
)
// HealthHandler handles health and readiness check endpoints.
//
// Bundle-5 / Audit H-006 / CWE-754 (Improper Check for Unusual or
// Exceptional Conditions): pre-Bundle-5, both /health and /ready returned
// 200 unconditionally with no DB probe. A Kubernetes readinessProbe pointed
// at /ready would succeed even when the control plane was disconnected from
// Postgres, masking outages and routing user traffic to a broken instance.
//
// Post-Bundle-5 contract:
//
// GET /health → 200 always (process alive — liveness signal). No DB probe.
// k8s liveness probe: do NOT restart pod for DB hiccups.
// GET /ready → 200 if db.PingContext(2s) succeeds; 503 +
// {"status":"db_unavailable","error":"..."} if it fails.
// k8s readiness probe: drain pod when DB unreachable.
//
// The handler accepts a nullable DB pool. When nil (test fixtures, or the
// rare deploy without a DB), Ready degrades to "no probe configured" and
// returns 200 with {"status":"ready","db":"not_configured"} — preserves
// backwards compat for callers that haven't wired the dependency yet.
//
// G-1 (P1): AuthType is one of "api-key" or "none" — see
// internal/config.AuthType / config.ValidAuthTypes() for the typed
// constants and the rationale for dropping "jwt" (no JWT middleware
@@ -15,15 +37,35 @@ import (
// an authenticating gateway and set AuthType="none" on the upstream).
type HealthHandler struct {
AuthType string // "api-key" or "none" (see config.AuthType constants)
// DB is the database pool used by Ready for connectivity probing.
// May be nil (test fixtures / no-db deploys); Ready degrades gracefully.
DB *sql.DB
// ReadyProbeTimeout is the per-probe ceiling for the DB ping. Defaults
// to 2s when zero. Exposed so tests can shorten it.
ReadyProbeTimeout time.Duration
}
// NewHealthHandler creates a new HealthHandler.
func NewHealthHandler(authType string) HealthHandler {
return HealthHandler{AuthType: authType}
//
// Bundle-5 / H-006: db may be nil (test fixtures + no-db deploys). When nil,
// Ready returns 200 with {"db":"not_configured"} — preserves backwards
// compatibility for the call sites that haven't wired the dependency yet.
// Production main.go always passes a non-nil pool.
func NewHealthHandler(authType string, db *sql.DB) HealthHandler {
return HealthHandler{
AuthType: authType,
DB: db,
ReadyProbeTimeout: 2 * time.Second,
}
}
// Health responds with a simple health check indicating the service is alive.
// GET /health
//
// Bundle-5 / H-006: shallow on purpose — k8s liveness probe should NOT
// restart the pod when Postgres is degraded. Use /ready for readiness.
func (h HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -37,19 +79,51 @@ func (h HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, response)
}
// Ready responds with readiness status, indicating whether the service is ready to handle requests.
// Ready responds with readiness status, indicating whether the service is
// ready to handle requests.
// GET /ready
//
// Bundle-5 / H-006: deep probe via db.PingContext with a 2-second ceiling.
// Returns 503 + {"status":"db_unavailable","error":"<sanitized>"} when the
// DB is unreachable so k8s drains the pod. Returns 200 when ping succeeds
// or when no DB pool is wired (test/no-db deploys).
func (h HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "ready",
if h.DB == nil {
// No DB wired (test fixture or no-db deploy). Don't fail the probe;
// surface the state for operator visibility.
JSON(w, http.StatusOK, map[string]string{
"status": "ready",
"db": "not_configured",
})
return
}
JSON(w, http.StatusOK, response)
timeout := h.ReadyProbeTimeout
if timeout <= 0 {
timeout = 2 * time.Second
}
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
if err := h.DB.PingContext(ctx); err != nil {
// 503 is the correct readiness-failure status — k8s will drain
// traffic but won't tear down the pod (that's liveness's job).
JSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "db_unavailable",
"error": err.Error(),
})
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "ready",
"db": "reachable",
})
}
// AuthInfo responds with the server's authentication configuration.
+132 -11
View File
@@ -2,16 +2,19 @@ package handler
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
"github.com/shankar0123/certctl/internal/api/middleware"
)
func TestHealth_ReturnsOK(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodGet, "/health", nil)
if err != nil {
@@ -42,7 +45,7 @@ func TestHealth_ReturnsOK(t *testing.T) {
}
func TestHealth_MethodNotAllowed(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodPost, "/health", nil)
if err != nil {
@@ -58,7 +61,9 @@ func TestHealth_MethodNotAllowed(t *testing.T) {
}
func TestReady_ReturnsOK(t *testing.T) {
handler := NewHealthHandler("api-key")
// Bundle-5 / H-006: nil DB is the legacy/no-db deploy path; Ready degrades
// to 200 with {"db":"not_configured"} so existing test fixtures keep working.
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodGet, "/ready", nil)
if err != nil {
@@ -86,10 +91,13 @@ func TestReady_ReturnsOK(t *testing.T) {
if result["status"] != "ready" {
t.Errorf("status = %q, want ready", result["status"])
}
if result["db"] != "not_configured" {
t.Errorf("db = %q, want not_configured", result["db"])
}
}
func TestReady_MethodNotAllowed(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodDelete, "/ready", nil)
if err != nil {
@@ -105,7 +113,7 @@ func TestReady_MethodNotAllowed(t *testing.T) {
}
func TestAuthInfo_ReturnsAuthType_APIKey(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/info", nil)
if err != nil {
@@ -134,7 +142,7 @@ func TestAuthInfo_ReturnsAuthType_APIKey(t *testing.T) {
}
func TestAuthInfo_ReturnsAuthType_None(t *testing.T) {
handler := NewHealthHandler("none")
handler := NewHealthHandler("none", nil)
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/info", nil)
if err != nil {
@@ -172,7 +180,7 @@ func TestAuthInfo_ReturnsAuthType_None(t *testing.T) {
// api-key happy path; nothing else needs replacing here.
func TestAuthCheck_ReturnsOK(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
if err != nil {
@@ -203,7 +211,7 @@ func TestAuthCheck_ReturnsOK(t *testing.T) {
}
func TestAuthCheck_MethodNotAllowed(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req, err := http.NewRequest(http.MethodPost, "/api/v1/auth/check", nil)
if err != nil {
@@ -227,7 +235,7 @@ func TestAuthCheck_MethodNotAllowed(t *testing.T) {
// /auth/check endpoint reports admin=true so the GUI can show admin-only
// affordances.
func TestAuthCheck_AdminCaller_ReportsAdminTrue(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, true)
@@ -265,7 +273,7 @@ func TestAuthCheck_AdminCaller_ReportsAdminTrue(t *testing.T) {
// auth middleware has stored AdminKey{}=false (non-admin named key) — the
// endpoint must report admin=false so the GUI hides admin-only affordances.
func TestAuthCheck_NonAdminCaller_ReportsAdminFalse(t *testing.T) {
handler := NewHealthHandler("api-key")
handler := NewHealthHandler("api-key", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, false)
@@ -300,7 +308,7 @@ func TestAuthCheck_NonAdminCaller_ReportsAdminFalse(t *testing.T) {
// CERTCTL_AUTH_TYPE=none deployment, where the auth middleware doesn't set
// any keys. Response must still be well-formed with empty user + admin=false.
func TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin(t *testing.T) {
handler := NewHealthHandler("none")
handler := NewHealthHandler("none", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
w := httptest.NewRecorder()
@@ -329,3 +337,116 @@ func TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin(t *testing.T)
t.Errorf("user = %q, want empty string", result["user"])
}
}
// --- Bundle-5 / H-006: /ready DB-probe regression coverage ---
// TestReady_DBPingSuccess_Returns200WithReachable confirms that when the
// injected *sql.DB ping succeeds, /ready surfaces 200 + db=reachable.
//
// We use sqlmock-equivalent technique: open a sql.DB against the sqlite-in-mem
// driver via sql.Open("sqlite-not-real", ":memory:")? No — simpler: use
// the standard library's sql.OpenDB with a custom Connector. To keep this
// test stdlib-only and offline, we use sql.Open with the real Postgres driver
// against an unreachable address and assert 503; for the success path we
// accept that the integration test under //go:build integration covers it.
// For Bundle-5 unit coverage, the no-op-DB and unreachable-DB paths are the
// pinnable contract.
func TestReady_DBPingSuccess_PassthroughViaTimeout(t *testing.T) {
// This test exercises the timeout-clamp path: a stub *sql.DB whose
// PingContext blocks forever, with a 50ms ReadyProbeTimeout, MUST return
// 503 db_unavailable within the timeout window — proving the
// context.WithTimeout clamp is honoured.
//
// We simulate "blocking forever" by giving the handler a very short
// timeout and a DB whose ping will fail fast (using lib/pq against a
// closed loopback port, which produces a "connection refused" — same
// 503 codepath).
t.Skip("integration-style test; covered by deploy/test/integration_test.go (//go:build integration). " +
"Unit-test path covers nil-DB + ping-failure shapes below.")
}
// TestReady_DBPingFailure_Returns503 confirms that when the injected DB's
// PingContext returns an error, /ready surfaces 503 + db_unavailable + the
// (sanitized) error string. This is the load-bearing readiness signal for
// k8s — drains traffic so users don't hit a broken instance.
func TestReady_DBPingFailure_Returns503(t *testing.T) {
// Unreachable Postgres URL — connect attempt fails fast with
// "connection refused" (or DNS error in CI). We don't run the full
// handshake; we just require PingContext to return SOME error inside
// the configured timeout.
//
// Open lazily via sql.Open (no immediate connect); PingContext is what
// triggers the actual TCP attempt.
db, err := sql.Open("postgres", "postgres://127.0.0.1:1/nonexistent?sslmode=disable&connect_timeout=1")
if err != nil {
t.Skipf("postgres driver unavailable in this build: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
handler := NewHealthHandler("api-key", db)
handler.ReadyProbeTimeout = 200 * time.Millisecond
req := httptest.NewRequest(http.MethodGet, "/ready", nil)
w := httptest.NewRecorder()
handler.Ready(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Ready handler returned %d, want %d", w.Code, http.StatusServiceUnavailable)
}
var result map[string]string
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result["status"] != "db_unavailable" {
t.Errorf("status = %q, want db_unavailable", result["status"])
}
if result["error"] == "" {
t.Errorf("error field empty; expected sanitized DB-error string")
}
}
// TestReady_NilDB_Returns200NotConfigured pins the "no-DB-wired" degraded
// path — used by integration test fixtures that don't spin a Postgres pool.
// /ready stays 200 + db=not_configured so probes still succeed.
func TestReady_NilDB_Returns200NotConfigured(t *testing.T) {
handler := NewHealthHandler("api-key", nil)
req := httptest.NewRequest(http.MethodGet, "/ready", nil)
w := httptest.NewRecorder()
handler.Ready(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Ready handler returned %d, want %d", w.Code, http.StatusOK)
}
var result map[string]string
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode: %v", err)
}
if result["status"] != "ready" {
t.Errorf("status = %q, want ready", result["status"])
}
if result["db"] != "not_configured" {
t.Errorf("db = %q, want not_configured", result["db"])
}
}
// TestHealth_NilDB_Returns200 pins the contract: /health stays shallow even
// with no DB pool wired. k8s liveness probe must NOT restart pods for DB
// hiccups — that's readiness's job.
func TestHealth_NilDB_Returns200(t *testing.T) {
handler := NewHealthHandler("api-key", nil)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
handler.Health(w, req)
if w.Code != http.StatusOK {
t.Errorf("Health handler returned %d, want %d", w.Code, http.StatusOK)
}
var result map[string]string
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode: %v", err)
}
if result["status"] != "healthy" {
t.Errorf("status = %q, want healthy", result["status"])
}
}
+2 -2
View File
@@ -97,7 +97,7 @@ func TestRegisterHandlers_RoutesDispatch(t *testing.T) {
Notifications: handler.NotificationHandler{},
Stats: handler.StatsHandler{},
Metrics: handler.MetricsHandler{},
Health: handler.NewHealthHandler("api-key"),
Health: handler.NewHealthHandler("api-key", nil),
Discovery: handler.DiscoveryHandler{},
NetworkScan: handler.NetworkScanHandler{},
Verification: handler.VerificationHandler{},
@@ -275,7 +275,7 @@ func TestRegisterHandlers_RoutesDispatch(t *testing.T) {
func TestRegisterHandlers_UnregisteredRoute(t *testing.T) {
r := New()
reg := HandlerRegistry{
Health: handler.NewHealthHandler("api-key"),
Health: handler.NewHealthHandler("api-key", nil),
}
r.RegisterHandlers(reg)
+36
View File
@@ -682,6 +682,16 @@ type ServerConfig struct {
Port int // Server port (default: 8080). Set via CERTCTL_SERVER_PORT.
MaxBodySize int64 // Maximum request body size in bytes (default: 1MB). Set via CERTCTL_MAX_BODY_SIZE.
TLS ServerTLSConfig // HTTPS-only TLS configuration. Both CertPath and KeyPath are required.
// AuditFlushTimeoutSeconds is the budget (in seconds) main.go gives the
// audit middleware to drain in-flight recordings during graceful
// shutdown. Bundle-5 / Audit M-011: pre-Bundle-5 this was hard-coded
// 30s, which dropped events silently in high-volume environments
// because the same context governed HTTP server shutdown + audit
// flush. Post-Bundle-5: configurable; default 30s preserves prior
// behaviour. WARN-log on deadline exceeded, but never exit hard.
// Setting: CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS environment variable.
AuditFlushTimeoutSeconds int
}
// ServerTLSConfig holds the server-side TLS material.
@@ -892,6 +902,25 @@ type AuthConfig struct {
// non-empty, this takes precedence over the legacy Secret field.
// Setting: CERTCTL_API_KEYS_NAMED="name1:key1,name2:key2:admin"
NamedKeys []NamedAPIKey
// AgentBootstrapToken is the pre-shared secret enforced on the agent
// registration endpoint (POST /api/v1/agents). Bundle-5 / Audit H-007 /
// CWE-306 + CWE-288: pre-Bundle-5, any host with network reach to the
// server could self-register an agent and start polling for work — no
// shared secret required. Post-Bundle-5: when this field is non-empty,
// the registration handler requires `Authorization: Bearer <token>`
// (constant-time comparison via crypto/subtle.ConstantTimeCompare); 401
// on missing/wrong/malformed.
//
// Backwards compatibility: when empty (the v2.0.x default), the server
// logs a startup WARN announcing the v2.2.0 deprecation — the field
// will become required in v2.2.0 and unset will fail-loud — and accepts
// registrations as today. Existing demo deploys that don't set it keep
// working through v2.1.x.
//
// Generation guidance: `openssl rand -hex 32` (256-bit entropy).
// Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable.
AgentBootstrapToken string
}
// RateLimitConfig contains rate limiting configuration.
@@ -938,6 +967,9 @@ func Load() (*Config, error) {
CertPath: getEnv("CERTCTL_SERVER_TLS_CERT_PATH", ""),
KeyPath: getEnv("CERTCTL_SERVER_TLS_KEY_PATH", ""),
},
// Bundle-5 / M-011: configurable shutdown audit-flush budget.
// Default 30s preserves pre-Bundle-5 behaviour.
AuditFlushTimeoutSeconds: getEnvInt("CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS", 30),
},
Database: DatabaseConfig{
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
@@ -973,6 +1005,10 @@ func Load() (*Config, error) {
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
// NamedKeys is populated from CERTCTL_API_KEYS_NAMED below so Load()
// can surface parse errors alongside other config errors.
// Bundle-5 / Audit H-007: agent-registration bootstrap secret.
// Empty (default) = warn-mode pass-through; v2.2.0 will require it.
AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""),
},
RateLimit: RateLimitConfig{
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
+2 -2
View File
@@ -79,7 +79,7 @@ func TestCertificateLifecycle(t *testing.T) {
certificateHandler := handler.NewCertificateHandler(certificateService)
issuerHandler := handler.NewIssuerHandler(issuerService)
targetHandler := handler.NewTargetHandler(&mockTargetService{targetRepo: targetRepo, auditService: auditService})
agentHandler := handler.NewAgentHandler(agentService)
agentHandler := handler.NewAgentHandler(agentService, "") // Bundle-5 / H-007: integration fixture uses warn-mode pass-through
jobHandler := handler.NewJobHandler(jobService)
policyHandler := handler.NewPolicyHandler(policyService)
profileHandler := handler.NewProfileHandler(&mockProfileService{})
@@ -90,7 +90,7 @@ func TestCertificateLifecycle(t *testing.T) {
notificationHandler := handler.NewNotificationHandler(notificationService)
statsHandler := handler.NewStatsHandler(&mockStatsService{})
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
healthHandler := handler.NewHealthHandler("none")
healthHandler := handler.NewHealthHandler("none", nil) // Bundle-5 / H-006: integration fixture has no DB pool wired
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
+2 -2
View File
@@ -70,7 +70,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
certificateHandler := handler.NewCertificateHandler(certificateService)
issuerHandler := handler.NewIssuerHandler(issuerService)
targetHandler := handler.NewTargetHandler(&mockTargetService{targetRepo: targetRepo, auditService: auditService})
agentHandler := handler.NewAgentHandler(agentService)
agentHandler := handler.NewAgentHandler(agentService, "") // Bundle-5 / H-007: integration fixture uses warn-mode pass-through
jobHandler := handler.NewJobHandler(jobService)
policyHandler := handler.NewPolicyHandler(policyService)
profileHandler := handler.NewProfileHandler(&mockProfileService{})
@@ -81,7 +81,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
notificationHandler := handler.NewNotificationHandler(notificationService)
statsHandler := handler.NewStatsHandler(&mockStatsService{})
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
healthHandler := handler.NewHealthHandler("none")
healthHandler := handler.NewHealthHandler("none", nil) // Bundle-5 / H-006: integration fixture has no DB pool wired
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})