mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:51:30 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
451 lines
14 KiB
Go
451 lines
14 KiB
Go
package router
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/certctl-io/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 RFC 7030 hardening Phase 1: RegisterESTHandlers signature
|
|
// changed from `(handler.ESTHandler)` to `(map[string]handler.ESTHandler)`.
|
|
// The empty-PathID key preserves the legacy /.well-known/est/ root
|
|
// routes this test asserts.
|
|
est := handler.ESTHandler{}
|
|
r.RegisterESTHandlers(map[string]handler.ESTHandler{"": 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)
|
|
}
|
|
}
|
|
}
|