mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 19:38:51 +00:00
5c8d37e0f0
Closes Audit-2026-04-25 H-006 (High), H-007 (High), M-011 (Medium),
L-006 (Low — verified-already-closed via C-1 master closure in v2.0.54).
Hardens the orchestrator-facing surface — k8s probes, agent enrollment,
shutdown audit drain, scheduler config plumbing.
What changed
- internal/api/handler/health.go — split contract:
* /health stays shallow 200 (k8s liveness — process alive)
* /ready accepts *sql.DB; runs db.PingContext(2s); 503 on failure
* Nil DB path returns 200 + db=not_configured (test fixtures)
- internal/api/handler/agent_bootstrap.go (NEW) — verifyBootstrapToken:
* empty expected = warn-mode pass-through
* non-empty = `Authorization: Bearer <token>` required
* crypto/subtle.ConstantTimeCompare; length-mismatch path runs dummy
compare to keep timing uniform
* ErrBootstrapTokenInvalid sentinel
- internal/api/handler/agents.go — RegisterAgent calls verifyBootstrapToken
BEFORE body parse so unauth probes don't even allocate a JSON decoder
- internal/config/config.go — two new env vars:
* CERTCTL_AGENT_BOOTSTRAP_TOKEN (Auth.AgentBootstrapToken)
* CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS (Server.AuditFlushTimeoutSeconds)
- cmd/server/main.go — 3 changes:
* pass *sql.DB into NewHealthHandler (H-006)
* pass cfg.Auth.AgentBootstrapToken into NewAgentHandler (H-007)
* configurable shutdown audit-flush timeout (M-011)
* one-shot startup WARN when bootstrap token unset (deprecation)
- new tests: agent_bootstrap_test.go (full deny/accept/warn-mode coverage,
constant-time compare path, length-mismatch); health_test.go extended
with /ready DB-probe failure (503), nil-DB pass-through, /health-shallow
L-006 verified
- cmd/server/main.go:557 already calls
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
per the C-1 master closure in v2.0.54. Bundle 5 confirms; no code change.
Threat model: TB-1 (operator/orchestrator), TB-2 (Agent↔Server).
- CWE-754 (Improper Check for Unusual or Exceptional Conditions) for H-006
- CWE-306 + CWE-288 (Missing Authentication for Critical Function) for H-007
Verification
- go vet ./... → clean
- go build ./... → clean
- go test -short -count=1 ./... → all packages pass
- targeted Bundle-5 regressions → all pass
- npx tsc --noEmit (web) → clean
- npx vitest run (web) → in-flight (sandbox 45s
ceiling exceeded; no failure markers in dot stream; no frontend
changes in this bundle so no regression risk)
- python3 yaml.safe_load(api/openapi.yaml) → 89 paths
Backward compatibility
- Bootstrap token defaults to empty (warn-mode) — existing demo
deployments unaffected. Server logs deprecation WARN; v2.2.0 will
require it.
- Audit flush timeout default 30s preserves prior behaviour.
- Helm chart already routes readiness probe to /ready (no chart change
needed); now /ready actually probes the DB.
Bundle 5 of the 2026-04-25 comprehensive audit.
447 lines
13 KiB
Go
447 lines
13 KiB
Go
package router
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/api/handler"
|
|
)
|
|
|
|
// TestNew_ReturnsValidRouter tests that New() returns a properly initialized router.
|
|
func TestNew_ReturnsValidRouter(t *testing.T) {
|
|
r := New()
|
|
if r == nil {
|
|
t.Fatal("expected non-nil router, got nil")
|
|
}
|
|
if r.mux == nil {
|
|
t.Fatal("expected non-nil mux, got nil")
|
|
}
|
|
if r.middleware == nil {
|
|
t.Fatal("expected non-nil middleware slice, got nil")
|
|
}
|
|
if len(r.middleware) != 0 {
|
|
t.Fatalf("expected empty middleware slice, got %d", len(r.middleware))
|
|
}
|
|
}
|
|
|
|
// TestNewWithMiddleware_InitializesMiddleware tests that NewWithMiddleware() applies middlewares.
|
|
func TestNewWithMiddleware_InitializesMiddleware(t *testing.T) {
|
|
called := false
|
|
mw := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
r := NewWithMiddleware(mw)
|
|
if len(r.middleware) != 1 {
|
|
t.Fatalf("expected 1 middleware, got %d", len(r.middleware))
|
|
}
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Register("GET /test", handler)
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if !called {
|
|
t.Error("middleware was not called")
|
|
}
|
|
}
|
|
|
|
// TestRegisterHandlers_RoutesDispatch verifies that RegisterHandlers registers all expected routes.
|
|
// We construct a HandlerRegistry where each handler method writes a unique marker,
|
|
// then verify the expected routes dispatch to the correct handlers.
|
|
func TestRegisterHandlers_RoutesDispatch(t *testing.T) {
|
|
// Create handlers that respond with a marker so we can verify dispatch.
|
|
// The handler structs have zero-value service dependencies which would panic
|
|
// on real calls, so we intercept at the HTTP level using a wrapper.
|
|
r := New()
|
|
|
|
// Track which handler was called
|
|
var lastCalled string
|
|
|
|
// Create a registry with marker-writing handlers using a recovery wrapper.
|
|
// Since zero-value handlers may panic when called (nil service), we wrap the
|
|
// mux in a panic-recovering middleware for this test.
|
|
recoverMW := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rv := recover(); rv != nil {
|
|
// Handler panicked due to nil service — that's expected.
|
|
// The important thing is that the route was matched.
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
reg := HandlerRegistry{
|
|
Certificates: handler.CertificateHandler{},
|
|
Issuers: handler.IssuerHandler{},
|
|
Targets: handler.TargetHandler{},
|
|
Agents: handler.AgentHandler{},
|
|
Jobs: handler.JobHandler{},
|
|
Policies: handler.PolicyHandler{},
|
|
Profiles: handler.ProfileHandler{},
|
|
Teams: handler.TeamHandler{},
|
|
Owners: handler.OwnerHandler{},
|
|
AgentGroups: handler.AgentGroupHandler{},
|
|
Audit: handler.AuditHandler{},
|
|
Notifications: handler.NotificationHandler{},
|
|
Stats: handler.StatsHandler{},
|
|
Metrics: handler.MetricsHandler{},
|
|
Health: handler.NewHealthHandler("api-key", nil),
|
|
Discovery: handler.DiscoveryHandler{},
|
|
NetworkScan: handler.NetworkScanHandler{},
|
|
Verification: handler.VerificationHandler{},
|
|
Export: handler.ExportHandler{},
|
|
Digest: handler.DigestHandler{},
|
|
}
|
|
|
|
r.RegisterHandlers(reg)
|
|
|
|
// Wrap the router with recovery middleware for testing
|
|
testHandler := recoverMW(r)
|
|
|
|
// Test a representative sample of routes. We just check that the route
|
|
// is registered (doesn't return 404). The handler may panic (caught by recoverMW)
|
|
// or return an error, but NOT 404.
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
// Health (registered outside middleware chain)
|
|
{"GET", "/health"},
|
|
{"GET", "/ready"},
|
|
{"GET", "/api/v1/auth/info"},
|
|
{"GET", "/api/v1/auth/check"},
|
|
|
|
// Certificates CRUD
|
|
{"GET", "/api/v1/certificates"},
|
|
{"POST", "/api/v1/certificates"},
|
|
{"GET", "/api/v1/certificates/mc-test"},
|
|
{"PUT", "/api/v1/certificates/mc-test"},
|
|
{"DELETE", "/api/v1/certificates/mc-test"},
|
|
{"GET", "/api/v1/certificates/mc-test/versions"},
|
|
{"GET", "/api/v1/certificates/mc-test/deployments"},
|
|
{"POST", "/api/v1/certificates/mc-test/renew"},
|
|
{"POST", "/api/v1/certificates/mc-test/deploy"},
|
|
{"POST", "/api/v1/certificates/mc-test/revoke"},
|
|
|
|
// Export
|
|
{"GET", "/api/v1/certificates/mc-test/export/pem"},
|
|
|
|
// NOTE: CRL/OCSP moved out of /api/v1/* in M-006. They are now served
|
|
// unauthenticated at /.well-known/pki/* via RegisterPKIHandlers and
|
|
// are verified in TestRegisterPKIHandlers_AllPaths below.
|
|
|
|
// Issuers
|
|
{"GET", "/api/v1/issuers"},
|
|
{"POST", "/api/v1/issuers"},
|
|
{"GET", "/api/v1/issuers/iss-test"},
|
|
{"PUT", "/api/v1/issuers/iss-test"},
|
|
{"DELETE", "/api/v1/issuers/iss-test"},
|
|
{"POST", "/api/v1/issuers/iss-test/test"},
|
|
|
|
// Targets
|
|
{"GET", "/api/v1/targets"},
|
|
{"POST", "/api/v1/targets"},
|
|
{"GET", "/api/v1/targets/t-test"},
|
|
{"PUT", "/api/v1/targets/t-test"},
|
|
{"DELETE", "/api/v1/targets/t-test"},
|
|
{"POST", "/api/v1/targets/t-test/test"},
|
|
|
|
// Agents
|
|
{"GET", "/api/v1/agents"},
|
|
{"POST", "/api/v1/agents"},
|
|
{"GET", "/api/v1/agents/agent-1"},
|
|
{"POST", "/api/v1/agents/agent-1/heartbeat"},
|
|
{"POST", "/api/v1/agents/agent-1/csr"},
|
|
{"GET", "/api/v1/agents/agent-1/certificates/mc-1"},
|
|
{"GET", "/api/v1/agents/agent-1/work"},
|
|
{"POST", "/api/v1/agents/agent-1/jobs/job-1/status"},
|
|
|
|
// Jobs
|
|
{"GET", "/api/v1/jobs"},
|
|
{"GET", "/api/v1/jobs/job-1"},
|
|
{"POST", "/api/v1/jobs/job-1/cancel"},
|
|
{"POST", "/api/v1/jobs/job-1/approve"},
|
|
{"POST", "/api/v1/jobs/job-1/reject"},
|
|
|
|
// Policies
|
|
{"GET", "/api/v1/policies"},
|
|
{"POST", "/api/v1/policies"},
|
|
{"GET", "/api/v1/policies/pol-1"},
|
|
{"PUT", "/api/v1/policies/pol-1"},
|
|
{"DELETE", "/api/v1/policies/pol-1"},
|
|
{"GET", "/api/v1/policies/pol-1/violations"},
|
|
|
|
// Profiles
|
|
{"GET", "/api/v1/profiles"},
|
|
{"POST", "/api/v1/profiles"},
|
|
{"GET", "/api/v1/profiles/prof-1"},
|
|
{"PUT", "/api/v1/profiles/prof-1"},
|
|
{"DELETE", "/api/v1/profiles/prof-1"},
|
|
|
|
// Teams
|
|
{"GET", "/api/v1/teams"},
|
|
{"POST", "/api/v1/teams"},
|
|
{"GET", "/api/v1/teams/team-1"},
|
|
|
|
// Owners
|
|
{"GET", "/api/v1/owners"},
|
|
{"POST", "/api/v1/owners"},
|
|
{"GET", "/api/v1/owners/owner-1"},
|
|
|
|
// Agent Groups
|
|
{"GET", "/api/v1/agent-groups"},
|
|
{"POST", "/api/v1/agent-groups"},
|
|
{"GET", "/api/v1/agent-groups/ag-1"},
|
|
{"GET", "/api/v1/agent-groups/ag-1/members"},
|
|
|
|
// Audit
|
|
{"GET", "/api/v1/audit"},
|
|
{"GET", "/api/v1/audit/evt-1"},
|
|
|
|
// Notifications
|
|
{"GET", "/api/v1/notifications"},
|
|
{"GET", "/api/v1/notifications/notif-1"},
|
|
{"POST", "/api/v1/notifications/notif-1/read"},
|
|
|
|
// Stats
|
|
{"GET", "/api/v1/stats/summary"},
|
|
{"GET", "/api/v1/stats/certificates-by-status"},
|
|
{"GET", "/api/v1/stats/expiration-timeline"},
|
|
{"GET", "/api/v1/stats/job-trends"},
|
|
{"GET", "/api/v1/stats/issuance-rate"},
|
|
|
|
// Metrics
|
|
{"GET", "/api/v1/metrics"},
|
|
{"GET", "/api/v1/metrics/prometheus"},
|
|
|
|
// Discovery
|
|
{"POST", "/api/v1/agents/agent-1/discoveries"},
|
|
{"GET", "/api/v1/discovered-certificates"},
|
|
{"GET", "/api/v1/discovered-certificates/dc-1"},
|
|
{"POST", "/api/v1/discovered-certificates/dc-1/claim"},
|
|
{"POST", "/api/v1/discovered-certificates/dc-1/dismiss"},
|
|
{"GET", "/api/v1/discovery-scans"},
|
|
{"GET", "/api/v1/discovery-summary"},
|
|
|
|
// Network scan
|
|
{"GET", "/api/v1/network-scan-targets"},
|
|
{"POST", "/api/v1/network-scan-targets"},
|
|
{"GET", "/api/v1/network-scan-targets/nst-1"},
|
|
{"PUT", "/api/v1/network-scan-targets/nst-1"},
|
|
{"DELETE", "/api/v1/network-scan-targets/nst-1"},
|
|
{"POST", "/api/v1/network-scan-targets/nst-1/scan"},
|
|
|
|
// Verification
|
|
{"POST", "/api/v1/jobs/job-1/verify"},
|
|
{"GET", "/api/v1/jobs/job-1/verification"},
|
|
|
|
// Digest
|
|
{"GET", "/api/v1/digest/preview"},
|
|
{"POST", "/api/v1/digest/send"},
|
|
}
|
|
|
|
_ = lastCalled // suppress unused
|
|
|
|
for _, tc := range routes {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ServeHTTP(w, req)
|
|
|
|
// Route should NOT return 404 (route not found) or 405 (method not allowed)
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("route %s %s returned 404 — route not registered", tc.method, tc.path)
|
|
}
|
|
if w.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("route %s %s returned 405 — method not allowed", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegisterHandlers_UnregisteredRoute verifies 404 for non-existent route.
|
|
func TestRegisterHandlers_UnregisteredRoute(t *testing.T) {
|
|
r := New()
|
|
reg := HandlerRegistry{
|
|
Health: handler.NewHealthHandler("api-key", nil),
|
|
}
|
|
r.RegisterHandlers(reg)
|
|
|
|
req := httptest.NewRequest("GET", "/api/v1/nonexistent", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for nonexistent route, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestRegisterESTHandlers_AllPaths verifies EST route registration.
|
|
func TestRegisterESTHandlers_AllPaths(t *testing.T) {
|
|
r := New()
|
|
|
|
// EST handler with zero-value services will panic, so wrap with recovery
|
|
recoverMW := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rv := recover(); rv != nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
est := handler.ESTHandler{}
|
|
r.RegisterESTHandlers(est)
|
|
|
|
testHandler := recoverMW(r)
|
|
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{"GET", "/.well-known/est/cacerts"},
|
|
{"POST", "/.well-known/est/simpleenroll"},
|
|
{"POST", "/.well-known/est/simplereenroll"},
|
|
{"GET", "/.well-known/est/csrattrs"},
|
|
}
|
|
|
|
for _, tc := range routes {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ServeHTTP(w, req)
|
|
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("EST route %s %s returned 404 — route not registered", tc.method, tc.path)
|
|
}
|
|
if w.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("EST route %s %s returned 405", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRegisterPKIHandlers_AllPaths verifies that RegisterPKIHandlers registers
|
|
// the two RFC-compliant unauthenticated endpoints relocated in M-006:
|
|
//
|
|
// - GET /.well-known/pki/crl/{issuer_id} (RFC 5280 §5 DER CRL)
|
|
// - GET /.well-known/pki/ocsp/{issuer_id}/{serial} (RFC 6960 §2.1 OCSP)
|
|
//
|
|
// Registration and middleware gating are complementary: this test proves the
|
|
// router matches the path; the unauthenticated contract is enforced separately
|
|
// by cmd/server/main.go's finalHandler routing /.well-known/pki/* through the
|
|
// noAuthHandler.
|
|
func TestRegisterPKIHandlers_AllPaths(t *testing.T) {
|
|
r := New()
|
|
|
|
// Zero-value CertificateHandler will panic on real calls; the only thing
|
|
// this test is verifying is that the route dispatches (i.e. the URL
|
|
// pattern is registered), so catch the downstream panic.
|
|
recoverMW := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rv := recover(); rv != nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
r.RegisterPKIHandlers(handler.CertificateHandler{})
|
|
testHandler := recoverMW(r)
|
|
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{"GET", "/.well-known/pki/crl/iss-local"},
|
|
{"GET", "/.well-known/pki/ocsp/iss-local/01ABCDEF"},
|
|
}
|
|
|
|
for _, tc := range routes {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ServeHTTP(w, req)
|
|
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("PKI route %s %s returned 404 — route not registered", tc.method, tc.path)
|
|
}
|
|
if w.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("PKI route %s %s returned 405", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetMux_ReturnsUnderlyingMux tests that GetMux returns the underlying mux.
|
|
func TestGetMux_ReturnsUnderlyingMux(t *testing.T) {
|
|
r := New()
|
|
mux := r.GetMux()
|
|
if mux == nil {
|
|
t.Fatal("expected non-nil mux from GetMux, got nil")
|
|
}
|
|
if mux != r.mux {
|
|
t.Error("GetMux should return the underlying mux")
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareOrder tests that middlewares are applied in the correct order.
|
|
func TestMiddlewareOrder(t *testing.T) {
|
|
var order []string
|
|
|
|
mw1 := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
order = append(order, "mw1-before")
|
|
next.ServeHTTP(w, r)
|
|
order = append(order, "mw1-after")
|
|
})
|
|
}
|
|
|
|
mw2 := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
order = append(order, "mw2-before")
|
|
next.ServeHTTP(w, r)
|
|
order = append(order, "mw2-after")
|
|
})
|
|
}
|
|
|
|
r := NewWithMiddleware(mw1, mw2)
|
|
|
|
r.RegisterFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
|
|
order = append(order, "handler")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
expected := []string{"mw1-before", "mw2-before", "handler", "mw2-after", "mw1-after"}
|
|
|
|
if len(order) != len(expected) {
|
|
t.Fatalf("middleware order length mismatch: expected %d, got %d", len(expected), len(order))
|
|
}
|
|
|
|
for i, v := range order {
|
|
if v != expected[i] {
|
|
t.Errorf("middleware order[%d]: expected %q, got %q", i, expected[i], v)
|
|
}
|
|
}
|
|
}
|