diff --git a/go.work b/go.work index 24d4e45..20d79d7 100644 --- a/go.work +++ b/go.work @@ -4,10 +4,14 @@ use ( . ./example/go-simple-extension ./test/e2e/filters/custom_err_response + ./test/e2e/filters/envoy_property ./test/e2e/filters/helloworld ./test/e2e/filters/http_body ./test/e2e/filters/http_headers_modifier + ./test/e2e/filters/http_query_validation ./test/e2e/filters/http_reroute + ./test/e2e/filters/jwt_validation ./test/e2e/filters/metrics ./test/e2e/filters/panic_filter + ./test/e2e/filters/shared_cache ) diff --git a/test/e2e/filters/envoy_property/envoy.yaml b/test/e2e/filters/envoy_property/envoy.yaml new file mode 100644 index 0000000..5d4c252 --- /dev/null +++ b/test/e2e/filters/envoy_property/envoy.yaml @@ -0,0 +1,85 @@ +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + http_filters: + - name: envoy.filters.http.golang + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config + library_id: envoy_property + library_path: "/filter.so" + plugin_name: envoy_property + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - name: property-route + match: + prefix: "/" + route: + cluster: staticreply + - name: staticreply + address: + socket_address: + address: 0.0.0.0 + port_value: 10001 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: staticreply_route + virtual_hosts: + - name: staticreply_service + domains: ["*"] + routes: + - name: root + match: + prefix: "/" + direct_response: + status: 200 + body: + inline_string: "STATICREPLY.index - path(/) - 200" + clusters: + - name: staticreply + connect_timeout: 5000s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: staticreply + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 0.0.0.0 + port_value: 10001 diff --git a/test/e2e/filters/envoy_property/go.mod b/test/e2e/filters/envoy_property/go.mod new file mode 100644 index 0000000..1c3ef50 --- /dev/null +++ b/test/e2e/filters/envoy_property/go.mod @@ -0,0 +1,25 @@ +module envoy_property + +go 1.26 + +require github.com/ardikabs/gonvoy v0.3.5 + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/envoy v1.40.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.20.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.12 // indirect +) + +replace github.com/ardikabs/gonvoy => ../../../../ diff --git a/test/e2e/filters/envoy_property/go.sum b/test/e2e/filters/envoy_property/go.sum new file mode 100644 index 0000000..e37897c --- /dev/null +++ b/test/e2e/filters/envoy_property/go.sum @@ -0,0 +1,39 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/envoy v1.40.0 h1:Th4hptYfOG6d8CGNL8HxHW4s8VeqGFGwjWnwRodkMxg= +github.com/envoyproxy/envoy v1.40.0/go.mod h1:r/yWvSqfclcy9nxXMIT8UKmJWTauUzTKuL//9Y6duZc= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= +github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/test/e2e/filters/envoy_property/main.go b/test/e2e/filters/envoy_property/main.go new file mode 100644 index 0000000..c810b2c --- /dev/null +++ b/test/e2e/filters/envoy_property/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "strconv" + + "github.com/ardikabs/gonvoy" + "github.com/ardikabs/gonvoy/pkg/envoy" +) + +func init() { + envoy.Handle(FilterName, + gonvoy.Use(func(next gonvoy.Handler) gonvoy.Handler { + return gonvoy.Handler(func(c gonvoy.Context) error { + host, _ := c.Attr("request.host", "-") + method, _ := c.Attr("request.method", "-") + path, _ := c.Attr("request.path", "-") + + c.Log().Info("envoy property request attrs", "host", host, "method", method, "path", path) + + if err := next(c); err != nil { + return err + } + + cluster, _ := c.Attr("xds.cluster_name", "-") + route, _ := c.Attr("xds.route_name", "-") + + code, _ := c.Attr("response.code", "-") + if code == "-" && c.Status() != 0 { + code = strconv.Itoa(c.Status()) + } + + missing, missingOK := c.Attr("nonexistent.key", "fallback") + + c.SetHeader("x-request-host", host) + c.SetHeader("x-request-method", method) + c.SetHeader("x-request-path", path) + c.SetHeader("x-xds-cluster", cluster) + c.SetHeader("x-xds-route", route) + c.SetHeader("x-response-code", code) + c.SetHeader("x-missing-value", missing) + c.SetHeader("x-missing-ok", strconv.FormatBool(missingOK)) + + c.Log().Info("envoy property response attrs", "cluster", cluster, "route", route, "code", code, "missing", missing, "missing_ok", missingOK) + + return nil + }) + }), + ) +} + +func main() {} + +const FilterName = "envoy_property" diff --git a/test/e2e/filters/http_query_validation/envoy.yaml b/test/e2e/filters/http_query_validation/envoy.yaml new file mode 100644 index 0000000..ed80315 --- /dev/null +++ b/test/e2e/filters/http_query_validation/envoy.yaml @@ -0,0 +1,44 @@ +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + http_filters: + - name: envoy.filters.http.golang + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config + library_id: http-query-validation + library_path: "/filter.so" + plugin_name: http_query_validation + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - name: root + match: + prefix: "/" + direct_response: + status: 200 + body: + inline_string: "Hello, World!" diff --git a/test/e2e/filters/http_query_validation/go.mod b/test/e2e/filters/http_query_validation/go.mod new file mode 100644 index 0000000..79fabbc --- /dev/null +++ b/test/e2e/filters/http_query_validation/go.mod @@ -0,0 +1,25 @@ +module http_query_validation + +go 1.26 + +require github.com/ardikabs/gonvoy v0.3.5 + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/envoy v1.40.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.20.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.12 // indirect +) + +replace github.com/ardikabs/gonvoy => ../../../../ diff --git a/test/e2e/filters/http_query_validation/go.sum b/test/e2e/filters/http_query_validation/go.sum new file mode 100644 index 0000000..e37897c --- /dev/null +++ b/test/e2e/filters/http_query_validation/go.sum @@ -0,0 +1,39 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/envoy v1.40.0 h1:Th4hptYfOG6d8CGNL8HxHW4s8VeqGFGwjWnwRodkMxg= +github.com/envoyproxy/envoy v1.40.0/go.mod h1:r/yWvSqfclcy9nxXMIT8UKmJWTauUzTKuL//9Y6duZc= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= +github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/test/e2e/filters/http_query_validation/main.go b/test/e2e/filters/http_query_validation/main.go new file mode 100644 index 0000000..f4afed4 --- /dev/null +++ b/test/e2e/filters/http_query_validation/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "net/http" + "strconv" + + "github.com/ardikabs/gonvoy" + "github.com/ardikabs/gonvoy/pkg/envoy" +) + +func init() { + envoy.Handle(FilterName, func(c gonvoy.Context) error { + if c.Path() != "/search" { + return nil + } + + q := c.QueryParam("q") + if q == "" { + return c.String(http.StatusBadRequest, "missing required query param q") + } + + if mode := c.QueryParam("mode"); mode != "" && mode != "READ" && mode != "WRITE" { + return c.String(http.StatusBadRequest, "invalid mode, must be READ or WRITE") + } + + if limitRaw := c.QueryParam("limit"); limitRaw != "" { + limit, err := strconv.Atoi(limitRaw) + if err != nil || limit < 1 || limit > 100 { + return c.String(http.StatusBadRequest, "invalid limit, must be int 1..100") + } + } + + c.Log().Info("query validated q=" + q + " path=" + c.Path() + " tag=" + c.QueryParam("tag")) + + return nil + }) +} + +func main() {} + +const FilterName = "http_query_validation" diff --git a/test/e2e/filters/jwt_validation/envoy.yaml b/test/e2e/filters/jwt_validation/envoy.yaml new file mode 100644 index 0000000..eeba387 --- /dev/null +++ b/test/e2e/filters/jwt_validation/envoy.yaml @@ -0,0 +1,71 @@ +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + http_filters: + - name: envoy.filters.http.jwt_authn + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + test: + issuer: https://test-issuer.example.com/ + audiences: + - test-audience + local_jwks: + inline_string: '{"keys":[{"kty":"oct","k":"Z29udm95LWUyZS1qd3QtdGVzdC1zZWNyZXQtMDEyMzQ1Njc4OQ","alg":"HS256","kid":"test-key"}]}' + from_headers: + - name: Authorization + value_prefix: "Bearer " + from_params: + - access_token + from_cookies: + - access_token + claim_to_headers: + - header_name: x-claim-name + claim_name: name + requirement_map: + valid: + provider_name: test + rules: + - match: + prefix: / + requirement_name: valid + - name: envoy.filters.http.golang + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config + library_id: jwt_validation + library_path: "/filter.so" + plugin_name: jwt_validation + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - name: root + match: + prefix: "/" + direct_response: + status: 200 + body: + inline_string: "OK" diff --git a/test/e2e/filters/jwt_validation/go.mod b/test/e2e/filters/jwt_validation/go.mod new file mode 100644 index 0000000..9401466 --- /dev/null +++ b/test/e2e/filters/jwt_validation/go.mod @@ -0,0 +1,25 @@ +module jwt_validation + +go 1.26 + +require github.com/ardikabs/gonvoy v0.3.5 + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/envoy v1.40.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.20.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.12 // indirect +) + +replace github.com/ardikabs/gonvoy => ../../../../ diff --git a/test/e2e/filters/jwt_validation/go.sum b/test/e2e/filters/jwt_validation/go.sum new file mode 100644 index 0000000..e37897c --- /dev/null +++ b/test/e2e/filters/jwt_validation/go.sum @@ -0,0 +1,39 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/envoy v1.40.0 h1:Th4hptYfOG6d8CGNL8HxHW4s8VeqGFGwjWnwRodkMxg= +github.com/envoyproxy/envoy v1.40.0/go.mod h1:r/yWvSqfclcy9nxXMIT8UKmJWTauUzTKuL//9Y6duZc= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= +github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/test/e2e/filters/jwt_validation/main.go b/test/e2e/filters/jwt_validation/main.go new file mode 100644 index 0000000..02ce92a --- /dev/null +++ b/test/e2e/filters/jwt_validation/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/ardikabs/gonvoy" + "github.com/ardikabs/gonvoy/pkg/envoy" +) + +func init() { + envoy.Handle(FilterName, func(c gonvoy.Context) error { + claim := c.Header("x-claim-name") + if claim == "" { + return c.JSON(http.StatusUnauthorized, json.RawMessage(gonvoy.NewMinimalJSONResponse("UNAUTHORIZED", "UNAUTHORIZED"))) + } + + c.SetHeader("x-claim-name", claim) + c.Log().Info("JWT validated from x-claim-name header") + + return nil + }) +} + +func main() {} + +const FilterName = "jwt_validation" diff --git a/test/e2e/filters/shared_cache/envoy.yaml b/test/e2e/filters/shared_cache/envoy.yaml new file mode 100644 index 0000000..202a67e --- /dev/null +++ b/test/e2e/filters/shared_cache/envoy.yaml @@ -0,0 +1,44 @@ +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + http_filters: + - name: envoy.filters.http.golang + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config + library_id: shared_cache + library_path: "/filter.so" + plugin_name: shared_cache + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - name: root + match: + prefix: "/" + direct_response: + status: 200 + body: + inline_string: "Hello, World!" diff --git a/test/e2e/filters/shared_cache/go.mod b/test/e2e/filters/shared_cache/go.mod new file mode 100644 index 0000000..f3a46b2 --- /dev/null +++ b/test/e2e/filters/shared_cache/go.mod @@ -0,0 +1,25 @@ +module shared_cache + +go 1.26 + +require github.com/ardikabs/gonvoy v0.3.5 + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/envoy v1.40.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.20.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.12 // indirect +) + +replace github.com/ardikabs/gonvoy => ../../../../ diff --git a/test/e2e/filters/shared_cache/go.sum b/test/e2e/filters/shared_cache/go.sum new file mode 100644 index 0000000..e37897c --- /dev/null +++ b/test/e2e/filters/shared_cache/go.sum @@ -0,0 +1,39 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/envoy v1.40.0 h1:Th4hptYfOG6d8CGNL8HxHW4s8VeqGFGwjWnwRodkMxg= +github.com/envoyproxy/envoy v1.40.0/go.mod h1:r/yWvSqfclcy9nxXMIT8UKmJWTauUzTKuL//9Y6duZc= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= +github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/test/e2e/filters/shared_cache/main.go b/test/e2e/filters/shared_cache/main.go new file mode 100644 index 0000000..3c06467 --- /dev/null +++ b/test/e2e/filters/shared_cache/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "net/http" + + "github.com/ardikabs/gonvoy" + "github.com/ardikabs/gonvoy/pkg/envoy" +) + +func init() { + envoy.Handle(FilterName, func(c gonvoy.Context) error { + switch c.Path() { + case "/store": + key := c.QueryParam("key") + value := c.QueryParam("value") + if key == "" || value == "" { + return c.String(http.StatusBadRequest, "key and value query params are required") + } + c.Cache().Store(key, value) + c.Log().Info("shared cache store", "key", key, "value", value) + return c.String(http.StatusOK, value) + case "/load": + key := c.QueryParam("key") + if key == "" { + return c.String(http.StatusBadRequest, "key query param is required") + } + var v string + ok, err := c.Cache().Load(key, &v) + if err != nil { + c.Log().Info("shared cache load error", "key", key, "error", err.Error()) + return c.String(http.StatusInternalServerError, err.Error()) + } + if !ok { + return c.String(http.StatusNotFound, "not found") + } + c.Log().Info("shared cache hit", "key", key, "value", v) + return c.String(http.StatusOK, v) + case "/store-struct": + key := c.QueryParam("key") + if key == "" { + return c.String(http.StatusBadRequest, "key query param is required") + } + c.Cache().Store(key, cachedData{Value: "struct-value"}) + c.Log().Info("shared cache store struct", "key", key) + return c.String(http.StatusOK, "stored") + case "/load-struct-bad": + key := c.QueryParam("key") + if key == "" { + return c.String(http.StatusBadRequest, "key query param is required") + } + var v string + ok, err := c.Cache().Load(key, &v) + if err != nil { + c.Log().Info("shared cache load type mismatch", "key", key, "error", err.Error()) + return c.String(http.StatusInternalServerError, err.Error()) + } + if !ok { + return c.String(http.StatusNotFound, "not found") + } + return c.String(http.StatusOK, v) + default: + return nil + } + }) +} + +func main() {} + +type cachedData struct { + Value string +} + +// FilterName is the Envoy filter name for the shared cache fixture. +const FilterName = "shared_cache" diff --git a/test/e2e/tests/envoy_property.go b/test/e2e/tests/envoy_property.go new file mode 100644 index 0000000..6425ae7 --- /dev/null +++ b/test/e2e/tests/envoy_property.go @@ -0,0 +1,50 @@ +//go:build e2e +// +build e2e + +package tests + +import ( + "net/http" + "testing" + + "github.com/ardikabs/gonvoy/pkg/suite" + "github.com/stretchr/testify/require" +) + +func init() { + TestCases = append(TestCases, EnvoyPropertyTestCase) +} + +var EnvoyPropertyTestCase = suite.TestCase{ + Name: "EnvoyPropertyTest", + FilterName: "envoy_property", + Description: "Verify internal Envoy attributes are exposed via Attr and echo back as response headers.", + Parallel: true, + Test: func(t *testing.T, kit *suite.TestSuiteKit) { + stop := kit.StartEnvoy(t) + defer stop() + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.Host = "envoy-property.test" + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "envoy-property.test", resp.Header.Get("x-request-host")) + require.Equal(t, http.MethodGet, resp.Header.Get("x-request-method")) + require.Equal(t, "/", resp.Header.Get("x-request-path")) + require.Equal(t, "staticreply", resp.Header.Get("x-xds-cluster")) + require.Equal(t, "property-route", resp.Header.Get("x-xds-route")) + require.Equal(t, "200", resp.Header.Get("x-response-code")) + require.Equal(t, "fallback", resp.Header.Get("x-missing-value")) + require.Equal(t, "false", resp.Header.Get("x-missing-ok")) + + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("envoy property response attrs") + }, kit.WaitDuration, kit.TickDuration, "failed to find envoy property log message in envoy log") + }, +} diff --git a/test/e2e/tests/http_query_validation.go b/test/e2e/tests/http_query_validation.go new file mode 100644 index 0000000..63b8b44 --- /dev/null +++ b/test/e2e/tests/http_query_validation.go @@ -0,0 +1,173 @@ +//go:build e2e +// +build e2e + +package tests + +import ( + "net/http" + "net/url" + "testing" + + "github.com/ardikabs/gonvoy/pkg/suite" + "github.com/stretchr/testify/require" +) + +func init() { + TestCases = append(TestCases, HttpQueryValidationTestCase) +} + +var HttpQueryValidationTestCase = suite.TestCase{ + Name: "HttpQueryValidationTest", + FilterName: "http_query_validation", + Description: "Validate HTTP query parameters on GET /search using QueryParam and Path.", + Parallel: true, + Test: func(t *testing.T, kit *suite.TestSuiteKit) { + stop := kit.StartEnvoy(t) + defer stop() + + t.Run("valid q returns 200", func(t *testing.T) { + values := url.Values{} + values.Set("q", "golang") + values.Set("mode", "READ") + values.Set("limit", "10") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("query validated q=golang path=/search") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log: %s", kit.ShowEnvoyLog()) + }) + + t.Run("missing q returns 400", func(t *testing.T) { + values := url.Values{} + values.Set("mode", "READ") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("empty q returns 400", func(t *testing.T) { + values := url.Values{} + values.Set("q", "") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("invalid mode returns 400", func(t *testing.T) { + values := url.Values{} + values.Set("q", "golang") + values.Set("mode", "DELETE") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("invalid limit returns 400", func(t *testing.T) { + for _, limit := range []string{"0", "101", "abc"} { + values := url.Values{} + values.Set("q", "golang") + values.Set("limit", limit) + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "limit=%s should be rejected", limit) + } + }) + + t.Run("multi-value tag returns first value", func(t *testing.T) { + // QueryParam uses r.URL.Query().Get which returns the first value. + values := url.Values{} + values.Set("q", "multitag") + values.Add("tag", "a") + values.Add("tag", "b") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("tag=a") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log: %s", kit.ShowEnvoyLog()) + }) + + t.Run("encoded q decodes", func(t *testing.T) { + values := url.Values{} + values.Set("q", "hello world") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Same payload with explicit %20 encoding. + rawReq, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?q=hello%20world", nil) + require.NoError(t, err) + + rawResp, err := http.DefaultClient.Do(rawReq) + require.NoError(t, err) + defer rawResp.Body.Close() + + require.Equal(t, http.StatusOK, rawResp.StatusCode) + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("hello world") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log: %s", kit.ShowEnvoyLog()) + }) + + t.Run("unknown extra params ignored", func(t *testing.T) { + values := url.Values{} + values.Set("q", "extra") + values.Set("foo", "bar") + values.Set("unknown", "1") + + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/search?"+values.Encode(), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("query validated q=extra path=/search") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log: %s", kit.ShowEnvoyLog()) + }) + }, +} diff --git a/test/e2e/tests/jwt_validation.go b/test/e2e/tests/jwt_validation.go new file mode 100644 index 0000000..ad972fe --- /dev/null +++ b/test/e2e/tests/jwt_validation.go @@ -0,0 +1,217 @@ +//go:build e2e +// +build e2e + +package tests + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/ardikabs/gonvoy/pkg/suite" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func init() { + TestCases = append(TestCases, JwtValidationTestCase) +} + +// JwtValidationTestCase observes Envoy jwt_authn via the x-claim-name header. +// +// Envoy is configured with an inline JWKS (hermetic, no remote fetch) for a +// single HS256 issuer. Tokens below are signed deterministically with the +// fixed test secret so the suite needs no external JWT dependency. +var JwtValidationTestCase = suite.TestCase{ + Name: "JwtValidationTest", + FilterName: "jwt_validation", + Description: "Validate JWT via Envoy jwt_authn (inline JWKS) and observe the claim_to_headers mapping in the Go filter.", + Parallel: true, + Test: func(t *testing.T, kit *suite.TestSuiteKit) { + stop := kit.StartEnvoy(t) + defer stop() + + valid := jwtValidationBuildToken(t, jwtTestAudience, jwtTestClaimName, jwtTestValidExp) + expired := jwtValidationBuildToken(t, jwtTestAudience, jwtTestClaimName, jwtTestExpiredExp) + wrongAud := jwtValidationBuildToken(t, "wrong-audience", jwtTestClaimName, jwtTestValidExp) + noNameClaim := jwtValidationBuildToken(t, jwtTestAudience, "", jwtTestValidExp) + tampered := valid[:len(valid)-1] + jwtValidationFlipLast(valid[len(valid)-1]) + + t.Run("missing token returns 401", func(t *testing.T) { + resp, err := http.Get(kit.GetEnvoyHost() + "/") + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("valid token via header returns 200 and propagates claim", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+valid) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, jwtTestClaimName, resp.Header.Get("x-claim-name")) + }) + + t.Run("tampered signature returns 401", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tampered) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("expired token returns 401", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+expired) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("wrong audience returns 403", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+wrongAud) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("valid token via query param returns 200", func(t *testing.T) { + resp, err := http.Get(kit.GetEnvoyHost() + "/?access_token=" + valid) + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, jwtTestClaimName, resp.Header.Get("x-claim-name")) + }) + + t.Run("valid token via cookie returns 200", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.AddCookie(&http.Cookie{Name: "access_token", Value: valid}) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, jwtTestClaimName, resp.Header.Get("x-claim-name")) + }) + + t.Run("valid signature without name claim returns 401 JSON from Go filter", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, kit.GetEnvoyHost()+"/", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+noNameClaim) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + payload := gjson.ParseBytes(body) + + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Equal(t, "UNAUTHORIZED", payload.Get("code").Str) + require.Equal(t, "UNAUTHORIZED", payload.Get("message").Str) + }) + }, +} + +// Fixed HS256 test identity. The base64url of jwtValidationTestSecret is +// embedded as the "k" parameter of the inline JWKS in envoy.yaml, so tokens +// signed here verify hermetically without any remote fetch. +const jwtValidationTestSecret = "gonvoy-e2e-jwt-test-secret-0123456789" + +const jwtValidationTestIssuer = "https://test-issuer.example.com/" + +const jwtTestAudience = "test-audience" + +const jwtTestClaimName = "gonvoy-tester" + +const jwtTestKid = "test-key" + +// jwtTestValidExp is 2100-01-01T00:00:00Z, jwtTestExpiredExp is +// 2001-09-09T01:46:40Z, and jwtTestIat is fixed so tokens are deterministic. +const jwtTestValidExp = 4102444800 + +const jwtTestExpiredExp = 1000000000 + +const jwtTestIat = 1700000000 + +type jwtValidationClaims struct { + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Iss string `json:"iss"` + Name string `json:"name,omitempty"` + Sub string `json:"sub"` +} + +func jwtValidationBuildToken(t *testing.T, aud, name string, exp int64) string { + t.Helper() + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT","kid":"` + jwtTestKid + `"}`)) + + payload, err := json.Marshal(jwtValidationClaims{ + Aud: aud, + Exp: exp, + Iat: jwtTestIat, + Iss: jwtValidationTestIssuer, + Name: name, + Sub: "test-user", + }) + require.NoError(t, err) + + unsigned := header + "." + base64.RawURLEncoding.EncodeToString(payload) + + mac := hmac.New(sha256.New, []byte(jwtValidationTestSecret)) + _, err = mac.Write([]byte(unsigned)) + require.NoError(t, err) + + return unsigned + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func jwtValidationFlipLast(last byte) string { + if last == 'A' { + return "B" + } + + return "A" +} diff --git a/test/e2e/tests/shared_cache.go b/test/e2e/tests/shared_cache.go new file mode 100644 index 0000000..e034c4c --- /dev/null +++ b/test/e2e/tests/shared_cache.go @@ -0,0 +1,123 @@ +//go:build e2e +// +build e2e + +package tests + +import ( + "io" + "net/http" + "testing" + + "github.com/ardikabs/gonvoy/pkg/suite" + "github.com/stretchr/testify/require" +) + +func init() { + TestCases = append(TestCases, SharedCacheTestCase) +} + +// SharedCacheTestCase verifies the filter-wide cache persists across requests. +var SharedCacheTestCase = suite.TestCase{ + Name: "SharedCacheTest", + FilterName: "shared_cache", + Description: "Running test to simulate shared cache globally throughout Envoy's lifespan.", + Parallel: true, + Test: func(t *testing.T, kit *suite.TestSuiteKit) { + stop := kit.StartEnvoy(t) + defer stop() + + t.Run("store then load persists across requests", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, kit.GetEnvoyHost()+"/store?key=persist-key&value=hello", nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("shared cache store") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log") + + loadResp, err := http.Get(kit.GetEnvoyHost() + "/load?key=persist-key") + require.NoError(t, err) + defer loadResp.Body.Close() + + body, err := io.ReadAll(loadResp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, loadResp.StatusCode) + require.Equal(t, "hello", string(body)) + + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("shared cache hit") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log") + }) + + t.Run("overwrite existing key", func(t *testing.T) { + store := func(value string) { + req, err := http.NewRequest(http.MethodPost, kit.GetEnvoyHost()+"/store?key=overwrite-key&value="+value, nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + } + + load := func() (int, string) { + resp, err := http.Get(kit.GetEnvoyHost() + "/load?key=overwrite-key") + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(body) + } + + store("v1") + + code, body := load() + require.Equal(t, http.StatusOK, code) + require.Equal(t, "v1", body) + + store("v2") + + code, body = load() + require.Equal(t, http.StatusOK, code) + require.Equal(t, "v2", body) + }) + + t.Run("miss returns 404", func(t *testing.T) { + resp, err := http.Get(kit.GetEnvoyHost() + "/load?key=missing-key") + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("type mismatch returns 500", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, kit.GetEnvoyHost()+"/store-struct?key=struct-key", nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + badResp, err := http.Get(kit.GetEnvoyHost() + "/load-struct-bad?key=struct-key") + require.NoError(t, err) + defer badResp.Body.Close() + + require.Equal(t, http.StatusInternalServerError, badResp.StatusCode) + + require.Eventually(t, func() bool { + return kit.CheckEnvoyLog("shared cache load type mismatch") + }, kit.WaitDuration, kit.TickDuration, "failed to find log message in envoy log") + }) + }, +}