From b8d60fa3095238a175080408fcced28c4a528e9e Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 31 Jan 2025 11:29:30 -0500 Subject: [PATCH] Adds methodAny and tests --- parrot/Makefile | 12 ++-- parrot/errors.go | 3 +- parrot/examples_test.go | 4 +- parrot/parrot.go | 57 ++++++++++++----- parrot/parrot_fuzz_test.go | 62 +++++++++++++++++++ parrot/parrot_test.go | 30 +++------ .../fuzz/FuzzMethodAny/771e938e4458e983 | 2 + 7 files changed, 123 insertions(+), 47 deletions(-) create mode 100644 parrot/parrot_fuzz_test.go create mode 100644 parrot/testdata/fuzz/FuzzMethodAny/771e938e4458e983 diff --git a/parrot/Makefile b/parrot/Makefile index c673345ee..2f6af3352 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -20,6 +20,7 @@ test_race: set -euo pipefail go test $(TEST_ARGS) -json -timeout $(TEST_TIMEOUT) -cover -count=1 -race -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + .PHONY: test_unit test_unit: go test $(TEST_ARGS) -timeout $(TEST_TIMEOUT) -coverprofile cover.out ./... @@ -28,10 +29,11 @@ test_unit: bench: go test $(TEST_ARGS) -bench=. -run=^$$ ./... +.PHONY: fuzz_tests +fuzz: + go test -list="Fuzz" ./... + .PHONY: build build: - go build -o ./parrot ./cmd - -.PHONY: goreleaser -goreleaser: - cd .. && goreleaser release --snapshot --clean -f ./parrot/.goreleaser.yaml \ No newline at end of file + cd .. && goreleaser release --snapshot --clean -f ./parrot/.goreleaser.yaml + \ No newline at end of file diff --git a/parrot/errors.go b/parrot/errors.go index 097d3627a..09b366ea8 100644 --- a/parrot/errors.go +++ b/parrot/errors.go @@ -7,12 +7,13 @@ import ( var ( ErrNilRoute = errors.New("route is nil") - ErrNoMethod = errors.New("no method specified") ErrInvalidPath = errors.New("invalid path") + ErrInvalidMethod = errors.New("invalid method") ErrNoResponse = errors.New("route must have a handler or some response") ErrOnlyOneResponse = errors.New("route can only have one response type") ErrResponseMarshal = errors.New("unable to marshal response body to JSON") ErrRouteNotFound = errors.New("route not found") + ErrWildcardPath = fmt.Errorf("path can only contain one wildcard '*' and it must be the final value") ErrNoRecorderURL = errors.New("no recorder URL specified") ErrInvalidRecorderURL = errors.New("invalid recorder URL") diff --git a/parrot/examples_test.go b/parrot/examples_test.go index 15662b9c0..54517fa1c 100644 --- a/parrot/examples_test.go +++ b/parrot/examples_test.go @@ -12,7 +12,7 @@ import ( "github.com/smartcontractkit/chainlink-testing-framework/parrot" ) -func ExampleServer_Register_internal() { +func ExampleServer_internal() { // Create a new parrot instance with no logging and a custom save file saveFile := "register_example.json" p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) @@ -69,7 +69,7 @@ func ExampleServer_Register_internal() { // 0 } -func ExampleServer_Register_external() { +func ExampleServer_external() { var ( saveFile = "route_example.json" port = 9090 diff --git a/parrot/parrot.go b/parrot/parrot.go index 43a443697..3b4d67680 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -28,6 +28,9 @@ const ( HealthRoute = "/health" RoutesRoute = "/routes" RecorderRoute = "/recorder" + + // MethodAny is a wildcard for any HTTP method + MethodAny = "ANY" ) // Route holds information about the mock route configuration @@ -36,9 +39,6 @@ type Route struct { Method string `json:"Method"` // Path is the URL path to match Path string `json:"Path"` - // Handler is the dynamic handler function to use when called - // Can only be set upon creation of the server - Handler http.HandlerFunc `json:"-"` // RawResponseBody is the static, raw string response to return when called RawResponseBody string `json:"raw_response_body"` // ResponseBody will be marshalled to JSON and returned when called @@ -187,7 +187,7 @@ func (p *Server) run(listener net.Listener) { p.log.Error().Err(err).Msg("Failed to save routes") } if err := p.logFile.Close(); err != nil { - p.log.Error().Err(err).Msg("Failed to close log file") + fmt.Println("ERROR: Failed to close log file:", err) } p.shutDownOnce.Do(func() { close(p.shutDownChan) @@ -197,7 +197,7 @@ func (p *Server) run(listener net.Listener) { p.log.Info().Str("Address", p.address).Msg("Parrot awake and ready to squawk") p.log.Debug().Str("Save File", p.saveFileName).Str("Log File", p.logFileName).Msg("Configuration") if err := p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - p.log.Fatal().Err(err).Msg("Error while running server") + fmt.Println("ERROR: Failed to start server:", err) } } @@ -209,11 +209,6 @@ func (p *Server) routeCallHandler(route *Route) http.HandlerFunc { return c.Str("Route ID", route.ID()) }) - if route.Handler != nil { - route.Handler(w, r) - return - } - if route.RawResponseBody != "" { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(route.ResponseStatusCode) @@ -317,15 +312,12 @@ func (p *Server) Register(route *Route) error { if !isValidPath(route.Path) { return newDynamicError(ErrInvalidPath, fmt.Sprintf("'%s'", route.Path)) } - if route.Method == "" { - return ErrNoMethod + if !isValidMethod(route.Method) { + return newDynamicError(ErrInvalidMethod, fmt.Sprintf("'%s'", route.Method)) } - if route.Handler == nil && route.ResponseBody == nil && route.RawResponseBody == "" { + if route.ResponseBody == nil && route.RawResponseBody == "" { return ErrNoResponse } - if route.Handler != nil && (route.ResponseBody != nil || route.RawResponseBody != "") { - return newDynamicError(ErrOnlyOneResponse, "handler and another response type provided") - } if route.ResponseBody != nil && route.RawResponseBody != "" { return ErrOnlyOneResponse } @@ -334,8 +326,19 @@ func (p *Server) Register(route *Route) error { return newDynamicError(ErrResponseMarshal, err.Error()) } } + numWildcards := strings.Count(route.Path, "*") + if 1 < numWildcards { + return newDynamicError(ErrWildcardPath, fmt.Sprintf("more than 1 wildcard '%s'", route.Path)) + } + if numWildcards == 1 && !strings.HasSuffix(route.Path, "*") { + return newDynamicError(ErrWildcardPath, fmt.Sprintf("wildcard not at end '%s'", route.Path)) + } - p.router.MethodFunc(route.Method, route.Path, routeRecordingMiddleware(p, p.routeCallHandler(route))) + if route.Method == MethodAny { + p.router.Handle(route.Path, routeRecordingMiddleware(p, p.routeCallHandler(route))) + } else { + p.router.MethodFunc(route.Method, route.Path, routeRecordingMiddleware(p, p.routeCallHandler(route))) + } p.routesMu.Lock() defer p.routesMu.Unlock() @@ -496,6 +499,9 @@ func (p *Server) Call(method, path string) (*resty.Response, error) { if p.shutDown.Load() { return nil, ErrServerShutdown } + if !isValidMethod(method) { + return nil, newDynamicError(ErrInvalidMethod, fmt.Sprintf("'%s'", method)) + } return p.client.R().Execute(method, "http://"+filepath.Join(p.Address(), path)) } @@ -660,6 +666,7 @@ func (p *Server) loggingMiddleware(next http.Handler) http.Handler { var pathRegex = regexp.MustCompile(`^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@\/]*$`) +// isValidPath checks if the path is a valid URL path func isValidPath(path string) bool { if path == "" || path == "/" { return false @@ -681,3 +688,19 @@ func isValidPath(path string) bool { } return pathRegex.MatchString(path) } + +// isValidMethod checks if the method is a valid HTTP method, in loose terms +func isValidMethod(method string) bool { + switch method { + case + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, + MethodAny: + return true + } + return false +} diff --git a/parrot/parrot_fuzz_test.go b/parrot/parrot_fuzz_test.go new file mode 100644 index 000000000..ea32066ce --- /dev/null +++ b/parrot/parrot_fuzz_test.go @@ -0,0 +1,62 @@ +package parrot + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func FuzzRegisterPath(f *testing.F) { + p := newParrot(f) + + baseRoute := Route{ + Method: http.MethodGet, + ResponseStatusCode: http.StatusOK, + RawResponseBody: "Squawk", + } + f.Add("/foo") + f.Add("/foo/bar") + f.Add("/*") + f.Add("/foo/*") + + f.Fuzz(func(t *testing.T, path string) { + route := baseRoute + route.Path = path + + _ = p.Register(&route) // We just don't want panics + }) +} + +func FuzzMethodAny(f *testing.F) { + p := newParrot(f) + + route := &Route{ + Method: MethodAny, + Path: "/any", + ResponseStatusCode: http.StatusOK, + RawResponseBody: "Squawk", + } + + err := p.Register(route) + require.NoError(f, err) + + f.Add(http.MethodGet) + f.Add(http.MethodPost) + f.Add(http.MethodPut) + f.Add(http.MethodPatch) + f.Add(http.MethodDelete) + f.Add(http.MethodOptions) + + f.Fuzz(func(t *testing.T, method string) { + if !isValidMethod(method) { + t.Skip("invalid method") + } + resp, err := p.Call(method, route.Path) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, resp.StatusCode(), fmt.Sprintf("bad response code with method: '%s'", method)) + require.Equal(t, "Squawk", string(resp.Body()), fmt.Sprintf("bad response body with method: '%s'", method)) + }) +} diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index f809bd919..ab88d31f3 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -306,7 +306,7 @@ func TestBadRegisterRoute(t *testing.T) { }, { name: "no method", - err: ErrNoMethod, + err: ErrInvalidMethod, route: &Route{ Path: "/hello", RawResponseBody: "Squawk", @@ -372,20 +372,6 @@ func TestBadRegisterRoute(t *testing.T) { ResponseStatusCode: http.StatusOK, }, }, - { - name: "too many responses", - err: ErrOnlyOneResponse, - route: &Route{ - Method: http.MethodGet, - Path: "/hello", - ResponseBody: map[string]any{"message": "Squawk"}, - Handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("Squawk")) - }, - ResponseStatusCode: http.StatusOK, - }, - }, { name: "bad JSON", err: ErrResponseMarshal, @@ -586,16 +572,16 @@ func TestJSONLogger(t *testing.T) { require.Contains(t, string(logs), fmt.Sprintf(`"Route ID":"%s"`, route.ID()), "expected log file to contain route call in JSON format") } -func newParrot(t *testing.T) *Server { - t.Helper() +func newParrot(tb testing.TB) *Server { + tb.Helper() - logFileName := t.Name() + ".log" - saveFileName := t.Name() + ".json" + logFileName := tb.Name() + ".log" + saveFileName := tb.Name() + ".json" p, err := Wake(WithSaveFile(saveFileName), WithLogFile(logFileName), WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") - t.Cleanup(func() { + require.NoError(tb, err, "error waking parrot") + tb.Cleanup(func() { err := p.Shutdown(context.Background()) - assert.NoError(t, err, "error shutting down parrot") + assert.NoError(tb, err, "error shutting down parrot") p.WaitShutdown() // Wait for shutdown to complete and file to be written os.Remove(saveFileName) os.Remove(logFileName) diff --git a/parrot/testdata/fuzz/FuzzMethodAny/771e938e4458e983 b/parrot/testdata/fuzz/FuzzMethodAny/771e938e4458e983 new file mode 100644 index 000000000..ee3f33997 --- /dev/null +++ b/parrot/testdata/fuzz/FuzzMethodAny/771e938e4458e983 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0")