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

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