package endpointer import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/logging" platform_http "github.com/fleetdm/fleet/v4/server/platform/http" "github.com/go-kit/kit/endpoint" kithttp "github.com/go-kit/kit/transport/http" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testHandlerFunc is a handler function type used for testing. type testHandlerFunc func(ctx context.Context, request any) (platform_http.Errorer, error) func TestCustomMiddlewareAfterAuth(t *testing.T) { var ( i = 0 beforeIndex = 0 authIndex = 0 afterFirstIndex = 0 afterSecondIndex = 0 ) beforeAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { i++ beforeIndex = i return next(ctx, req) } } authMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { i++ authIndex = i if authctx, ok := authz_ctx.FromContext(ctx); ok { authctx.SetChecked() } return next(ctx, req) } } afterAuthMiddlewareFirst := func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { i++ afterFirstIndex = i return next(ctx, req) } } afterAuthMiddlewareSecond := func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req interface{}) (interface{}, error) { i++ afterSecondIndex = i return next(ctx, req) } } r := mux.NewRouter() ce := &CommonEndpointer[testHandlerFunc]{ EP: nopEP{}, MakeDecoderFn: func(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc { return func(ctx context.Context, r *http.Request) (request any, err error) { return nopRequest{}, nil } }, EncodeFn: func(ctx context.Context, w http.ResponseWriter, i any) error { w.WriteHeader(http.StatusOK) return nil }, AuthMiddleware: authMiddleware, CustomMiddleware: []endpoint.Middleware{ beforeAuthMiddleware, }, CustomMiddlewareAfterAuth: []endpoint.Middleware{ afterAuthMiddlewareFirst, afterAuthMiddlewareSecond, }, Router: r, } ce.handleEndpoint("/", func(ctx context.Context, request any) (platform_http.Errorer, error) { fmt.Printf("handler\n") return nopResponse{}, nil }, nil, "GET") s := httptest.NewServer(r) t.Cleanup(func() { s.Close() }) req, err := http.NewRequest("GET", s.URL+"/", nil) require.NoError(t, err) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) t.Cleanup(func() { resp.Body.Close() }) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, 1, beforeIndex) require.Equal(t, 2, authIndex) require.Equal(t, 3, afterFirstIndex) require.Equal(t, 4, afterSecondIndex) } type nopRequest struct{} type nopResponse struct{} func (n nopResponse) Error() error { return nil } type nopEP struct{} func (n nopEP) CallHandlerFunc(f testHandlerFunc, ctx context.Context, request any, svc any) (platform_http.Errorer, error) { return f(ctx, request) } func (n nopEP) Service() any { return nil } func TestRegisterDeprecatedPathAliases(t *testing.T) { // Set up a router and register a primary endpoint via CommonEndpointer. r := mux.NewRouter() registry := NewHandlerRegistry() versions := []string{"v1", "2022-04"} authMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, req any) (any, error) { if authctx, ok := authz_ctx.FromContext(ctx); ok { authctx.SetChecked() } return next(ctx, req) } } ce := &CommonEndpointer[testHandlerFunc]{ EP: nopEP{}, MakeDecoderFn: func(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc { return func(ctx context.Context, r *http.Request) (request any, err error) { return nopRequest{}, nil } }, EncodeFn: func(ctx context.Context, w http.ResponseWriter, i any) error { w.WriteHeader(http.StatusOK) return nil }, AuthMiddleware: authMiddleware, Router: r, Versions: versions, HandlerRegistry: registry, } // Register the primary endpoint. ce.GET("/api/_version_/fleet/fleets", func(ctx context.Context, request any) (platform_http.Errorer, error) { return nopResponse{}, nil }, nil) // Register a deprecated alias for it. RegisterDeprecatedPathAliases(r, versions, registry, []DeprecatedPathAlias{ { Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets", DeprecatedPaths: []string{"/api/_version_/fleet/teams"}, }, }) s := httptest.NewServer(r) t.Cleanup(s.Close) // Both the primary and deprecated paths should return 200. for _, path := range []string{"/api/v1/fleet/fleets", "/api/v1/fleet/teams", "/api/latest/fleet/teams"} { resp, err := http.Get(s.URL + path) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode, "path %s should return 200", path) } } func TestLogDeprecatedPathAlias(t *testing.T) { // Without deprecated path info in context, LogDeprecatedPathAlias is a no-op. lc := &logging.LoggingContext{} ctx := logging.NewContext(context.Background(), lc) ctx2 := LogDeprecatedPathAlias(ctx, nil) require.Equal(t, ctx, ctx2, "should return same context when no deprecated path info") require.Empty(t, lc.Extras) // With deprecated path info, it should set warn level and extras. ctx = context.WithValue(ctx, deprecatedPathInfoKey{}, deprecatedPathInfo{ deprecatedPath: "/api/_version_/fleet/teams", primaryPath: "/api/_version_/fleet/fleets", }) LogDeprecatedPathAlias(ctx, nil) // Extras is a flat []interface{} of key-value pairs. require.Len(t, lc.Extras, 4) // "deprecated_path", value, "deprecation_warning", value require.Equal(t, "deprecated_path", lc.Extras[0]) require.Equal(t, "/api/_version_/fleet/teams", lc.Extras[1]) require.Equal(t, "deprecation_warning", lc.Extras[2]) require.Contains(t, lc.Extras[3], "deprecated") // ForceLevel should be set to Warn. require.NotNil(t, lc.ForceLevel) require.Equal(t, slog.LevelWarn, *lc.ForceLevel) } func TestRegisterDeprecatedPathAliasesPanicsOnMissing(t *testing.T) { r := mux.NewRouter() registry := NewHandlerRegistry() versions := []string{"v1"} require.Panics(t, func() { RegisterDeprecatedPathAliases(r, versions, registry, []DeprecatedPathAlias{ { Method: "GET", PrimaryPath: "/api/_version_/fleet/nonexistent", DeprecatedPaths: []string{"/api/_version_/fleet/old"}, }, }) }) } func defaultJSONUnmarshal(body io.Reader, req any) error { return json.NewDecoder(body).Decode(req) } type testRequestDecoderType struct { Data string `json:"data"` } func (d *testRequestDecoderType) DecodeRequest(ctx context.Context, r *http.Request) (any, error) { err := json.NewDecoder(r.Body).Decode(d) return d, err } // TestMakeDecoderRequestDecoderFalsePositive verifies that a body containing // malformed JSON that is within the size limit does not produce a // PayloadTooLargeError (false positive) func TestMakeDecoderRequestDecoderFalsePositive(t *testing.T) { const limit = 50 makeDecoder := func(limit int64) kithttp.DecodeRequestFunc { return MakeDecoder(&testRequestDecoderType{}, defaultJSONUnmarshal, nil, nil, nil, nil, limit) } t.Run("malformed JSON within limit returns decode error, not 413", func(t *testing.T) { body := strings.NewReader(`{"data": "truncated`) // malformed, within limit r := httptest.NewRequest("POST", "/", body) _, err := makeDecoder(limit)(context.Background(), r) require.Error(t, err) var ple platform_http.PayloadTooLargeError require.False(t, errors.As(err, &ple), "malformed body within limit must not produce PayloadTooLargeError") }) t.Run("body over limit returns 413", func(t *testing.T) { big := `{"data":"` + strings.Repeat("x", limit+10) + `"}` body := strings.NewReader(big) r := httptest.NewRequest("POST", "/", body) _, err := makeDecoder(limit)(context.Background(), r) require.Error(t, err) var ple platform_http.PayloadTooLargeError require.True(t, errors.As(err, &ple), "body over limit must produce PayloadTooLargeError, got: %v", err) }) t.Run("malformed JSON exactly at limit returns decode error, not 413", func(t *testing.T) { // Build a body of exactly `limit` bytes that is malformed JSON (no closing // brace). The LimitedReader is exhausted (N==0), but a peek at the // underlying reader returns EOF — the body ended at the limit, it was not // cut short. Must not produce PayloadTooLargeError. prefix := `{"data":"` body := strings.NewReader(prefix + strings.Repeat("x", limit-len(prefix))) // exactly limit bytes, no closing r := httptest.NewRequest("POST", "/", body) _, err := makeDecoder(limit)(context.Background(), r) require.Error(t, err) var ple platform_http.PayloadTooLargeError require.False(t, errors.As(err, &ple), "malformed body exactly at limit must not produce PayloadTooLargeError") }) t.Run("body over limit without Content-Length returns 413", func(t *testing.T) { // Simulate a chunked request (no Content-Length) whose body exceeds the // limit. The peek at the underlying reader finds more data → 413. big := `{"data":"` + strings.Repeat("x", limit+10) + `"}` r := httptest.NewRequest("POST", "/", strings.NewReader(big)) r.ContentLength = -1 // strip the Content-Length that httptest set _, err := makeDecoder(limit)(context.Background(), r) require.Error(t, err) var ple platform_http.PayloadTooLargeError require.True(t, errors.As(err, &ple), "over-limit body without Content-Length must produce PayloadTooLargeError, got: %v", err) }) t.Run("valid body within limit is decoded successfully", func(t *testing.T) { body := strings.NewReader(`{"data":"hello"}`) r := httptest.NewRequest("POST", "/", body) result, err := makeDecoder(limit)(context.Background(), r) require.NoError(t, err) rd, ok := result.(*testRequestDecoderType) require.True(t, ok) assert.Equal(t, "hello", rd.Data) }) }