Skip to content

Commit fdf2dbd

Browse files
committed
refactor: Route jobservice with net/http.ServeMux
jobservice used gorilla/mux for eight routes and four reads of a single path variable. Go 1.22 method-and-wildcard patterns cover all of it, and dropping the router also drops otelmux: tracing now goes through tracelib.NewHandler, the otelhttp wrapper core and registryctl already use. otelmux leaves go.mod. gorilla/mux stays for now, still used by registryctl and the test helper. Routing is not a drop-in swap, so the differences were measured rather than assumed. Both routers were driven over the same sixteen requests: - Wrong method: gorilla answered 404, ServeMux answers 405 with an Allow header. More correct, and the Allow list on an internal secret-authenticated API discloses nothing useful. - HEAD on a GET route: gorilla answered 405, ServeMux serves it. Only /stats is reachable unauthenticated, and a GET there is already unauthenticated by design, so this exposes nothing new. - Path cleaning redirects moved from 301 to 307, which preserves the method and body instead of inviting a POST to be replayed as a GET. - ServeMux matches escaped segments and unescapes the wildcard afterwards, so "%2F" and "%2e%2e" now arrive as a job_id holding a separator or a parent reference. gorilla matched the decoded path and answered 404. That last one widens the input contract, so withJobID closes it again and keeps the 404. The traversal sink is the job log route, whose own guard stays where it is; the other three routes never had a guard because gorilla never let such an ID through. Reading job_id via req.PathValue also gives gosec's taint analysis a source it recognises, and it traced the value into an error body written with no declared content type. The finding is real if narrow, so handleError now sets text/plain and X-Content-Type-Options: nosniff rather than leaving the body to content sniffing. Signed-off-by: Prasanth Baskar <prasanth@8gears.com>
1 parent 8af213c commit fdf2dbd

5 files changed

Lines changed: 258 additions & 35 deletions

File tree

