From b825f9176faefaabf7b5e1ec2db05980aa78904f Mon Sep 17 00:00:00 2001 From: Uncle-Justice <40391338+Uncle-Justice@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:08:46 +0800 Subject: [PATCH] feat: e2e test support http body check (#733) --- test/e2e/conformance/base/manifests.yaml | 77 ++++- .../tests/go-wasm-request-block.go | 70 ++++ .../tests/go-wasm-request-block.yaml | 23 ++ .../conformance/tests/go-wasm-transformer.go | 83 ++++- .../tests/go-wasm-transformer.yaml | 120 ++++++- test/e2e/conformance/utils/http/http.go | 311 +++++++++++++++++- .../utils/roundtripper/roundtripper.go | 37 ++- 7 files changed, 693 insertions(+), 28 deletions(-) diff --git a/test/e2e/conformance/base/manifests.yaml b/test/e2e/conformance/base/manifests.yaml index 9b0a7ae9d..1fc59515c 100644 --- a/test/e2e/conformance/base/manifests.yaml +++ b/test/e2e/conformance/base/manifests.yaml @@ -61,7 +61,10 @@ spec: containers: - name: infra-backend-v1 # From https://github.com/kubernetes-sigs/ingress-controller-conformance/tree/master/images/echoserver - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + + # From https://github.com/Uncle-Justice/echo-server + image: 873292889/echo-server:1.3.0 env: - name: POD_NAME valueFrom: @@ -107,7 +110,10 @@ spec: spec: containers: - name: infra-backend-v2 - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + + # From https://github.com/Uncle-Justice/echo-server + image: 873292889/echo-server:1.3.0 env: - name: POD_NAME valueFrom: @@ -153,7 +159,57 @@ spec: spec: containers: - name: infra-backend-v3 - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + + # From https://github.com/Uncle-Justice/echo-server + image: 873292889/echo-server:1.3.0 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: + cpu: 10m +--- +apiVersion: v1 +kind: Service +metadata: + name: infra-backend-echo-body-v1 + namespace: higress-conformance-infra +spec: + selector: + app: infra-backend-echo-body-v1 + ports: + - protocol: TCP + port: 8080 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infra-backend-echo-body-v1 + namespace: higress-conformance-infra + labels: + app: infra-backend-echo-body-v1 +spec: + replicas: 2 + selector: + matchLabels: + app: infra-backend-echo-body-v1 + template: + metadata: + labels: + app: infra-backend-echo-body-v1 + spec: + containers: + - name: infra-backend-echo-body-v1 + # FROM https://github.com/higress-group/echo-body + image: 873292889/echo-body:1.0.0 env: - name: POD_NAME valueFrom: @@ -206,7 +262,10 @@ spec: spec: containers: - name: app-backend-v1 - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + + # From https://github.com/Uncle-Justice/echo-server + image: 873292889/echo-server:1.3.0 env: - name: POD_NAME valueFrom: @@ -252,7 +311,10 @@ spec: spec: containers: - name: app-backend-v2 - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + + # From https://github.com/Uncle-Justice/echo-server + image: 873292889/echo-server:1.3.0 env: - name: POD_NAME valueFrom: @@ -305,7 +367,10 @@ spec: spec: containers: - name: web-backend - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + # image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echoserver:v20221109-7ee2f3e + + # From https://github.com/Uncle-Justice/echo-server + image: 873292889/echo-server:1.3.0 env: - name: POD_NAME valueFrom: diff --git a/test/e2e/conformance/tests/go-wasm-request-block.go b/test/e2e/conformance/tests/go-wasm-request-block.go index 8419aabcd..524205dbe 100644 --- a/test/e2e/conformance/tests/go-wasm-request-block.go +++ b/test/e2e/conformance/tests/go-wasm-request-block.go @@ -86,6 +86,76 @@ var WasmPluginsRequestBlock = suite.ConformanceTest{ }, }, }, + { + // post blocked body + Meta: http.AssertionMeta{ + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Method: "POST", + ContentType: http.ContentTypeTextPlain, + Body: []byte(`hello world`), + UnfollowRedirect: true, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 403, + }, + }, + }, + { + // check body echoed back in expected request(same as ActualRequest if not set) + Meta: http.AssertionMeta{ + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + CompareTarget: http.CompareTargetRequest, + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Method: "POST", + ContentType: http.ContentTypeTextPlain, + Body: []byte(`hello higress`), + UnfollowRedirect: true, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + // check body echoed back in expected response + Meta: http.AssertionMeta{ + TargetBackend: "infra-backend-echo-body-v1", + TargetNamespace: "higress-conformance-infra", + CompareTarget: http.CompareTargetResponse, + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo2.com", + Path: "/foo", + Method: "POST", + ContentType: http.ContentTypeTextPlain, + Body: []byte(`hello higress`), + UnfollowRedirect: true, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + ContentType: http.ContentTypeTextPlain, + Body: []byte(`hello higress`), + }, + }, + }, } t.Run("WasmPlugins request-block", func(t *testing.T) { for _, testcase := range testcases { diff --git a/test/e2e/conformance/tests/go-wasm-request-block.yaml b/test/e2e/conformance/tests/go-wasm-request-block.yaml index 5312a5b3e..8e866704f 100644 --- a/test/e2e/conformance/tests/go-wasm-request-block.yaml +++ b/test/e2e/conformance/tests/go-wasm-request-block.yaml @@ -33,6 +33,27 @@ spec: port: number: 8080 --- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + nginx.ingress.kubernetes.io/app-root: "/foo" + name: httproute-app-root2 + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo2.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: infra-backend-echo-body-v1 + port: + number: 8080 +--- apiVersion: extensions.higress.io/v1alpha1 kind: WasmPlugin metadata: @@ -46,4 +67,6 @@ spec: - "/env.*" block_exact_urls: - "/web/info" + block_bodies: + - "hello world" url: file:///opt/plugins/wasm-go/extensions/request-block/plugin.wasm diff --git a/test/e2e/conformance/tests/go-wasm-transformer.go b/test/e2e/conformance/tests/go-wasm-transformer.go index 74a6788f2..b323cdea0 100644 --- a/test/e2e/conformance/tests/go-wasm-transformer.go +++ b/test/e2e/conformance/tests/go-wasm-transformer.go @@ -35,7 +35,7 @@ var WasmPluginsTransformer = suite.ConformanceTest{ testcases := []http.Assertion{ { Meta: http.AssertionMeta{ - TestCaseName: "case 1: request transformer", + TestCaseName: "case 1: request header&query transformer", TargetBackend: "infra-backend-v1", TargetNamespace: "higress-conformance-infra", }, @@ -77,7 +77,7 @@ var WasmPluginsTransformer = suite.ConformanceTest{ }, { Meta: http.AssertionMeta{ - TestCaseName: "case 2: response transformer", + TestCaseName: "case 2: response header&query transformer", TargetBackend: "infra-backend-v1", TargetNamespace: "higress-conformance-infra", }, @@ -111,6 +111,85 @@ var WasmPluginsTransformer = suite.ConformanceTest{ }, }, }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 4: request body transformer", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo4.com", + Path: "/post", + // TODO(Uncle-Justice) dedupe, replace的body插件逻辑有问题,暂跳过测试 + Method: "POST", + Body: []byte(` + { + "X-removed":["v1", "v2"], + "X-not-renamed":["v1"] + } + `), + ContentType: http.ContentTypeApplicationJson, + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "foo4.com", + Path: "/post", + // TODO(Uncle-Justice) dedupe, replace的body插件逻辑有问题,暂跳过测试 + Method: "POST", + ContentType: http.ContentTypeApplicationJson, + Body: []byte(` + { + "X-renamed":["v1"], + "X-add-append":["add","append"], + "X-map":["add","append"] + } + `), + }, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 5: response json body transformer", + TargetBackend: "infra-backend-echo-body-v1", + TargetNamespace: "higress-conformance-infra", + CompareTarget: http.CompareTargetResponse, + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo5.com", + Path: "/post", + // TODO(Uncle-Justice) dedupe, replace的body插件逻辑有问题,暂跳过测试 + Method: "POST", + Body: []byte(` + { + "X-removed":["v1", "v2"], + "X-not-renamed":["v1"] + } + `), + ContentType: http.ContentTypeApplicationJson, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + ContentType: http.ContentTypeApplicationJson, + Body: []byte(` + { + "X-renamed":["v1"], + "X-add-append":["add","append"], + "X-map":["add","append"] + } + `), + }, + }, + }, } t.Run("WasmPlugin transformer", func(t *testing.T) { for _, testcase := range testcases { diff --git a/test/e2e/conformance/tests/go-wasm-transformer.yaml b/test/e2e/conformance/tests/go-wasm-transformer.yaml index 51e56cf46..34c070a84 100644 --- a/test/e2e/conformance/tests/go-wasm-transformer.yaml +++ b/test/e2e/conformance/tests/go-wasm-transformer.yaml @@ -36,7 +36,7 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: - name: wasmplugin-transform-response + name: wasmplugin-transform-response-header-and-query namespace: higress-conformance-infra spec: ingressClassName: higress @@ -52,6 +52,46 @@ spec: port: number: 8080 --- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + name: wasmplugin-transform-request-body + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo4.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + name: wasmplugin-transform-response-body + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo5.com" + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: infra-backend-echo-body-v1 + port: + number: 8080 +--- apiVersion: extensions.higress.io/v1alpha1 kind: WasmPlugin metadata: @@ -118,9 +158,8 @@ spec: - key: k4 value: RETAIN_FIRST - # response transformer - ingress: - - higress-conformance-infra/wasmplugin-transform-response + - higress-conformance-infra/wasmplugin-transform-response-header-and-query configDisable: false config: type: response @@ -151,5 +190,78 @@ spec: headers: - key: X-add-append value: X-map - + - ingress: + - higress-conformance-infra/wasmplugin-transform-request-body + configDisable: false + config: + type: request + rules: + - operate: remove + body: + - key: X-removed + - operate: rename + body: + - key: X-not-renamed + value: X-renamed + - operate: replace + body: + - key: X-replace + value: replaced + - operate: add + body: + - key: X-add-append + value: add + - operate: append + body: + - key: X-add-append + value: append + - operate: map + body: + - key: X-add-append + value: X-map + - operate: dedupe + body: + - key: X-dedupe-first + value: RETAIN_FIRST + - key: X-dedupe-last + value: RETAIN_LAST + - key: X-dedupe-unique + value: RETAIN_UNIQUE + - ingress: + - higress-conformance-infra/wasmplugin-transform-response-body + configDisable: false + config: + type: response + rules: + - operate: remove + body: + - key: X-removed + - operate: rename + body: + - key: X-not-renamed + value: X-renamed + - operate: replace + body: + - key: X-replace + value: replaced + - operate: add + body: + - key: X-add-append + value: add + - operate: append + body: + - key: X-add-append + value: append + - operate: map + body: + - key: X-add-append + value: X-map + - operate: dedupe + body: + - key: X-dedupe-first + value: RETAIN_FIRST + - key: X-dedupe-last + value: RETAIN_LAST + - key: X-dedupe-unique + value: RETAIN_UNIQUE url: file:///opt/plugins/wasm-go/extensions/transformer/plugin.wasm \ No newline at end of file diff --git a/test/e2e/conformance/utils/http/http.go b/test/e2e/conformance/utils/http/http.go index 8a3d8b5e5..ce3e56647 100644 --- a/test/e2e/conformance/utils/http/http.go +++ b/test/e2e/conformance/utils/http/http.go @@ -14,8 +14,14 @@ limitations under the License. package http import ( + "bytes" + "encoding/json" "fmt" + "io" + "mime" + "mime/multipart" "net/url" + "reflect" "strings" "testing" "time" @@ -37,6 +43,8 @@ type AssertionMeta struct { TargetBackend string // TargetNamespace defines the target backend namespace TargetNamespace string + // CompareTarget defines who's header&body to compare in test, either CompareTargetResponse or CompareTargetRequest + CompareTarget string } type AssertionRequest struct { @@ -62,6 +70,18 @@ type AssertionResponse struct { ExpectedResponseNoRequest bool } +const ( + ContentTypeApplicationJson string = "application/json" + ContentTypeFormUrlencoded = "application/x-www-form-urlencoded" + ContentTypeMultipartForm = "multipart/form-data" + ContentTypeTextPlain = "text/plain" +) + +const ( + CompareTargetRequest = "Request" + CompareTargetResponse = "Response" +) + // Request can be used as both the request to make and a means to verify // that echoserver received the expected request. Note that multiple header // values can be provided, as a comma-separated value. @@ -70,6 +90,8 @@ type Request struct { Method string Path string Headers map[string]string + Body []byte + ContentType string UnfollowRedirect bool TLSConfig *TLSConfig } @@ -118,6 +140,8 @@ type ExpectedRequest struct { type Response struct { StatusCode int Headers map[string]string + Body []byte + ContentType string AbsentHeaders []string } @@ -164,11 +188,22 @@ func MakeRequestAndExpectEventuallyConsistentResponse(t *testing.T, r roundtripp tlsConfig.SNI = expected.Request.ActualRequest.Host } } - + if expected.Meta.CompareTarget == "" { + expected.Meta.CompareTarget = CompareTargetRequest + } if expected.Request.ActualRequest.Method == "" { expected.Request.ActualRequest.Method = "GET" } + if expected.Request.ActualRequest.Body != nil && len(expected.Request.ActualRequest.Body) > 0 { + if len(expected.Request.ActualRequest.ContentType) == 0 { + t.Error(`please set Content-Type in ActualRequest manually if you want to send a request with body. + For example, \"ContentType: http.ContentTypeApplicationJson\"`) + } + } + + expected.Request.ActualRequest.Method = strings.ToUpper(expected.Request.ActualRequest.Method) + if expected.Response.ExpectedResponse.StatusCode == 0 { expected.Response.ExpectedResponse.StatusCode = 200 } @@ -183,6 +218,8 @@ func MakeRequestAndExpectEventuallyConsistentResponse(t *testing.T, r roundtripp URL: url.URL{Scheme: scheme, Host: gwAddr, Path: path, RawQuery: query}, Protocol: protocol, Headers: map[string][]string{}, + Body: expected.Request.ActualRequest.Body, + ContentType: expected.Request.ActualRequest.ContentType, UnfollowRedirect: expected.Request.ActualRequest.UnfollowRedirect, TLSConfig: tlsConfig, } @@ -252,9 +289,36 @@ func WaitForConsistentResponse(t *testing.T, r roundtripper.RoundTripper, req ro t.Logf("Request failed, not ready yet: %v (after %v)", err.Error(), elapsed) return false } + // CompareTarget为Request(默认)时,ExpectedRequest中设置的所有断言均支持;除ExpectedResponse.Body外,ExpectedResponse中设置的所有断言均支持。目前支持echo-server作为backend + // CompareTarget为Response时,不支持设定ExpectedRequest断言,ExpectedResponse中设置的所有断言均支持。支持任意backend,如echo-body + if expected.Meta.CompareTarget == CompareTargetRequest { + if expected.Response.ExpectedResponse.Body != nil { + t.Logf(`detected CompareTarget is Request, but ExpectedResponse.Body is set. + You can only choose one to compare between Response and Request.`) + return false + } - if err := CompareRequest(&req, cReq, cRes, expected); err != nil { - t.Logf("Response expectation failed for request: %v not ready yet: %v (after %v)", req, err, elapsed) + if cRes.StatusCode == 200 && !expected.Response.ExpectedResponseNoRequest && cReq.Host == "" && cReq.Path == "" && cReq.Headers == nil && cReq.Body == nil { + t.Logf(`decoding client's response failed. Maybe you have chosen a wrong backend. + Choose echo-server if you want to check expected request header&body instead of response header&body.`) + return false + } + if err := CompareRequest(&req, cReq, cRes, expected); err != nil { + t.Logf("request expectation failed for actual request: %v not ready yet: %v (after %v)", req, err, elapsed) + return false + } + } else if expected.Meta.CompareTarget == CompareTargetResponse { + if expected.Request.ExpectedRequest != nil { + t.Logf(`detected CompareTarget is Response, but ExpectedRequest is set. + You can only choose one to compare between Response and Request.`) + return false + } + if err := CompareResponse(cRes, expected); err != nil { + t.Logf("Response expectation failed for actual request: %v not ready yet: %v (after %v)", req, err, elapsed) + return false + } + } else { + t.Logf("invalid CompareTarget: %v please set it Request or Response", expected.Meta.CompareTarget, err, elapsed) return false } @@ -306,10 +370,85 @@ func CompareRequest(req *roundtripper.Request, cReq *roundtripper.CapturedReques } else if strings.Join(actualVal, ",") != expectedVal { return fmt.Errorf("expected %s header to be set to %s, got %s", name, expectedVal, strings.Join(actualVal, ",")) } - } } + if expected.Request.ExpectedRequest.Body != nil && len(expected.Request.ExpectedRequest.Body) > 0 { + // 对ExpectedRequest.Body做断言时,须手动指定ExpectedRequest.ContentType + if len(expected.Request.ExpectedRequest.ContentType) == 0 { + return fmt.Errorf("ExpectedRequest.ContentType should not be empty since ExpectedRequest.Body is set") + } + if cReq.Headers["Content-Type"] == nil || len(cReq.Headers["Content-Type"]) == 0 { + cReq.Headers["Content-Type"] = []string{expected.Request.ExpectedRequest.ContentType} + } + + eTyp, eParams, err := mime.ParseMediaType(expected.Request.ExpectedRequest.ContentType) + if err != nil { + return fmt.Errorf("ExpectedRequest Content-Type: %s failed to parse: %s", expected.Request.ExpectedRequest.ContentType, err.Error()) + } + + cTyp := cReq.Headers["Content-Type"][0] + + if eTyp != cTyp { + return fmt.Errorf("expected %s Content-Type to be set, got %s", expected.Request.ExpectedRequest.ContentType, cReq.Headers["Content-Type"][0]) + } + var ok bool + switch cTyp { + case ContentTypeTextPlain: + if string(expected.Request.ExpectedRequest.Body) != cReq.Body.(string) { + return fmt.Errorf("expected %s body to be %s, got %s##", cTyp, string(expected.Request.ExpectedRequest.Body), cReq.Body.(string)) + } + case ContentTypeApplicationJson: + var eReqBody map[string]interface{} + var cReqBody map[string]interface{} + + err := json.Unmarshal(expected.Request.ExpectedRequest.Body, &eReqBody) + if err != nil { + return fmt.Errorf("failed to unmarshall ExpectedRequest body %s, %s", string(expected.Request.ExpectedRequest.Body), err.Error()) + } + + if cReqBody, ok = cReq.Body.(map[string]interface{}); !ok { + return fmt.Errorf("failed to parse CapturedRequest body %s, %s", string(cReq.Body.([]byte)), err.Error()) + } + + if !reflect.DeepEqual(eReqBody, cReqBody) { + b, _ := json.Marshal(cReqBody) + return fmt.Errorf("expected %s body to be %s, got %s", cTyp, string(expected.Request.ExpectedRequest.Body), string(b)) + } + case ContentTypeFormUrlencoded: + var eReqBody map[string][]string + var cReqBody map[string][]string + eReqBody, err = ParseFormUrlencodedBody(expected.Request.ExpectedRequest.Body) + if err != nil { + return fmt.Errorf("failed to parse ExpectedRequest body %s, %s", string(expected.Request.ExpectedRequest.Body), err.Error()) + } + + if cReqBody, ok = cReq.Body.(map[string][]string); !ok { + return fmt.Errorf("failed to parse CapturedRequest body %s, %s", string(cReq.Body.([]byte)), err.Error()) + } + + if !reflect.DeepEqual(eReqBody, cReqBody) { + return fmt.Errorf("expected %s body to be %s, got %s", cTyp, string(expected.Request.ExpectedRequest.Body), string(cReq.Body.([]byte))) + } + case ContentTypeMultipartForm: + var eReqBody map[string][]string + var cReqBody map[string][]string + + eReqBody, err = ParseMultipartFormBody(expected.Request.ExpectedRequest.Body, eParams["boundary"]) + if err != nil { + return fmt.Errorf("failed to parse ExpectedRequest body %s, %s", string(expected.Request.ExpectedRequest.Body), err.Error()) + } + if cReqBody, ok = cReq.Body.(map[string][]string); !ok { + return fmt.Errorf("failed to parse CapturedRequest body %s, %s", string(cReq.Body.([]byte)), err.Error()) + } + + if !reflect.DeepEqual(eReqBody, cReqBody) { + return fmt.Errorf("expected %s body to be %s, got %s", cTyp, string(expected.Request.ExpectedRequest.Body), string(cReq.Body.([]byte))) + } + default: + return fmt.Errorf("Content-Type: %s invalid or not support.", cTyp) + } + } if expected.Response.ExpectedResponse.Headers != nil { if cRes.Headers == nil { return fmt.Errorf("no headers captured, expected %v", len(expected.Request.ExpectedRequest.Headers)) @@ -359,6 +498,7 @@ func CompareRequest(req *roundtripper.Request, cReq *roundtripper.CapturedReques if !strings.HasPrefix(cReq.Pod, expected.Meta.TargetBackend) { return fmt.Errorf("expected pod name to start with %s, got %s", expected.Meta.TargetBackend, cReq.Pod) } + } else if roundtripper.IsRedirect(cRes.StatusCode) { if expected.Request.RedirectRequest == nil { return nil @@ -385,6 +525,169 @@ func CompareRequest(req *roundtripper.Request, cReq *roundtripper.CapturedReques return nil } +func CompareResponse(cRes *roundtripper.CapturedResponse, expected Assertion) error { + if expected.Response.ExpectedResponse.StatusCode != cRes.StatusCode { + return fmt.Errorf("expected status code to be %d, got %d", expected.Response.ExpectedResponse.StatusCode, cRes.StatusCode) + } + if cRes.StatusCode == 200 { + if len(expected.Meta.TargetNamespace) > 0 { + if cRes.Headers["Namespace"] == nil || len(cRes.Headers["Namespace"]) == 0 { + return fmt.Errorf("expected namespace to be %s, field not found in CaptureResponse", expected.Meta.TargetNamespace) + } + if expected.Meta.TargetNamespace != cRes.Headers["Namespace"][0] { + return fmt.Errorf("expected namespace to be %s, got %s", expected.Meta.TargetNamespace, cRes.Headers["Namespace"][0]) + } + } + + if len(expected.Meta.TargetBackend) > 0 { + if cRes.Headers["Pod"] == nil || len(cRes.Headers["Pod"]) == 0 { + return fmt.Errorf("expected pod to be %s, field not found in CaptureResponse", expected.Meta.TargetBackend) + } + if !strings.HasPrefix(cRes.Headers["Pod"][0], expected.Meta.TargetBackend) { + return fmt.Errorf("expected pod to be %s, got %s", expected.Meta.TargetBackend, cRes.Headers["Pod"][0]) + } + } + + if expected.Response.ExpectedResponse.Headers != nil { + if cRes.Headers == nil { + return fmt.Errorf("no headers captured, expected %v", len(expected.Response.ExpectedResponse.Headers)) + } + for name, val := range cRes.Headers { + cRes.Headers[strings.ToLower(name)] = val + } + for name, expectedVal := range expected.Response.ExpectedResponse.Headers { + actualVal, ok := cRes.Headers[strings.ToLower(name)] + if !ok { + return fmt.Errorf("expected %s header to be set, actual headers: %v", name, cRes.Headers) + } else if strings.Join(actualVal, ",") != expectedVal { + return fmt.Errorf("expected %s header to be set to %s, got %s", name, expectedVal, strings.Join(actualVal, ",")) + } + } + } + if expected.Response.ExpectedResponse.Body != nil && len(expected.Response.ExpectedResponse.Body) > 0 { + // 对ExpectedResponse.Body做断言时,必须指定ExpectedResponse.ContentType + if len(expected.Response.ExpectedResponse.ContentType) == 0 { + return fmt.Errorf("ExpectedResponse.ContentType should not be empty since ExpectedResponse.Body is set") + } + + if cRes.Headers["Content-Type"] == nil || len(cRes.Headers["Content-Type"]) == 0 { + cRes.Headers["Content-Type"] = []string{expected.Response.ExpectedResponse.ContentType} + } + + eTyp, eParams, err := mime.ParseMediaType(expected.Response.ExpectedResponse.ContentType) + if err != nil { + return fmt.Errorf("ExpectedResponse Content-Type: %s failed to parse: %s", expected.Response.ExpectedResponse.ContentType, err.Error()) + } + cTyp, cParams, err := mime.ParseMediaType(cRes.Headers["Content-Type"][0]) + if err != nil { + return fmt.Errorf("CapturedResponse Content-Type: %s failed to parse: %s", cRes.Headers["Content-Type"][0], err.Error()) + } + + if eTyp != cTyp { + return fmt.Errorf("expected %s Content-Type to be set, got %s", expected.Response.ExpectedResponse.ContentType, cRes.Headers["Content-Type"][0]) + } + + switch cTyp { + case ContentTypeTextPlain: + if !bytes.Equal(expected.Response.ExpectedResponse.Body, cRes.Body) { + return fmt.Errorf("expected %s body to be %s, got %s##", cTyp, string(expected.Response.ExpectedResponse.Body), string(cRes.Body)) + } + case ContentTypeApplicationJson: + eResBody := make(map[string]interface{}) + cResBody := make(map[string]interface{}) + err := json.Unmarshal(expected.Response.ExpectedResponse.Body, &eResBody) + if err != nil { + return fmt.Errorf("failed to unmarshall ExpectedResponse body %s, %s", string(expected.Response.ExpectedResponse.Body), err.Error()) + } + err = json.Unmarshal(cRes.Body, &cResBody) + if err != nil { + return fmt.Errorf("failed to unmarshall CapturedResponse body %s, %s", string(cRes.Body), err.Error()) + } + + if !reflect.DeepEqual(eResBody, cResBody) { + return fmt.Errorf("expected %s body to be %s, got %s", cTyp, string(expected.Response.ExpectedResponse.Body), string(cRes.Body)) + } + case ContentTypeFormUrlencoded: + eResBody, err := ParseFormUrlencodedBody(expected.Response.ExpectedResponse.Body) + if err != nil { + return fmt.Errorf("failed to parse ExpectedResponse body %s, %s", string(expected.Response.ExpectedResponse.Body), err.Error()) + } + cResBody, err := ParseFormUrlencodedBody(cRes.Body) + if err != nil { + return fmt.Errorf("failed to parse CapturedResponse body %s, %s", string(cRes.Body), err.Error()) + } + + if !reflect.DeepEqual(eResBody, cResBody) { + return fmt.Errorf("expected %s body to be %s, got %s", cTyp, string(expected.Response.ExpectedResponse.Body), string(cRes.Body)) + } + case ContentTypeMultipartForm: + eResBody, err := ParseMultipartFormBody(expected.Response.ExpectedResponse.Body, eParams["boundary"]) + if err != nil { + return fmt.Errorf("failed to parse ExpectedResponse body %s, %s", string(expected.Response.ExpectedResponse.Body), err.Error()) + } + cResBody, err := ParseMultipartFormBody(cRes.Body, cParams["boundary"]) + if err != nil { + return fmt.Errorf("failed to parse CapturedResponse body %s, %s", string(cRes.Body), err.Error()) + } + if !reflect.DeepEqual(eResBody, cResBody) { + return fmt.Errorf("expected %s body to be %s, got %s", cTyp, string(expected.Response.ExpectedResponse.Body), string(cRes.Body)) + } + default: + return fmt.Errorf("Content-Type: %s invalid or not support.", cTyp) + } + } + if len(expected.Response.ExpectedResponse.AbsentHeaders) > 0 { + for name, val := range cRes.Headers { + cRes.Headers[strings.ToLower(name)] = val + } + + for _, name := range expected.Response.ExpectedResponse.AbsentHeaders { + val, ok := cRes.Headers[strings.ToLower(name)] + if ok { + return fmt.Errorf("expected %s header to not be set, got %s", name, val) + } + } + } + } + return nil +} +func ParseFormUrlencodedBody(body []byte) (map[string][]string, error) { + ret := make(map[string][]string) + kvs, err := url.ParseQuery(string(body)) + if err != nil { + return nil, err + } + for k, vs := range kvs { + ret[k] = vs + } + + return ret, nil +} +func ParseMultipartFormBody(body []byte, boundary string) (map[string][]string, error) { + ret := make(map[string][]string) + mr := multipart.NewReader(bytes.NewReader(body), boundary) + for { + p, err := mr.NextPart() + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + formName := p.FormName() + fileName := p.FileName() + if formName == "" || fileName != "" { + continue + } + formValue, err := io.ReadAll(p) + if err != nil { + return nil, err + } + ret[formName] = append(ret[formName], string(formValue)) + } + return ret, nil +} + // Get User-defined test case name or generate from expected response to a given request. func (er *Assertion) GetTestCaseName(i int) string { diff --git a/test/e2e/conformance/utils/roundtripper/roundtripper.go b/test/e2e/conformance/utils/roundtripper/roundtripper.go index 1ff6c02d5..6f707aef5 100644 --- a/test/e2e/conformance/utils/roundtripper/roundtripper.go +++ b/test/e2e/conformance/utils/roundtripper/roundtripper.go @@ -14,6 +14,7 @@ limitations under the License. package roundtripper import ( + "bytes" "context" "crypto/tls" "crypto/x509" @@ -41,6 +42,8 @@ type Request struct { Protocol string Method string Headers map[string][]string + Body []byte + ContentType string UnfollowRedirect bool TLSConfig *TLSConfig } @@ -70,14 +73,14 @@ type ClientKeyPair struct { // CapturedRequest contains request metadata captured from an echoserver // response. type CapturedRequest struct { - Path string `json:"path"` - Host string `json:"host"` - Method string `json:"method"` - Protocol string `json:"proto"` - Headers map[string][]string `json:"headers"` - - Namespace string `json:"namespace"` - Pod string `json:"pod"` + Path string `json:"path"` + Host string `json:"host"` + Method string `json:"method"` + Protocol string `json:"proto"` + Headers map[string][]string `json:"headers"` + Body interface{} `json:"body"` + Namespace string `json:"namespace"` + Pod string `json:"pod"` } // RedirectRequest contains a follow up request metadata captured from a redirect @@ -95,6 +98,7 @@ type CapturedResponse struct { ContentLength int64 Protocol string Headers map[string][]string + Body []byte RedirectRequest *RedirectRequest } @@ -112,7 +116,7 @@ type DefaultRoundTripper struct { func (d *DefaultRoundTripper) CaptureRoundTrip(request Request) (*CapturedRequest, *CapturedResponse, error) { cReq := &CapturedRequest{} client := &http.Client{} - + cRes := &CapturedResponse{} if request.UnfollowRedirect { client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -171,6 +175,11 @@ func (d *DefaultRoundTripper) CaptureRoundTrip(request Request) (*CapturedReques } } + if request.Body != nil { + req.Header.Add("Content-Type", string(request.ContentType)) + req.Body = io.NopCloser(bytes.NewReader(request.Body)) + } + if d.Debug { var dump []byte dump, err = httputil.DumpRequestOut(req, true) @@ -198,21 +207,25 @@ func (d *DefaultRoundTripper) CaptureRoundTrip(request Request) (*CapturedReques fmt.Printf("Received Response:\n%s\n\n", formatDump(dump, "< ")) } - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("unexpected error reading response body: %w", err) + } // we cannot assume the response is JSON - if resp.Header.Get("Content-type") == "application/json" { + if resp.Header.Get("Content-Type") == "application/json" { err = json.Unmarshal(body, cReq) if err != nil { return nil, nil, fmt.Errorf("unexpected error reading response: %w", err) } } - cRes := &CapturedResponse{ + cRes = &CapturedResponse{ StatusCode: resp.StatusCode, ContentLength: resp.ContentLength, Protocol: resp.Proto, Headers: resp.Header, + Body: body, } if IsRedirect(resp.StatusCode) {