Files
certctl/internal/api/router/router_test.go
T
Shankar 5c8d37e0f0 fix(bundle-5): Operational Liveness + Bootstrap — 4 audit findings closed
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.
2026-04-25 23:54:18 +00:00

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)
}
}
}