diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index b54eb1add..c4cb687a5 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -102,65 +102,6 @@ func extractGeneratedNamePrefix(name string) string { return name } -// compareJSONFieldsStrings compare two strings from request JSON bodies -// has special case when string are terraform generated names -func compareJSONFieldsStrings(expected, actual string) bool { - expectedHandled := expected - actualHandled := actual - - // Remove s3 url suffix to allow comparison - if strings.HasSuffix(actual, ".s3-website.fr-par.scw.cloud") { - actual = strings.TrimSuffix(actual, ".s3-website.fr-par.scw.cloud") - expected = strings.TrimSuffix(expected, ".s3-website.fr-par.scw.cloud") - } - - // Try to parse test generated name - if strings.Contains(actual, "-") { - expectedHandled = extractTestGeneratedNamePrefix(expected) - actualHandled = extractTestGeneratedNamePrefix(actual) - } - - // Try provider generated name - if actualHandled == actual && strings.HasPrefix(actual, "tf-") { - expectedHandled = extractGeneratedNamePrefix(expected) - actualHandled = extractGeneratedNamePrefix(actual) - } - - return expectedHandled == actualHandled -} - -// compareJSONBodies compare two given maps that represent json bodies -// returns true if both json are equivalent -func compareJSONBodies(expected, actual map[string]any) bool { - // Check for each key in actual requests - // Compare its value to cassette content if marshal-able to string - for key := range actual { - expectedValue, exists := expected[key] - if !exists { - // Actual request may contain a field that does not exist in cassette - // New fields can appear in requests with new api features - // We do not want to generate new cassettes for each new features - continue - } - - if !compareJSONFields(expectedValue, actual[key]) { - return false - } - } - - for key := range expected { - _, exists := actual[key] - if !exists && expected[key] != nil { - // Fails match if cassettes contains a field not in actual requests - // Fields should not disappear from requests unless a sdk breaking change - // We ignore if field is nil in cassette as it could be an old deprecated and unused field - return false - } - } - - return true -} - // IsTestResource returns true if given resource identifier is from terraform test // identifier should be resource name but some resource don't have names // return true if identifier match regex "tf[-_]test" diff --git a/internal/acctest/acctest_test.go b/internal/acctest/acctest_test.go new file mode 100644 index 000000000..90bba05ab --- /dev/null +++ b/internal/acctest/acctest_test.go @@ -0,0 +1,498 @@ +package acctest_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/scaleway/terraform-provider-scaleway/v2/internal/acctest" + "gopkg.in/dnaeon/go-vcr.v3/cassette" +) + +var barMemberCreationBody = `{ + "organization_id": "6867048b-fe12-4e96-835e-41c79a39604b", + "tags": [ + "bar" + ], + "member": { + "email": "bar@scaleway.com", + "send_password_email": false, + "send_welcome_email": false, + "username": "bar", + "password": "", + "first_name": "", + "last_name": "", + "phone_number": "", + "locale": "" + } +} +` + +var fooMemberCreationBody = `{ + "organization_id": "6867048b-fe12-4e96-835e-41c79a39604b", + "tags": [ + "foo" + ], + "member": { + "email": "foo@scaleway.com", + "send_password_email": false, + "send_welcome_email": false, + "username": "foo", + "password": "", + "first_name": "", + "last_name": "", + "phone_number": "", + "locale": "" + } +} +` + +var secretPatchBodyCassette = `{ + "environment_variables": { + "foo": "bar" + }, + "privacy": "unknown_privacy", + "protocol": "unknown_protocol", + "secret_environment_variables": [ + { + "key": "foo_secret", + "value": "bar_secret" + }, + { + "key": "test_secret", + "value": "updated_secret" + }, + { + "key": "first_secret", + "value": null + } + ], + "http_option": "unknown_http_option", + "sandbox": "unknown_sandbox" +} +` + +var secretPatchBodyRequest = `{ + "environment_variables": { + "foo": "bar" + }, + "privacy": "unknown_privacy", + "protocol": "unknown_protocol", + "secret_environment_variables": [ + { + "key": "first_secret", + "value": null + }, + { + "key": "foo_secret", + "value": "bar_secret" + }, + { + "key": "test_secret", + "value": "updated_secret" + } + ], + "http_option": "unknown_http_option", + "sandbox": "unknown_sandbox" +} +` + +var integertestBodyRequest = `{ + "akey": "avalue", + "integers": [ + 1, + 2, + 3 + ] +} +` + +var integertestBodyCassette = `{ + "akey": "avalue", + "integers": [ + 4, + 5, + 6 + ] +} +` + +var integerBodyRequestOutOfOrder = `{ + "akey": "avalue", + "integers": [ + 2, + 1, + 3 + ] +} +` + +var nestedSliceOfSlicesRequest = `{ + "akey": "avalue", + "nested_lists": [ + [ + "1", + "2", + "3" + ], + [ + "4", + "5", + "6" + ] + } +} +` + +var nestedSliceOfSlicesCassette = `{ + "akey": "avalue", + "nested_slice_of_slices": { + "integers_array": [ + [ + "4", + "5", + "6" + ], + [ + "1", + "2", + "3" + ] + ] + }, +} +` + +var simpleSliceOfStringsRequest = `{ + "strings": [ + "1", + "2", + "3" + ] +} +` + +var simpleSliceOfStringsCassette = `{ + "strings": [ + "3", + "2", + "1" + ] +} +` + +var ludacrisBodyRequest = `{ + "payload": { + "artists": [ + { + "name": "Ludacris", + "age": 45, + "songs": ["Ludacris", "Ludacris", "Ludacris"] + } + } + } +} +` + +var jdillaBodyCassette = `{ + "payload": { + "artists": [ + { + "name": "Jdilla", + "age": 54, + "songs": ["this", "is", "jdilla"] + } + ] + } +} +` + +var requestInstanceSettings = `{ + "settings": [ + { + "name": "max_connections", + "value": "200" + }, + { + "name": "max_parallel_workers", + "value": "2" + }, + { + "name": "effective_cache_size", + "value": "1300" + }, + { + "name": "maintenance_work_mem", + "value": "150" + }, + { + "name": "max_parallel_workers_per_gather", + "value": "2" + }, + { + "name": "work_mem", + "value": "4" + } + ] +} +` + +var cassetteInstanceSettings = `{ + "settings": [ + { + "name": "maintenance_work_mem", + "value": "150" + }, + { + "name": "effective_cache_size", + "value": "1300" + }, + { + "name": "work_mem", + "value": "4" + }, + { + "name": "max_parallel_workers", + "value": "2" + }, + { + "name": "max_parallel_workers_per_gather", + "value": "2" + }, + { + "name": "max_connections", + "value": "200" + } + ] +} +` + +var objectBodyRequest = `{ + "Id": "MyPolicy", + "Statement": [ + { + "Action": [ + "s3:ListBucket", + "s3:GetObject" + ], + "Effect": "Allow", + "Principal": { + "SCW": "*" + }, + "Resource": [ + "tf-tests-scw-obp-basic-4713290580220176511", + "tf-tests-scw-obp-basic-4713290580220176511/*" + ], + "Sid": "GrantToEveryone" + }, + { + "Action": [ + "s3:ListBucket", + "s3:GetObject" + ], + "Effect": "Allow", + "Principal": { + "SCW": "*" + }, + "Sid": "GrantToEveryone", + "project_id": "1234567890" + } + ], + "Version": "2012-10-17" +} +` + +var objectBodyCassette = `{ + "Id": "MyPolicy", + "Statement": [ + { + "Action": [ + "s3:ListBucket", + "s3:GetObject" + ], + "Effect": "Allow", + "Principal": { + "SCW": "*" + }, + "Sid": "GrantToEveryone", + "project_id": "9876543210" + }, + { + "Action": [ + "s3:ListBucket", + "s3:GetObject" + ], + "Effect": "Allow", + "Principal": { + "SCW": "*" + }, + "Sid": "GrantToEveryone", + "Resource": [ + "tf-tests-scw-obp-basic-1234567890", + "tf-tests-scw-obp-basic-1234567890/*" + ] + } + ], + "Version": "2012-10-17" +} +` + +// we don't use httptest.NewRequest because it does not set the GetBody func +func newRequest(method, url string, body io.Reader) *http.Request { + req, err := http.NewRequestWithContext(context.Background(), method, url, body) + if err != nil { + panic(err) // lintignore: R009 + } + + return req +} + +var testBodyMatcherCases = []struct { + requestBody *http.Request + cassetteBody *cassette.Request + shouldMatch bool +}{ + // bar does not match foo + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(barMemberCreationBody)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: fooMemberCreationBody, + ContentLength: int64(len(fooMemberCreationBody)), + }, + shouldMatch: false, + }, + // bar matches bar + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(barMemberCreationBody)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: barMemberCreationBody, + ContentLength: int64(len(barMemberCreationBody)), + }, + shouldMatch: true, + }, + // simple http get + { + requestBody: newRequest(http.MethodGet, "https://api.scaleway.com/iam/v1alpha1/users/6867048b-fe12-4e96-835e-41c79a39604b", nil), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users/6867048b-fe12-4e96-835e-41c79a39604b", + Method: http.MethodGet, + Body: "", + ContentLength: 0, + }, + shouldMatch: true, + }, + // patch secret with nested slices of map[string]interface{} in different order + { + requestBody: newRequest(http.MethodPatch, "https://api.scaleway.com/secrets/v1/secrets/123", strings.NewReader(secretPatchBodyRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/secrets/v1/secrets/123", + Method: http.MethodPatch, + Body: secretPatchBodyCassette, + ContentLength: int64(len(secretPatchBodyCassette)), + }, + shouldMatch: true, + }, + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(requestInstanceSettings)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: cassetteInstanceSettings, + ContentLength: int64(len(cassetteInstanceSettings)), + }, + shouldMatch: true, + }, + // complex slice of maps case + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/policies", strings.NewReader(objectBodyRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/policies", + Method: http.MethodPost, + Body: objectBodyCassette, + ContentLength: int64(len(objectBodyCassette)), + }, + shouldMatch: true, + }, + // compare slices of different integers + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(integertestBodyRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: integertestBodyCassette, + ContentLength: int64(len(integertestBodyCassette)), + }, + shouldMatch: false, + }, + // compare slices of same integers in different order + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(integerBodyRequestOutOfOrder)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: integertestBodyRequest, + ContentLength: int64(len(integertestBodyRequest)), + }, + shouldMatch: true, + }, + // compare slices of slices of strings in different order + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(nestedSliceOfSlicesRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: nestedSliceOfSlicesCassette, + ContentLength: int64(len(nestedSliceOfSlicesCassette)), + }, + shouldMatch: false, + }, + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(nestedSliceOfSlicesRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: nestedSliceOfSlicesRequest, + ContentLength: int64(len(nestedSliceOfSlicesRequest)), + }, + shouldMatch: true, + }, + // compare slices of strings in different order + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(simpleSliceOfStringsRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: simpleSliceOfStringsCassette, + ContentLength: int64(len(simpleSliceOfStringsCassette)), + }, + shouldMatch: true, + }, + // ludacris does not match jdilla + { + requestBody: newRequest(http.MethodPost, "https://api.scaleway.com/iam/v1alpha1/users", strings.NewReader(ludacrisBodyRequest)), + cassetteBody: &cassette.Request{ + URL: "https://api.scaleway.com/iam/v1alpha1/users", + Method: http.MethodPost, + Body: jdillaBodyCassette, + ContentLength: int64(len(jdillaBodyCassette)), + }, + shouldMatch: false, + }, +} + +func TestCassetteMatcher(t *testing.T) { + for i, test := range testBodyMatcherCases { + shouldMatch := acctest.CassetteMatcher(test.requestBody, *test.cassetteBody) + if shouldMatch != test.shouldMatch { + t.Errorf("test %d: expected %v, got %v", i, test.shouldMatch, shouldMatch) + t.Errorf("requestBody: %s", test.requestBody.Body) + t.Errorf("cassetteBody: %s", test.cassetteBody.Body) + } + } +} diff --git a/internal/acctest/compare.go b/internal/acctest/compare.go new file mode 100644 index 000000000..9e0529453 --- /dev/null +++ b/internal/acctest/compare.go @@ -0,0 +1,243 @@ +package acctest + +import ( + "net/url" + "reflect" + "sort" + "strings" +) + +// compareJSONFields is the entry point for comparing two interface values +// handle string with special cases, map[string]interface{} and []interface{} or any other primitive type +func compareJSONFields(requestValue, cassetteValue any, strict bool) bool { + switch requestValue := requestValue.(type) { + case string: + return compareFieldsStrings(requestValue, cassetteValue.(string)) + case map[string]any: + return compareJSONBodies(requestValue, cassetteValue.(map[string]any), strict) + case []any: + return compareSlices(requestValue, cassetteValue.([]any)) + default: + return reflect.DeepEqual(requestValue, cassetteValue) + } +} + +// compareJSONBodies compare two given maps that represent json bodies +// returns true if both json are equivalent +func compareJSONBodies(request, cassette map[string]any, strict bool) bool { + for key, requestValue := range request { + cassetteValue, ok := cassette[key] + if !ok { + if strict { + return false + } + + continue + } + + if reflect.TypeOf(cassetteValue) != reflect.TypeOf(requestValue) { + return false + } + + if !compareJSONFields(requestValue, cassetteValue, strict) { + return false + } + } + + for key, cassetteValue := range cassette { + if _, ok := request[key]; !ok && cassetteValue != nil { + // Fails match if cassettes contains a field not in actual requests + // Fields should not disappear from requests unless a sdk breaking change + // We ignore if field is nil in cassette as it could be an old deprecated and unused field + return false + } + } + + return true +} + +// compareFormBodies compare two given url.Values +// returns true if both url.Values are equivalent +func compareFormBodies(request, cassette url.Values) bool { + // Check for each key in actual requests + // Compare its value to cassette content if marshal-able to string + for key := range request { + requestValue, exists := request[key] + if !exists { + // Actual request may contain a field that does not exist in cassette + // New fields can appear in requests with new api features + // We do not want to generate new cassettes for each new features + continue + } + + if !compareStringSlices(requestValue, cassette[key]) { + return false + } + } + + for key, cassetteValue := range cassette { + if _, exists := request[key]; !exists && cassetteValue != nil { + // Fails match if cassettes contains a field not in actual requests + // Fields should not disappear from requests unless a sdk breaking change + // We ignore if field is nil in cassette as it could be an old deprecated and unused field + return false + } + } + + return true +} + +// compareFieldsStrings compare two strings from request JSON bodies +// has special case when string are terraform generated names +func compareFieldsStrings(expected, actual string) bool { + if expected == actual { + return true + } + + // Action=DeleteTopic&TopicArn=arn%3Ascw%3Asns%3Afr-par%3Aproject-1a080a81-67b6-476d-80b4-f3bb9184e318%3Atest-mnq-sns-topic-basic20250603151943185500000004&Version=2010-03-31 + snsPrefix := "test-mnq-sns-topic-basic" + if strings.HasPrefix(actual, snsPrefix) && strings.HasPrefix(expected, snsPrefix) { + return true + } + + if strings.HasPrefix(actual, "arn:scw:sns:") && strings.HasPrefix(expected, "arn:scw:sns:") { + return true + } + + expectedHandled := expected + actualHandled := actual + + // Remove s3 url suffix to allow comparison + if strings.HasSuffix(actual, ".s3-website.fr-par.scw.cloud") { + actual = strings.TrimSuffix(actual, ".s3-website.fr-par.scw.cloud") + expected = strings.TrimSuffix(expected, ".s3-website.fr-par.scw.cloud") + } + + // Try to parse test generated name + if strings.Contains(actual, "-") { + expectedHandled = extractTestGeneratedNamePrefix(expected) + actualHandled = extractTestGeneratedNamePrefix(actual) + } + + // Try provider generated name + if actualHandled == actual && strings.HasPrefix(actual, "tf-") { + expectedHandled = extractGeneratedNamePrefix(expected) + actualHandled = extractGeneratedNamePrefix(actual) + } + + return expectedHandled == actualHandled +} + +func compareStringSlices(request, cassette []string) bool { + if len(request) != len(cassette) { + return false + } + + sort.Slice(request, func(i, j int) bool { + return request[i] < request[j] + }) + sort.Slice(cassette, func(i, j int) bool { + return cassette[i] < cassette[j] + }) + + for i, v := range request { + if !compareFieldsStrings(v, cassette[i]) { + return false + } + } + + return true +} + +// compareSlices compares two slices of interface{} +// in case of slice of map[string]interface{}, it will attempt to find a match in the other slice without taking into account the order +func compareSlices(request, cassette []any) bool { + if len(request) != len(cassette) { + return false + } + + if len(request) == 0 { + return true + } + + switch request[0].(type) { + case string: + requestStrings := make([]string, len(request)) + for i, v := range request { + requestStrings[i] = v.(string) + } + + cassetteStrings := make([]string, len(cassette)) + for i, v := range cassette { + cassetteStrings[i] = v.(string) + } + + return compareStringSlices(requestStrings, cassetteStrings) + case float64: + sort.Slice(request, func(i, j int) bool { + return request[i].(float64) < request[j].(float64) + }) + sort.Slice(cassette, func(i, j int) bool { + return cassette[i].(float64) < cassette[j].(float64) + }) + + for i := range request { + if request[i] != cassette[i] { + return false + } + } + + return true + case map[string]any: + // first compare assuming that the order is the same, tolerating missing keys in the cassette + matched := 0 + + for i := range request { + // cleanup ignored keys + for _, key := range BodyMatcherIgnore { + removeKeyRecursive(request[i].(map[string]any), key) + } + + for _, key := range BodyMatcherIgnore { + removeKeyRecursive(cassette[i].(map[string]any), key) + } + + if compareJSONFields(request[i], cassette[i], false) { + matched++ + } + } + + if matched == len(request) { + return true + } + + // if no match try to compare out of order + matched = 0 + reqVisited := make([]bool, len(request)) + casVisited := make([]bool, len(cassette)) + + for i := range request { + if reqVisited[i] { + continue + } + + for j := range cassette { + if casVisited[j] { + continue + } + + if compareJSONFields(request[i], cassette[j], true) { + matched++ + reqVisited[i] = true + casVisited[j] = true + + break + } + } + } + + return matched == len(request) + default: + return reflect.DeepEqual(request, cassette) + } +} diff --git a/internal/acctest/vcr.go b/internal/acctest/vcr.go index d5865989f..33cc6f864 100644 --- a/internal/acctest/vcr.go +++ b/internal/acctest/vcr.go @@ -43,6 +43,22 @@ var BodyMatcherIgnore = []string{ "organization_id", "project_id", "project", // like project_id but should be deprecated + // function related fields + "mnq_project_id", + "mnq_region", + "mnq_nats_account_id", + "mnq_nats_subject", +} + +// removeKeyRecursive removes a key from a map and all its nested maps +func removeKeyRecursive(m map[string]any, key string) { + delete(m, key) + + for _, v := range m { + if v, ok := v.(map[string]any); ok { + removeKeyRecursive(v, key) + } + } } // getTestFilePath returns a valid filename path based on the go test name and suffix. (Take care of non fs friendly char) @@ -65,174 +81,129 @@ func getTestFilePath(t *testing.T, pkgFolder string, suffix string) string { return filepath.Join(pkgFolder, "testdata", fileName) } -func compareJSONFields(expected, actualI any) bool { - switch actual := actualI.(type) { - case string: - if _, isString := expected.(string); !isString { - return false - } - - return compareJSONFieldsStrings(expected.(string), actual) - default: - // Consider equality when not handled - return true - } -} - -// compareFormBodies compare two given url.Values -// returns true if both url.Values are equivalent -func compareFormBodies(expected, actual url.Values) bool { - // Check for each key in actual requests - // Compare its value to cassette content if marshal-able to string - for key := range actual { - expectedValue, exists := expected[key] - if !exists { - // Actual request may contain a field that does not exist in cassette - // New fields can appear in requests with new api features - // We do not want to generate new cassettes for each new features - continue - } - - if !compareJSONFields(expectedValue, actual[key]) { - return false - } - } - - for key := range expected { - _, exists := actual[key] - if !exists && expected[key] != nil { - // Fails match if cassettes contains a field not in actual requests - // Fields should not disappear from requests unless a sdk breaking change - // We ignore if field is nil in cassette as it could be an old deprecated and unused field - return false - } - } - - return true -} - // cassetteMatcher is a custom matcher that will juste check equivalence of request bodies -func cassetteBodyMatcher(actualRequest *http.Request, cassetteRequest cassette.Request) bool { - if actualRequest.Body == nil || actualRequest.ContentLength == 0 { - if cassetteRequest.Body == "" { +func cassetteBodyMatcher(request *http.Request, cassette cassette.Request) bool { + if request.Body == nil || request.ContentLength == 0 { + if cassette.Body == "" { return true // Body match if both are empty - } else if _, isFile := actualRequest.Body.(*os.File); isFile { + } + + if _, isFile := request.Body.(*os.File); isFile { return true // Body match if request is sending a file, maybe do more check here } return false } - actualBody, err := actualRequest.GetBody() + r, err := request.GetBody() if err != nil { - panic(fmt.Errorf("cassette body matcher: failed to copy actualRequest body: %w", err)) // lintignore: R009 + panic(fmt.Errorf("cassette body matcher: failed to copy request body: %w", err)) // lintignore: R009 } - actualRawBody, err := io.ReadAll(actualBody) + requestBody, err := io.ReadAll(r) if err != nil { panic(fmt.Errorf("cassette body matcher: failed to read actualRequest body: %w", err)) // lintignore: R009 } // Try to match raw bodies if they are not JSON (ex: cloud-init config) - if string(actualRawBody) == cassetteRequest.Body { + if string(requestBody) == cassette.Body { return true } - actualJSON := make(map[string]any) + requestJSON := make(map[string]any) cassetteJSON := make(map[string]any) - err = xml.Unmarshal(actualRawBody, new(any)) + // match if content is xml + err = xml.Unmarshal(requestBody, new(any)) if err == nil { - // match if content is xml return true } - if !json.Valid(actualRawBody) { - values, err := url.ParseQuery(string(actualRawBody)) + if !json.Valid(requestBody) { + requestValues, err := url.ParseQuery(string(requestBody)) if err != nil { panic(fmt.Errorf("cassette body matcher: failed to parse body as url values: %w", err)) // lintignore: R009 } - // Remove keys that should be ignored during compare + // Remove keys that should be ignored during comparison for _, key := range BodyMatcherIgnore { - values.Del(key) + requestValues.Del(key) } - // Compare url values - return compareFormBodies(values, cassetteRequest.Form) + return compareFormBodies(requestValues, cassette.Form) } - err = json.Unmarshal(actualRawBody, &actualJSON) + err = json.Unmarshal(requestBody, &requestJSON) if err != nil { - panic(fmt.Errorf("cassette body matcher: failed to parse json body: %w", err)) // lintignore: R009 + panic(fmt.Errorf("cassette body matcher: failed to parse request body as json: %w", err)) // lintignore: R009 } - err = json.Unmarshal([]byte(cassetteRequest.Body), &cassetteJSON) + err = json.Unmarshal([]byte(cassette.Body), &cassetteJSON) if err != nil { // actualRequest contains JSON but cassette may not contain JSON, this doesn't match in this case return false } - - // Remove keys that should be ignored during compare + // remove keys that should be ignored during comparison for _, key := range BodyMatcherIgnore { - delete(actualJSON, key) - delete(cassetteJSON, key) + removeKeyRecursive(requestJSON, key) + removeKeyRecursive(cassetteJSON, key) } - return compareJSONBodies(cassetteJSON, actualJSON) + return compareJSONBodies(requestJSON, cassetteJSON, false) } -// cassetteMatcher is a custom matcher that check equivalence of a played request against a recorded one +// CassetteMatcher is a custom matcher that check equivalence of a played request against a recorded one // It compares method, path and query but will remove unwanted values from query -func cassetteMatcher(actual *http.Request, expected cassette.Request) bool { - expectedURL, _ := url.Parse(expected.URL) - actualURL := actual.URL - actualURLValues := actualURL.Query() - expectedURLValues := expectedURL.Query() +func CassetteMatcher(request *http.Request, cassette cassette.Request) bool { + cassetteURL, _ := url.Parse(cassette.URL) + requestURL := request.URL + + requestURLValues := requestURL.Query() + cassetteURLValues := cassetteURL.Query() for _, query := range QueryMatcherIgnore { - actualURLValues.Del(query) - expectedURLValues.Del(query) + requestURLValues.Del(query) + cassetteURLValues.Del(query) } - actualURL.RawQuery = actualURLValues.Encode() - expectedURL.RawQuery = expectedURLValues.Encode() + requestURL.RawQuery = requestURLValues.Encode() + cassetteURL.RawQuery = cassetteURLValues.Encode() // Specific handling of s3 URLs // Url format is https://test-acc-scaleway-object-bucket-lifecycle-8445817190507446251.s3.fr-par.scw.cloud/?lifecycle= - if strings.HasSuffix(actualURL.Host, "scw.cloud") { - if !strings.HasSuffix(expectedURL.Host, "scw.cloud") { + if strings.HasSuffix(requestURL.Host, "scw.cloud") { + if !strings.HasSuffix(cassetteURL.Host, "scw.cloud") { return false } - actualS3Host := strings.Split(actualURL.Host, ".") - expectedS3Host := strings.Split(expectedURL.Host, ".") + requestS3Host := strings.Split(requestURL.Host, ".") + cassetteS3Host := strings.Split(cassetteURL.Host, ".") - if len(actualS3Host) >= 5 && len(expectedS3Host) >= 5 { + if len(requestS3Host) >= 5 && len(cassetteS3Host) >= 5 { // Host is bucket.s3.region.scw.cloud // it could be a host without bucket name (ex: function upload) - actualBucket := actualS3Host[0] - expectedBucket := expectedS3Host[0] + requestBucket := requestS3Host[0] + cassetteBucket := cassetteS3Host[0] // Remove random number at the end of the bucket name - if strings.Contains(actualBucket, "-") { - actualBucket = actualBucket[:strings.LastIndex(actualBucket, "-")] + if strings.Contains(requestBucket, "-") { + requestBucket = requestBucket[:strings.LastIndex(requestBucket, "-")] } - if strings.Contains(expectedBucket, "-") { - expectedBucket = expectedBucket[:strings.LastIndex(expectedBucket, "-")] + if strings.Contains(cassetteBucket, "-") { + cassetteBucket = cassetteBucket[:strings.LastIndex(cassetteBucket, "-")] } - if actualBucket != expectedBucket { + if requestBucket != cassetteBucket { return false } } } - return actual.Method == expected.Method && - actual.URL.Path == expectedURL.Path && - actualURL.RawQuery == expectedURL.RawQuery && - cassetteBodyMatcher(actual, expected) + return request.Method == cassette.Method && + request.URL.Path == cassetteURL.Path && + requestURL.RawQuery == cassetteURL.RawQuery && + cassetteBodyMatcher(request, cassette) } func cassetteSensitiveFieldsAnonymizer(i *cassette.Interaction) error { @@ -297,7 +268,7 @@ func getHTTPRecoder(t *testing.T, pkgFolder string, update bool) (client *http.C }(r) // Add custom matcher for requests and cassettes - r.SetMatcher(cassetteMatcher) + r.SetMatcher(CassetteMatcher) // Add a filter which removes Authorization headers from all requests: r.AddHook(func(i *cassette.Interaction) error {