mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
a808948397
EST RFC 7030 hardening master bundle Phases 0 + 1 of 13. Lays the
foundation for the remaining hardening phases (mTLS auth, HTTP Basic
auth, channel binding, server-keygen, admin observability, GUI, libest
e2e) without changing existing operator behavior — backward-compat
shim preserves the v2.0.66 single-issuer flat env-var setup.
WHAT LANDS:
Phase 0 — Frozen decisions
9 frozen decisions documented in
cowork/est-rfc7030-hardening-prompt.md::Phase 0 frozen decisions
(auth modes mTLS+Basic at GA; RFC 9266 channel binding; multi-profile
env-var family CERTCTL_EST_PROFILES; mTLS sibling URL
/.well-known/est-mtls/<pathID>; serverkeygen ships V2; fullcmc
deferred; renewal device-driven per RFC 7030 §4.2.2; csrattrs
algorithm allow-list profile-derived; libest as e2e reference).
Phase 1 — Multi-profile config + per-profile dispatch
internal/config/config.go: extended ESTConfig with Profiles slice;
added ESTProfileConfig struct with all field contracts (PathID +
IssuerID + ProfileID + EnrollmentPassword + MTLSEnabled +
MTLSClientCATrustBundlePath + ChannelBindingRequired +
AllowedAuthModes + RateLimitPerPrincipal24h + ServerKeygenEnabled).
Forward-looking fields (mTLS, HTTP Basic, channel binding,
rate limit, server-keygen) are dormant in Phase 1 — Phase 2-5 wire
the corresponding handlers; Validate() gates ensure operators can't
set incoherent combinations (MTLSEnabled=true without bundle path,
basic auth without password, mtls auth mode without MTLSEnabled,
ChannelBindingRequired without mTLS, ServerKeygenEnabled without
ProfileID).
loadESTProfilesFromEnv: mirrors loadSCEPProfilesFromEnv exactly.
Reads CERTCTL_EST_PROFILES=corp,iot,wifi and per-profile env vars
CERTCTL_EST_PROFILE_<NAME>_*. Lowercase PathID, uppercase env-var
name. parseAuthModes handles comma-separated normalization.
mergeESTLegacyIntoProfiles: back-compat shim. When CERTCTL_EST_PROFILES
is unset AND CERTCTL_EST_ENABLED=true, synthesizes a single-element
Profiles[0] with PathID="" so existing /.well-known/est/
operators see no behavior change.
validESTPathID + validESTAuthMode: shape validators. PathID matches
[a-z0-9-]+ with no leading/trailing hyphen (mirrors validSCEPPathID
exactly). Auth mode is one of {mtls, basic}.
Per-profile Validate(): refuses every documented misconfiguration
with operator-greppable error messages naming the offending profile
index + PathID + field. Mirrors the SCEP audit-closure pattern.
internal/api/router/router.go: refactored RegisterESTHandlers from
single-handler to map[string]ESTHandler. Empty PathID maps to legacy
/.well-known/est/ root (literal-string r.Register calls preserve
openapi-parity scanner behavior). Non-empty PathIDs dynamic-register
/.well-known/est/<pathID>/{cacerts,simpleenroll,simplereenroll,csrattrs}.
Mirrors the SCEP per-profile dispatch from commit fdd424b.
cmd/server/main.go: refactored EST startup block to iterate
cfg.EST.Profiles. Per-profile preflight (issuer-in-registry,
preflightEnrollmentIssuer L-005 gate) runs in the loop with
per-profile structured logging including PathID. Failures log the
offending PathID so multi-profile deploys can pinpoint which broke
startup. Mirrors the SCEP per-profile loop from commit fdd424b.
Updated 3 callers of the old single-handler signature:
- internal/api/router/router_test.go::TestRegisterESTHandlers_AllPaths
- internal/integration/lifecycle_test.go::setupTestServer
- internal/integration/negative_test.go::setupTestServer
Each wraps the existing single ESTHandler in a single-element
map[string]handler.ESTHandler{"": estHandler} preserving exact
legacy behavior.
NEW TESTS:
internal/config/config_est_profiles_test.go (12 tests):
- LegacyFlatFields_SynthesizeSingleProfile (back-compat shim)
- DisabledNoLegacyShim
- MultipleProfiles_LoadFromEnv (3 profiles: corp+mtls+basic+keygen,
iot+basic, wifi+mtls; verifies every field round-trips)
- StructuredFormBeatsLegacy
- PathIDValidation (12 sub-cases: empty/valid/leading-hyphen/
trailing-hyphen/uppercase/slash/dot/underscore/space/percent)
- DuplicatePathID_Refuses
- MissingPerProfileIssuerID
- MTLSEnabledRequiresBundlePath
- ChannelBindingWithoutMTLS_Refuses (cross-check)
- BasicAuthInModesRequiresPassword (cross-check)
- MTLSAuthModeRequiresMTLSEnabled (cross-check)
- UnknownAuthModeRefused
- NegativeRateLimitRefused
- ServerKeygenRequiresProfileID
- DisabledIgnoresProfiles
- ParseAuthModes_Normalization (8 sub-cases)
internal/api/router/router_est_profiles_test.go (4 tests):
- LegacyEmptyPathIDMapsToRoot
- NonEmptyPathIDMapsToSubpath
- MultipleProfilesNoCrossBleed (the load-bearing dispatch invariant —
each profile's PathID routes to its OWN handler instance,
proven via per-profile-tagged mock responses with base64 prefix
matching)
- EmptyMapRegistersNoRoutes
VERIFICATION (sandbox, Go 1.25.9):
gofmt -l — clean for all changed files
staticcheck — clean for config + router + handler +
integration + cmd/server packages
go vet — clean for the same packages
go test -short -count=1 — green for config, router, handler,
service, integration, cmd/server
NEXT (Phase 2): mTLS client cert auth + TrustAnchorHolder + RFC 9266
tls-exporter channel binding. Phase 1's Validate gates already refuse
the incoherent configurations Phase 2 must defend against; Phase 2
adds the actual TLS-listener wiring + handler-side cert validation +
channel-binding extraction.
Spec preserved at cowork/est-rfc7030-hardening-prompt.md.
451 lines
14 KiB
Go
451 lines
14 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 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)
|
|
}
|
|
}
|
|
}
|