mirror of
https://github.com/alibaba/higress.git
synced 2026-05-08 04:17:27 +08:00
feat: e2e test support http body check (#733)
This commit is contained in:
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user