diff --git a/internal/app/pactproxy/constraint.go b/internal/app/pactproxy/constraint.go index 74fc55d..284f198 100644 --- a/internal/app/pactproxy/constraint.go +++ b/internal/app/pactproxy/constraint.go @@ -1,9 +1,12 @@ package pactproxy import ( + "fmt" "strings" ) +const fmtLen = "_length_" + type interactionConstraint struct { Interaction string `json:"interaction"` Path string `json:"path"` @@ -15,3 +18,34 @@ type interactionConstraint struct { func (i interactionConstraint) Key() string { return strings.Join([]string{i.Interaction, i.Path}, "_") } + +func (i interactionConstraint) check(expectedValues []interface{}, actualValue interface{}) error { + if i.Format == fmtLen { + if len(expectedValues) != 1 { + return fmt.Errorf( + "expected single positive integer value for path %q length constraint, but there are %v expected values", + i.Path, len(expectedValues)) + } + expected, ok := expectedValues[0].(int) + if !ok || expected < 0 { + return fmt.Errorf("expected value for %q length constraint must be a positive integer", i.Path) + } + + actualSlice, ok := actualValue.([]interface{}) + if !ok { + return fmt.Errorf("value at path %q must be an array due to length constraint", i.Path) + } + if expected != len(actualSlice) { + return fmt.Errorf("value of length %v at path %q does not match length constraint %v", + expected, i.Path, len(actualSlice)) + } + return nil + } + + expected := fmt.Sprintf(i.Format, expectedValues...) + actual := fmt.Sprintf("%v", actualValue) + if expected != actual { + return fmt.Errorf("value %q at path %q does not match constraint %q", actual, i.Path, expected) + } + return nil +} diff --git a/internal/app/pactproxy/interaction.go b/internal/app/pactproxy/interaction.go index 8c5bd29..e9b97a1 100644 --- a/internal/app/pactproxy/interaction.go +++ b/internal/app/pactproxy/interaction.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "mime" - "reflect" "regexp" "strings" "sync" @@ -202,6 +201,8 @@ func getPathRegex(matchingRules map[string]interface{}) (string, error) { return regexString, nil } +// Gets the pact JSON file style matching rules from the "matchingRules" property of the request. +// Note that Pact DSL style matching rules within the body are identified later when adding JSON constraints. func getMatchingRules(request map[string]interface{}) map[string]interface{} { rules, hasRules := request["matchingRules"] if !hasRules { @@ -272,22 +273,40 @@ func parseMediaType(request map[string]interface{}) (string, error) { // have a corresponding matching rule func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, values map[string]interface{}) { for k, v := range values { - switch val := v.(type) { - case map[string]interface{}: - if _, exists := val["json_class"]; exists { - continue - } - i.addJSONConstraintsFromPact(path+"."+k, matchingRules, val) - default: - p := path + "." + k - if _, hasRule := matchingRules[p]; !hasRule { - i.AddConstraint(interactionConstraint{ - Path: p, - Format: "%v", - Values: []interface{}{val}, - }) - } + i.addJSONConstraintsFromPactAny(path+"."+k, matchingRules, v) + } +} + +func (i *Interaction) addJSONConstraintsFromPactAny(path string, matchingRules map[string]bool, value interface{}) { + if _, hasRule := matchingRules[path]; hasRule { + return + } + + switch val := value.(type) { + case map[string]interface{}: + // json_class is used to test for a Pact DSL-style matching rule within the body. The matchingRules passed + // to this method will not include these. + if _, exists := val["json_class"]; exists { + return + } + i.addJSONConstraintsFromPact(path, matchingRules, val) + case []interface{}: + // Create constraints for each element in the array. This allows matching rules to override them. + for j := range val { + i.addJSONConstraintsFromPactAny(fmt.Sprintf("%s[%d]", path, j), matchingRules, val[j]) } + // Length constraint so that requests with additional elements at the end of the array will not match + i.AddConstraint(interactionConstraint{ + Path: path, + Format: fmtLen, + Values: []interface{}{len(val)}, + }) + default: + i.AddConstraint(interactionConstraint{ + Path: path, + Format: "%v", + Values: []interface{}{val}, + }) } } @@ -341,10 +360,10 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions i.mu.RLock() defer i.mu.RUnlock() for _, constraint := range i.constraints { - values := constraint.Values + expected := constraint.Values if constraint.Source != "" { var err error - values, err = i.loadValuesFromSource(constraint, interactions) + expected, err = i.loadValuesFromSource(constraint, interactions) if err != nil { violations = append(violations, err.Error()) result = false @@ -352,22 +371,16 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions } } - actual := "" - val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request)) + actual, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request)) if err != nil { - log.Warn(err) - } - if reflect.TypeOf(val) == reflect.TypeOf([]interface{}{}) { - log.Infof("skipping matching on []interface{} type for path '%s'", constraint.Path) + violations = append(violations, + fmt.Sprintf("constraint path %q cannot be resolved within request: %q", constraint.Path, err)) + result = false continue } - if err == nil { - actual = fmt.Sprintf("%v", val) - } - expected := fmt.Sprintf(constraint.Format, values...) - if actual != expected { - violations = append(violations, fmt.Sprintf("value '%s' at path '%s' does not match constraint '%s'", actual, constraint.Path, expected)) + if err := constraint.check(expected, actual); err != nil { + violations = append(violations, err.Error()) result = false } } diff --git a/internal/app/pactproxy/interaction_test.go b/internal/app/pactproxy/interaction_test.go index 9110e83..ff2409e 100644 --- a/internal/app/pactproxy/interaction_test.go +++ b/internal/app/pactproxy/interaction_test.go @@ -120,6 +120,114 @@ func TestLoadInteractionPlainTextConstraints(t *testing.T) { } } +func TestLoadInteractionJSONConstraints(t *testing.T) { + arrMatchersNotPresent := `{ + "description": "A request to create an address", + "request": { + "method": "POST", + "path": "/addresses", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "addressLines": ["line 1", "line 2"] + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "addressLines": ["line 1", "line 2"] + } + } + }` + arrMatcherPresent := + `{ + "description": "A request to create an address", + "request": { + "method": "POST", + "path": "/addresses", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "addressLines": ["line 1", "line 2"] + }, + "matchingRules": { + "$.body.addressLines[0]": { + "regex": ".*" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "addressLines": ["line 1", "line 2"] + } + } + }` + tests := []struct { + name string + interaction []byte + wantConstraints []interactionConstraint + }{ + { + name: "array and matcher not present - interactions are created per element", + interaction: []byte(arrMatchersNotPresent), + wantConstraints: []interactionConstraint{ + { + Path: "$.body.addressLines[0]", + Format: "%v", + Values: []interface{}{"line 1"}, + }, + { + Path: "$.body.addressLines[1]", + Format: "%v", + Values: []interface{}{"line 2"}, + }, + { + Path: "$.body.addressLines", + Format: fmtLen, + Values: []interface{}{2}, + }, + }, + }, + { + name: "array and matcher present - interaction is not created for matched element", + interaction: []byte(arrMatcherPresent), + wantConstraints: []interactionConstraint{ + { + Path: "$.body.addressLines[1]", + Format: "%v", + Values: []interface{}{"line 2"}, + }, + { + Path: "$.body.addressLines", + Format: fmtLen, + Values: []interface{}{2}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + interaction, err := LoadInteraction(tt.interaction, "alias") + require.NoError(t, err) + + actual := make([]interactionConstraint, 0, len(interaction.constraints)) + for _, constraint := range interaction.constraints { + actual = append(actual, constraint) + } + assert.ElementsMatch(t, tt.wantConstraints, actual) + }) + } +} + // This test asserts that given a pact v3-style nested matching rule, a constraint // is not created for the corresponding property func TestV3MatchingRulesLeadToCorrectConstraints(t *testing.T) { diff --git a/internal/app/proxy_stage_test.go b/internal/app/proxy_stage_test.go index c68660a..c3d1494 100644 --- a/internal/app/proxy_stage_test.go +++ b/internal/app/proxy_stage_test.go @@ -451,8 +451,7 @@ func (s *ProxyStage) the_nth_response_body_is(n int, data []byte) *ProxyStage { s.assert.GreaterOrEqual(len(s.responseBodies), n, "number of request bodies is les than expected") body := s.responseBodies[n-1] - c := bytes.Compare(body, data) - s.assert.Equal(0, c, "Expected body did not match") + s.assert.Equal(data, body, "Expected body did not match") return s } diff --git a/internal/app/proxy_test.go b/internal/app/proxy_test.go index e0c8539..0730de2 100644 --- a/internal/app/proxy_test.go +++ b/internal/app/proxy_test.go @@ -3,6 +3,8 @@ package app import ( "net/http" "testing" + + "github.com/pact-foundation/pact-go/dsl" ) func TestLargePactResponse(t *testing.T) { @@ -592,9 +594,9 @@ func TestArrayBodyRequestUnmatchedRequestBody(t *testing.T) { a_request_is_sent_with("application/json", tc.unmatchedReqBody) then. - // Pact Mock Server returns 500 if request body does not match, - // so the response status code is not checked - pact_verification_is_not_successful() + // Pact Mock Server returns 500 if request body does not match + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusInternalServerError) }) } } @@ -637,3 +639,204 @@ func TestArrayBodyRequestConstraintDoesNotMatch(t *testing.T) { }) } } + +func TestArrayNestedWithinBody(t *testing.T) { + pactReqBody := map[string]interface{}{ + "entries": []interface{}{ + map[string]string{"key": "val"}, + map[string]string{"key": "val"}, + }, + } + const respContentType = "application/json" + const respBody = `[{"status":"ok"}]` + + const matchedReqBody = `{"entries": [ {"key": "val"}, {"key": "val"} ]}` + const unmatchedReqBody = `{"entries": [ {"key": "val"}, {"key": "unexpected value"} ]}` + const unmatchedReqBodyAdditionalArrElem = `{"entries": [ {"key": "val"}, {"key": "val"}, {"key": "val"} ]}` + + const matchedConstraintPath = "$.body.entries[0].key" + const matchedConstraintValue = "val" + + const unmatchedConstraintPath = "$.body.entries[1].key" + const unmatchedConstraintValue = "wrong value" + + const outOfBoundsConstraintPath = "$.body.entries[2].key" + + t.Run("Matches", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) + + t.Run("Does not match - additional array element", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBodyAdditionalArrElem) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) + + t.Run("Matches with additional constraint", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and(). + an_additional_constraint_is_added(matchedConstraintPath, matchedConstraintValue) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match due to additional constraint with different value", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and(). + an_additional_constraint_is_added(unmatchedConstraintPath, unmatchedConstraintValue) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) + + t.Run("Does not match due to additional constraint out of array bounds", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and(). + an_additional_constraint_is_added(outOfBoundsConstraintPath, matchedConstraintValue) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) +} + +// Matching rules are not enforced by Pact proxy, so this test is checking that constraints aren't being +// applied when matching rules are specified, leading to the request being handled by Pact server. +// Note: these tests are using pact DSL style matching rules. Pact proxy identifies these, but differently to +// ones specified in the "matchingRules" JSON property. Loading of the "matchingRules" JSON property style +// gets unit tested in TestLoadInteractionJSONConstraints. +func TestArrayNestedWithinBodyContainingMatchers_NoConstraint(t *testing.T) { + pactReqBody := map[string]interface{}{ + "entries": []interface{}{ + map[string]any{"key": "val"}, + map[string]any{"key": dsl.Term("a", "(a|b)")}, + }, + } + const respContentType = "application/json" + const respBody = `[{"status":"ok"}]` + + const matchedReqBody = `{"entries": [ {"key": "val"}, {"key": "a"} ]}` + const unmatchedReqBody = `{"entries": [ {"key": "val"}, {"key": "c"} ]}` + + t.Run("Matches", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBody) + + then. + pact_verification_is_not_successful().and(). + // Since the request is passed to Pact Server, the response for not matching is 500 + the_response_is_(http.StatusInternalServerError) + }) +} + +func TestEmptyArrayNestedWithinBody(t *testing.T) { + pactReqBody := map[string]interface{}{ + "entries": []interface{}{}, + } + const respContentType = "application/json" + const respBody = `[{"status":"ok"}]` + + const matchedReqBody = `{"entries": []}` + const unmatchedReqBody = `{"entries": [ {"key": "val"} ]}` + + t.Run("Matches", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) +}