src/go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ require (
5959
github.com/tencentcloud/tencentcloud-sdk-go v3.0.233+incompatible
6060
github.com/vmihailenco/msgpack/v5 v5.4.1
6161
github.com/volcengine/volcengine-go-sdk v1.2.50
62-
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.71.0
6362
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
6463
go.opentelemetry.io/otel v1.46.0
6564
go.opentelemetry.io/otel/exporters/jaeger v1.17.0

src/go.sum

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4450,8 +4450,6 @@ go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ
44504450
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
44514451
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
44524452
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
4453-
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.71.0 h1:jCSatxkz7I19oUOz3UOJSnKx49hlXuE00OuPzaJCa7k=
4454-
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.71.0/go.mod h1:bACfoFljYysuN0gZsGRCKBQMjKslSDiEAzmSEiZNlRI=
44554453
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
44564454
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ=
44574455
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0/go.mod h1:tIKj3DbO8N9Y2xo52og3irLsPI4GW02DSMtrVgNMgxg=
@@ -4514,8 +4512,6 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyV
45144512
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
45154513
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
45164514
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
4517-
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo=
4518-
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM=
45194515
go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU=
45204516
go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8=
45214517
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=

src/jobservice/api/handler.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ import (
2323
"strconv"
2424
"strings"
2525

26-
"github.com/gorilla/mux"
27-
2826
"github.com/goharbor/harbor/src/jobservice/common/query"
2927
"github.com/goharbor/harbor/src/jobservice/common/utils"
3028
"github.com/goharbor/harbor/src/jobservice/config"
@@ -118,8 +116,7 @@ func (dh *DefaultHandler) HandleLaunchJobReq(w http.ResponseWriter, req *http.Re
118116

119117
// HandleGetJobReq is implementation of method defined in interface 'Handler'
120118
func (dh *DefaultHandler) HandleGetJobReq(w http.ResponseWriter, req *http.Request) {
121-
vars := mux.Vars(req)
122-
jobID := vars["job_id"]
119+
jobID := req.PathValue("job_id")
123120

124121
jobStats, err := dh.controller.GetJob(jobID)
125122
if err != nil {
@@ -140,8 +137,7 @@ func (dh *DefaultHandler) HandleGetJobReq(w http.ResponseWriter, req *http.Reque
140137

141138
// HandleJobActionReq is implementation of method defined in interface 'Handler'
142139
func (dh *DefaultHandler) HandleJobActionReq(w http.ResponseWriter, req *http.Request) {
143-
vars := mux.Vars(req)
144-
jobID := vars["job_id"]
140+
jobID := req.PathValue("job_id")
145141

146142
data, err := io.ReadAll(req.Body)
147143
if err != nil {
@@ -195,8 +191,7 @@ func (dh *DefaultHandler) HandleCheckStatusReq(w http.ResponseWriter, req *http.
195191

196192
// HandleJobLogReq is implementation of method defined in interface 'Handler'
197193
func (dh *DefaultHandler) HandleJobLogReq(w http.ResponseWriter, req *http.Request) {
198-
vars := mux.Vars(req)
199-
jobID := vars["job_id"]
194+
jobID := req.PathValue("job_id")
200195

201196
if strings.Contains(jobID, "..") || strings.ContainsRune(jobID, os.PathSeparator) {
202197
dh.handleError(w, req, http.StatusBadRequest, errors.Errorf("invalid Job ID: %s", jobID))
@@ -226,8 +221,7 @@ func (dh *DefaultHandler) HandleJobLogReq(w http.ResponseWriter, req *http.Reque
226221
// HandlePeriodicExecutions is implementation of method defined in interface 'Handler'
227222
func (dh *DefaultHandler) HandlePeriodicExecutions(w http.ResponseWriter, req *http.Request) {
228223
// Get param
229-
vars := mux.Vars(req)
230-
jobID := vars["job_id"]
224+
jobID := req.PathValue("job_id")
231225

232226
// Get query params
233227
q := extractQuery(req)
@@ -290,6 +284,10 @@ func (dh *DefaultHandler) handleError(w http.ResponseWriter, req *http.Request,
290284
// Log all errors
291285
logger.Errorf("Serve http request '%s %s' error: %d %s", req.Method, req.URL.String(), code, err.Error())
292286

287+
// Error text can carry a path value straight back to the caller, so name the
288+
// type rather than leaving the body to content sniffing.
289+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
290+
w.Header().Set("X-Content-Type-Options", "nosniff")
293291
w.WriteHeader(code)
294292
writeDate(w, []byte(err.Error()))
295293
}
@@ -364,6 +362,9 @@ func extractQuery(req *http.Request) *query.Parameter {
364362
}
365363

366364
func writeDate(w http.ResponseWriter, bytes []byte) {
365+
// nolint:gosec // G705: every caller sets an explicit Content-Type, and the
366+
// error path adds X-Content-Type-Options: nosniff, so a reflected path value
367+
// cannot be sniffed into HTML.
367368
if _, err := w.Write(bytes); err != nil {
368369
logger.Errorf("writer write error: %s", err)
369370
}

src/jobservice/api/router.go

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ package api
1717
import (
1818
"fmt"
1919
"net/http"
20-
21-
"github.com/gorilla/mux"
22-
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
20+
"strings"
2321

2422
"github.com/goharbor/harbor/src/jobservice/errs"
2523
"github.com/goharbor/harbor/src/jobservice/logger"
@@ -42,8 +40,8 @@ type Router interface {
4240

4341
// BaseRouter provides the basic routes for the job service based on the golang http server mux.
4442
type BaseRouter struct {
45-
// Use mux to keep the routes mapping.
46-
router *mux.Router
43+
// Use the standard library mux to keep the routes mapping.
44+
router http.Handler
4745

4846
// Handler used to handle the requests
4947
handler Handler
@@ -55,16 +53,15 @@ type BaseRouter struct {
5553
// NewBaseRouter is the constructor of BaseRouter.
5654
func NewBaseRouter(handler Handler, authenticator Authenticator) Router {
5755
br := &BaseRouter{
58-
router: mux.NewRouter(),
5956
handler: handler,
6057
authenticator: authenticator,
6158
}
6259

6360
// Register routes here
64-
br.registerRoutes()
61+
br.router = br.registerRoutes()
6562

6663
if tracelib.Enabled() {
67-
br.router.Use(otelmux.Middleware("serve-http"))
64+
br.router = tracelib.NewHandler(br.router, "serve-http")
6865
}
6966
return br
7067
}
@@ -90,16 +87,35 @@ func (br *BaseRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
9087
br.router.ServeHTTP(w, req)
9188
}
9289

93-
// registerRoutes adds routes to the server mux.
94-
func (br *BaseRouter) registerRoutes() {
95-
subRouter := br.router.PathPrefix(fmt.Sprintf("%s/%s", baseRoute, apiVersion)).Subrouter()
96-
97-
subRouter.HandleFunc("/jobs", br.handler.HandleLaunchJobReq).Methods(http.MethodPost)
98-
subRouter.HandleFunc("/jobs", br.handler.HandleGetJobsReq).Methods(http.MethodGet)
99-
subRouter.HandleFunc("/jobs/{job_id}", br.handler.HandleGetJobReq).Methods(http.MethodGet)
100-
subRouter.HandleFunc("/jobs/{job_id}", br.handler.HandleJobActionReq).Methods(http.MethodPost)
101-
subRouter.HandleFunc("/jobs/{job_id}/log", br.handler.HandleJobLogReq).Methods(http.MethodGet)
102-
subRouter.HandleFunc("/stats", br.handler.HandleCheckStatusReq).Methods(http.MethodGet)
103-
subRouter.HandleFunc("/config", br.handler.HandleGetConfigReq).Methods(http.MethodGet)
104-
subRouter.HandleFunc("/jobs/{job_id}/executions", br.handler.HandlePeriodicExecutions).Methods(http.MethodGet)
90+
// registerRoutes builds the server mux carrying the job service routes.
91+
func (br *BaseRouter) registerRoutes() http.Handler {
92+
prefix := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
93+
router := http.NewServeMux()
94+
95+
router.HandleFunc("POST "+prefix+"/jobs", br.handler.HandleLaunchJobReq)
96+
router.HandleFunc("GET "+prefix+"/jobs", br.handler.HandleGetJobsReq)
97+
router.HandleFunc("GET "+prefix+"/jobs/{job_id}", withJobID(br.handler.HandleGetJobReq))
98+
router.HandleFunc("POST "+prefix+"/jobs/{job_id}", withJobID(br.handler.HandleJobActionReq))
99+
router.HandleFunc("GET "+prefix+"/jobs/{job_id}/log", withJobID(br.handler.HandleJobLogReq))
100+
router.HandleFunc("GET "+prefix+"/stats", br.handler.HandleCheckStatusReq)
101+
router.HandleFunc("GET "+prefix+"/config", br.handler.HandleGetConfigReq)
102+
router.HandleFunc("GET "+prefix+"/jobs/{job_id}/executions", withJobID(br.handler.HandlePeriodicExecutions))
103+
104+
return router
105+
}
106+
107+
// withJobID turns away the job IDs the routes could not previously receive.
108+
// ServeMux matches escaped path segments and unescapes the wildcard afterwards,
109+
// so "%2F" and "%2e%2e" now reach a handler as a path value holding a separator
110+
// or a parent reference; gorilla/mux matched the decoded path and answered 404.
111+
// Keeping that answer leaves the routing contract where it was.
112+
func withJobID(next http.HandlerFunc) http.HandlerFunc {
113+
return func(w http.ResponseWriter, req *http.Request) {
114+
if id := req.PathValue("job_id"); strings.Contains(id, "..") || strings.ContainsRune(id, '/') {
115+
http.NotFound(w, req)
116+
return
117+
}
118+
119+
next(w, req)
120+
}
105121
}

src/jobservice/api/router_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright Project Harbor Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import (
18+
"fmt"
19+
"net/http"
20+
"net/http/httptest"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
25+
"github.com/goharbor/harbor/src/jobservice/errs"
26+
)
27+
28+
// routeProbe records which handler a request reached and the job ID it carried.
29+
type routeProbe struct {
30+
route string
31+
jobID string
32+
called bool
33+
}
34+
35+
func (p *routeProbe) record(route string) http.HandlerFunc {
36+
return func(w http.ResponseWriter, req *http.Request) {
37+
p.route, p.jobID, p.called = route, req.PathValue("job_id"), true
38+
w.WriteHeader(http.StatusOK)
39+
}
40+
}
41+
42+
func (p *routeProbe) HandleLaunchJobReq(w http.ResponseWriter, r *http.Request) {
43+
p.record("launch")(w, r)
44+
}
45+
func (p *routeProbe) HandleGetJobReq(w http.ResponseWriter, r *http.Request) {
46+
p.record("get-job")(w, r)
47+
}
48+
func (p *routeProbe) HandleJobActionReq(w http.ResponseWriter, r *http.Request) {
49+
p.record("job-action")(w, r)
50+
}
51+
func (p *routeProbe) HandleCheckStatusReq(w http.ResponseWriter, r *http.Request) {
52+
p.record("stats")(w, r)
53+
}
54+
func (p *routeProbe) HandleJobLogReq(w http.ResponseWriter, r *http.Request) {
55+
p.record("job-log")(w, r)
56+
}
57+
func (p *routeProbe) HandlePeriodicExecutions(w http.ResponseWriter, r *http.Request) {
58+
p.record("executions")(w, r)
59+
}
60+
func (p *routeProbe) HandleGetJobsReq(w http.ResponseWriter, r *http.Request) {
61+
p.record("get-jobs")(w, r)
62+
}
63+
func (p *routeProbe) HandleGetConfigReq(w http.ResponseWriter, r *http.Request) {
64+
p.record("config")(w, r)
65+
}
66+
67+
// openAuthenticator lets every request through so the tests see routing alone.
68+
type openAuthenticator struct{}
69+
70+
func (openAuthenticator) DoAuth(_ *http.Request) error { return nil }
71+
72+
func serve(t *testing.T, method, target string) (*routeProbe, *httptest.ResponseRecorder) {
73+
t.Helper()
74+
75+
probe := &routeProbe{}
76+
router := NewBaseRouter(probe, openAuthenticator{})
77+
78+
rec := httptest.NewRecorder()
79+
router.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
80+
81+
return probe, rec
82+
}
83+
84+
func TestRouterDispatch(t *testing.T) {
85+
base := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
86+
87+
cases := []struct {
88+
method string
89+
path string
90+
route string
91+
jobID string
92+
}{
93+
{http.MethodPost, "/jobs", "launch", ""},
94+
{http.MethodGet, "/jobs", "get-jobs", ""},
95+
{http.MethodGet, "/jobs?page_number=2", "get-jobs", ""},
96+
{http.MethodGet, "/jobs/abc123", "get-job", "abc123"},
97+
{http.MethodPost, "/jobs/abc123", "job-action", "abc123"},
98+
{http.MethodGet, "/jobs/abc123/log", "job-log", "abc123"},
99+
{http.MethodGet, "/jobs/abc123/executions", "executions", "abc123"},
100+
{http.MethodGet, "/stats", "stats", ""},
101+
{http.MethodGet, "/config", "config", ""},
102+
}
103+
104+
for _, c := range cases {
105+
t.Run(c.method+" "+c.path, func(t *testing.T) {
106+
probe, rec := serve(t, c.method, base+c.path)
107+
108+
assert.Equal(t, http.StatusOK, rec.Code)
109+
assert.True(t, probe.called, "no handler was reached")
110+
assert.Equal(t, c.route, probe.route)
111+
assert.Equal(t, c.jobID, probe.jobID)
112+
})
113+
}
114+
}
115+
116+
// TestRouterRejectsJobIDsWithSeparators pins the routing contract gorilla/mux
117+
// enforced by matching the decoded path. ServeMux matches escaped segments, so
118+
// without withJobID these would reach a handler carrying "a/b" or "..".
119+
func TestRouterRejectsJobIDsWithSeparators(t *testing.T) {
120+
base := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
121+
122+
paths := []string{
123+
"/jobs/a%2Fb",
124+
"/jobs/a%2Fb/log",
125+
"/jobs/a%2Fb/executions",
126+
"/jobs/%2e%2e",
127+
"/jobs/%2e%2e/log",
128+
"/jobs/%2e%2e/executions",
129+
"/jobs/%2e%2e%2f%2e%2e/log",
130+
}
131+
132+
for _, p := range paths {
133+
t.Run(p, func(t *testing.T) {
134+
probe, rec := serve(t, http.MethodGet, base+p)
135+
136+
assert.Equal(t, http.StatusNotFound, rec.Code)
137+
assert.False(t, probe.called, "handler was reached with job_id %q", probe.jobID)
138+
})
139+
}
140+
}
141+
142+
func TestRouterRejectsUnknownRoutes(t *testing.T) {
143+
base := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
144+
145+
probe, rec := serve(t, http.MethodGet, base+"/nope")
146+
147+
assert.Equal(t, http.StatusNotFound, rec.Code)
148+
assert.False(t, probe.called)
149+
}
150+
151+
// TestRouterRejectsWrongMethod records a deliberate change: gorilla/mux answered
152+
// 404 when no method matcher fired, ServeMux answers 405 and names the methods.
153+
func TestRouterRejectsWrongMethod(t *testing.T) {
154+
base := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
155+
156+
probe, rec := serve(t, http.MethodDelete, base+"/jobs")
157+
158+
assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
159+
assert.Equal(t, "GET, HEAD, POST", rec.Header().Get("Allow"))
160+
assert.False(t, probe.called)
161+
}
162+
163+
// TestRouterServesHeadOnGetRoutes records the other deliberate change: ServeMux
164+
// ties HEAD to GET, where gorilla/mux answered 405. /stats is the unauthenticated
165+
// health endpoint, so this exposes nothing a GET did not already.
166+
func TestRouterServesHeadOnGetRoutes(t *testing.T) {
167+
base := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
168+
169+
probe, rec := serve(t, http.MethodHead, base+"/stats")
170+
171+
assert.Equal(t, http.StatusOK, rec.Code)
172+
assert.True(t, probe.called)
173+
assert.Equal(t, "stats", probe.route)
174+
}
175+
176+
// TestErrorResponseIsNotSniffable covers the reflected path value reaching the
177+
// response body: the type is named and sniffing is turned off, so an error text
178+
// carrying a job ID cannot be interpreted as HTML.
179+
func TestErrorResponseIsNotSniffable(t *testing.T) {
180+
fc := &fakeController{}
181+
fc.On("GetJob", "<img src=x onerror=alert(1)>").
182+
Return(nil, errs.NoObjectFoundError("<img src=x onerror=alert(1)>"))
183+
184+
handler := NewDefaultHandler(fc)
185+
rec := httptest.NewRecorder()
186+
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/x", nil)
187+
req.SetPathValue("job_id", "<img src=x onerror=alert(1)>")
188+
189+
handler.HandleGetJobReq(rec, req)
190+
191+
assert.Equal(t, http.StatusNotFound, rec.Code)
192+
assert.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type"))
193+
assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
194+
}
195+
196+
func TestRouterRequiresAuthExceptStats(t *testing.T) {
197+
base := fmt.Sprintf("%s/%s", baseRoute, apiVersion)
198+
199+
probe := &routeProbe{}
200+
router := NewBaseRouter(probe, &SecretAuthenticator{})
201+
202+
rec := httptest.NewRecorder()
203+
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, base+"/jobs", nil))
204+
assert.Equal(t, http.StatusUnauthorized, rec.Code)
205+
assert.False(t, probe.called)
206+
207+
rec = httptest.NewRecorder()
208+
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, base+"/stats", nil))
209+
assert.Equal(t, http.StatusOK, rec.Code)
210+
assert.True(t, probe.called)
211+
}

0 commit comments

Comments
 (0)