feat: e2e test support http body check (#733)

This commit is contained in:
Uncle-Justice
2024-01-12 14:08:46 +08:00
committed by GitHub
parent d35d23e2d5
commit b825f9176f
7 changed files with 693 additions and 28 deletions

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {