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