diff --git a/cmd/monaco/integrationtest/v2/settings_integration_test.go b/cmd/monaco/integrationtest/v2/settings_integration_test.go index d3160176d..3e5a89829 100644 --- a/cmd/monaco/integrationtest/v2/settings_integration_test.go +++ b/cmd/monaco/integrationtest/v2/settings_integration_test.go @@ -138,6 +138,25 @@ func TestOldExternalIDGetsUpdated(t *testing.T) { } +func TestDeploySettingsWithUniqueProperties(t *testing.T) { + fs := testutils.CreateTestFileSystem() + var manifestPath = "test-resources/settings-unique-properties/manifest.yaml" + loadedManifest := integrationtest.LoadManifest(t, fs, manifestPath, "platform_env") + + t.Cleanup(func() { + integrationtest.CleanupIntegrationTest(t, fs, manifestPath, loadedManifest, "") + }) + + cmd := runner.BuildCli(fs) + cmd.SetArgs([]string{"deploy", "-e", "platform_env", "-p", "project1", manifestPath}) + err := cmd.Execute() + assert.NoError(t, err) + + cmd.SetArgs([]string{"deploy", "-e", "platform_env", "-p", "project2", manifestPath}) + err = cmd.Execute() + assert.NoError(t, err) +} + func createSettingsClient(t *testing.T, env manifest.EnvironmentDefinition, opts ...func(dynatraceClient *dtclient.DynatraceClient)) dtclient.SettingsClient { oauthCredentials := auth.OauthCredentials{ ClientID: env.Auth.OAuth.ClientID.Value, diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml new file mode 100644 index 000000000..bf5c75ffe --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/manifest.yaml @@ -0,0 +1,26 @@ +manifestVersion: 1.0 +projects: +- name: project1 +- name: project2 +environmentGroups: +- name: default + environments: + - name: classic_env + url: + type: environment + value: URL_ENVIRONMENT_1 + auth: + token: + name: TOKEN_ENVIRONMENT_1 + - name: platform_env + url: + type: environment + value: PLATFORM_URL_ENVIRONMENT_2 + auth: + token: + name: TOKEN_ENVIRONMENT_2 + oAuth: + clientId: + name: OAUTH_CLIENT_ID + clientSecret: + name: OAUTH_CLIENT_SECRET diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml new file mode 100644 index 000000000..550e4c22c --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: d955d9b7-9630-3be5-aca5-3715779a7282 + config: + name: d955d9b7-9630-3be5-aca5-3715779a7282 + template: d955d9b7-9630-3be5-aca5-3715779a7282.json + skip: false + type: + settings: + schema: builtin:monitoring.slo + schemaVersion: 6.0.12 + scope: environment diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json new file mode 100644 index 000000000..5052cf1e8 --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "name": "Just a Name", + "metricName": "justaname", + "metricExpression": "(100)*(builtin:service.errors.server.successCount:splitBy())/(builtin:service.requestCount.server:splitBy())", + "evaluationType": "AGGREGATE", + "filter": "type(\"SERVICE\")", + "evaluationWindow": "-1w", + "targetSuccess": 99.98, + "targetWarning": 99.99, + "errorBudgetBurnRate": { + "burnRateVisualizationEnabled": true, + "fastBurnThreshold": 10.0 + } +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json new file mode 100644 index 000000000..87aa792ba --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json @@ -0,0 +1,4 @@ +{ + "key": "graphql.operation.name", + "masking": "NOT_MASKED" +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml new file mode 100644 index 000000000..551972d2b --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project1/builtinspan-attribute/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + config: + name: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + template: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json + skip: false + type: + settings: + schema: builtin:span-attribute + schemaVersion: 0.0.32 + scope: environment diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml new file mode 100644 index 000000000..550e4c22c --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: d955d9b7-9630-3be5-aca5-3715779a7282 + config: + name: d955d9b7-9630-3be5-aca5-3715779a7282 + template: d955d9b7-9630-3be5-aca5-3715779a7282.json + skip: false + type: + settings: + schema: builtin:monitoring.slo + schemaVersion: 6.0.12 + scope: environment diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json new file mode 100644 index 000000000..4dab5cd6a --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinmonitoring.slo/d955d9b7-9630-3be5-aca5-3715779a7282.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "name": "NEW Name", + "metricName": "justaname", + "metricExpression": "(100)*(builtin:service.errors.server.successCount:splitBy())/(builtin:service.requestCount.server:splitBy())", + "evaluationType": "AGGREGATE", + "filter": "type(\"SERVICE\")", + "evaluationWindow": "-1w", + "targetSuccess": 99.98, + "targetWarning": 99.99, + "errorBudgetBurnRate": { + "burnRateVisualizationEnabled": true, + "fastBurnThreshold": 10.0 + } +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json new file mode 100644 index 000000000..87aa792ba --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json @@ -0,0 +1,4 @@ +{ + "key": "graphql.operation.name", + "masking": "NOT_MASKED" +} diff --git a/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml new file mode 100644 index 000000000..551972d2b --- /dev/null +++ b/cmd/monaco/integrationtest/v2/test-resources/settings-unique-properties/project2/builtinspan-attribute/config.yaml @@ -0,0 +1,11 @@ +configs: +- id: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + config: + name: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452 + template: 1f4b1f5c-11d2-38c8-9324-7d139c6e6452.json + skip: false + type: + settings: + schema: builtin:span-attribute + schemaVersion: 0.0.32 + scope: environment diff --git a/pkg/client/dtclient/client.go b/pkg/client/dtclient/client.go index 89ce12258..e5a4da97c 100644 --- a/pkg/client/dtclient/client.go +++ b/pkg/client/dtclient/client.go @@ -23,7 +23,6 @@ import ( "fmt" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/cache" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/filter" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log/field" @@ -560,61 +559,6 @@ func (d *DynatraceClient) getSettingById(ctx context.Context, objectId string) ( return &result, nil } -func (d *DynatraceClient) ListSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) (res []DownloadSettingsObject, err error) { - d.limiter.ExecuteBlocking(func() { - res, err = d.listSettings(ctx, schemaId, opts) - }) - return -} - -func (d *DynatraceClient) listSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) ([]DownloadSettingsObject, error) { - - if settings, cached := d.settingsCache.Get(schemaId); cached { - log.Debug("Using cached settings for schema %s", schemaId) - return filter.FilterSlice(settings, opts.Filter), nil - } - - log.Debug("Downloading all settings for schema %s", schemaId) - - listSettingsFields := defaultListSettingsFields - if opts.DiscardValue { - listSettingsFields = reducedListSettingsFields - } - params := url.Values{ - "schemaIds": []string{schemaId}, - "pageSize": []string{defaultPageSize}, - "fields": []string{listSettingsFields}, - } - - result := make([]DownloadSettingsObject, 0) - - addToResult := func(body []byte) (int, error) { - var parsed struct { - Items []DownloadSettingsObject `json:"items"` - } - if err := json.Unmarshal(body, &parsed); err != nil { - return 0, fmt.Errorf("failed to unmarshal response: %w", err) - } - - result = append(result, parsed.Items...) - return len(parsed.Items), nil - } - - u, err := buildUrl(d.environmentURL, d.settingsObjectAPIPath, params) - if err != nil { - return nil, fmt.Errorf("failed to list settings: %w", err) - } - - _, err = rest.ListPaginated(ctx, d.platformClient, d.retrySettings, u, schemaId, addToResult) - if err != nil { - return nil, err - } - - d.settingsCache.Set(schemaId, result) - - return filter.FilterSlice(result, opts.Filter), nil -} - type EntitiesTypeListResponse struct { Types []EntitiesType `json:"types"` } diff --git a/pkg/client/dtclient/client_settings_test.go b/pkg/client/dtclient/client_settings_test.go deleted file mode 100644 index db30483de..000000000 --- a/pkg/client/dtclient/client_settings_test.go +++ /dev/null @@ -1,106 +0,0 @@ -/* - * @license - * Copyright 2023 Dynatrace LLC - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package dtclient - -import ( - "context" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -func Test_schemaDetails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case settingsSchemaAPIPathPlatform + "/builtin:span-attribute": - r := []byte(` -{ - "schemaId": "builtin:span-attribute", - "schemaConstraints": [ - { - "type": "some another type", - "customMessage": "Attribute keys must be unique.", - "something": "example" - }, - { - "type": "UNIQUE", - "customMessage": "Attribute keys must be unique.", - "uniqueProperties": [ - "key0", - "key1" - ] - }, - { - "type": "UNIQUE", - "customMessage": "Attribute keys must be unique.", - "uniqueProperties": [ - "key2", - "key3" - ] - } - ] -}`) - rw.WriteHeader(http.StatusOK) - rw.Write(r) - default: - rw.WriteHeader(http.StatusNotFound) - - } - })) - defer server.Close() - - restCLient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - - d, _ := NewPlatformClient(server.URL, server.URL, restCLient, restCLient) - - t.Run("unmarshall data", func(t *testing.T) { - expected := SchemaConstraints{SchemaId: "builtin:span-attribute", UniqueProperties: [][]string{{"key0", "key1"}, {"key2", "key3"}}} - - actual, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") - - assert.NoError(t, err) - assert.Equal(t, expected, actual) - }) -} - -func Test_FetchSchemaConstraintsUsesCache(t *testing.T) { - apiHits := 0 - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - apiHits++ - r := []byte(`{"schemaId": "builtin:span-attribute","schemaConstraints": []}`) - rw.WriteHeader(http.StatusOK) - rw.Write(r) - - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - d, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient) - - _, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") - assert.NoError(t, err) - assert.Equal(t, 1, apiHits) - _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:alerting.profile") - assert.NoError(t, err) - assert.Equal(t, 2, apiHits) - _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") - assert.NoError(t, err) - assert.Equal(t, 2, apiHits) - -} diff --git a/pkg/client/dtclient/client_test.go b/pkg/client/dtclient/client_test.go index 68b1752d0..e31d7527e 100644 --- a/pkg/client/dtclient/client_test.go +++ b/pkg/client/dtclient/client_test.go @@ -19,15 +19,8 @@ package dtclient import ( - "context" - "fmt" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/cache" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/trafficlogs" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/version" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/api" - "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" "github.com/stretchr/testify/assert" "net/http" @@ -182,955 +175,6 @@ func TestNewPlatformClient(t *testing.T) { }) } -func TestReadByIdReturnsAnErrorUponEncounteringAnError(t *testing.T) { - testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - http.Error(res, "", http.StatusForbidden) - })) - defer func() { testServer.Close() }() - client := DynatraceClient{ - environmentURLClassic: testServer.URL, - classicClient: rest.NewRestClient(testServer.Client(), trafficlogs.NewFileBased(), rest.CreateRateLimitStrategy()), - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - _, err := client.ReadConfigById(mockAPI, "test") - assert.ErrorContains(t, err, "Response was") -} - -func TestReadByIdEscapesTheId(t *testing.T) { - unescapedID := "ruxit.perfmon.dotnetV4:%TimeInGC:time_in_gc_alert_high_generic" - - testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {})) - defer func() { testServer.Close() }() - client := DynatraceClient{ - environmentURLClassic: testServer.URL, - classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - _, err := client.ReadConfigById(mockAPINotSingle, unescapedID) - assert.NoError(t, err) -} - -func TestReadByIdReturnsTheResponseGivenNoError(t *testing.T) { - body := []byte{1, 3, 3, 7} - - testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - _, _ = res.Write(body) - })) - defer func() { testServer.Close() }() - - client := DynatraceClient{ - environmentURLClassic: testServer.URL, - classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - resp, err := client.ReadConfigById(mockAPI, "test") - assert.NoError(t, err, "there should not be an error") - assert.Equal(t, body, resp) -} - -func TestListKnownSettings(t *testing.T) { - - tests := []struct { - name string - givenSchemaID string - givenListSettingsOpts ListSettingsOptions - givenServerResponses []testServerResponse - want []DownloadSettingsObject - wantQueryParamsPerAPICall [][]testQueryParams - wantNumberOfAPICalls int - wantError bool - }{ - { - name: "Lists Settings objects as expected", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Lists Settings objects without value field as expected", - givenSchemaID: "builtin:something", - givenListSettingsOpts: ListSettingsOptions{DiscardValue: true}, - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", reducedListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Lists Settings objects with filter as expected", - givenSchemaID: "builtin:something", - givenListSettingsOpts: ListSettingsOptions{Filter: func(o DownloadSettingsObject) bool { - return o.ExternalId == "RG9jdG9yIFdobwo=" - }}, - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4641", "externalId": "RG9jdG9yIabcdef="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Handles Pagination when listing settings objects", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, - {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - { - ExternalId: "VGhlIE1hc3Rlcgo=", - ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", - }, - }, - - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 2, - wantError: false, - }, - { - name: "Returns empty if list if no items exist", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ ] }`}, - }, - want: []DownloadSettingsObject{}, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered - 400", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {400, `epic fail`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: true, - }, - { - name: "Returns error if HTTP error is encountered - 403", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {403, `epic fail`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: true, - }, - { - name: "Retries on HTTP error on paginated request and returns eventual success", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, - {400, `get next page fail`}, - {400, `retry fail`}, - {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, - }, - want: []DownloadSettingsObject{ - { - ExternalId: "RG9jdG9yIFdobwo=", - ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", - }, - { - ExternalId: "VGhlIE1hc3Rlcgo=", - ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", - }, - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 4, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered getting further paginated responses", - givenSchemaID: "builtin:something", - givenServerResponses: []testServerResponse{ - {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, - {400, `get next page fail`}, - {400, `retry fail 1`}, - {400, `retry fail 2`}, - {400, `retry fail 3`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"schemaIds", "builtin:something"}, - {"pageSize", "500"}, - {"fields", defaultListSettingsFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 5, - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - apiCalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if len(tt.wantQueryParamsPerAPICall) > 0 { - params := tt.wantQueryParamsPerAPICall[apiCalls] - for _, param := range params { - addedQueryParameter := req.URL.Query()[param.key] - assert.NotNil(t, addedQueryParameter) - assert.NotEmpty(t, addedQueryParameter) - assert.Equal(t, addedQueryParameter[0], param.value) - } - } else { - assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) - } - - resp := tt.givenServerResponses[apiCalls] - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - - apiCalls++ - assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - client, _ := NewClassicClient(server.URL, restClient, - WithRetrySettings(testRetrySettings), - WithClientRequestLimiter(concurrency.NewLimiter(5)), - WithExternalIDGenerator(idutils.GenerateExternalID)) - - res, err1 := client.ListSettings(context.TODO(), tt.givenSchemaID, tt.givenListSettingsOpts) - - if tt.wantError { - assert.Error(t, err1) - } else { - assert.NoError(t, err1) - } - - assert.Equal(t, tt.want, res) - - assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) - }) - } -} - -func TestGetSettingById(t *testing.T) { - type fields struct { - environmentURL string - retrySettings rest.RetrySettings - } - type args struct { - objectID string - } - tests := []struct { - name string - fields fields - args args - givenTestServerResp *testServerResponse - wantURLPath string - wantResult *DownloadSettingsObject - wantErr bool - }{ - { - name: "Get Setting by ID - server response != 2xx", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 500, - body: "{}", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantResult: nil, - wantErr: true, - }, - { - name: "Get Setting by ID - invalid server response", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 200, - body: `{bs}`, - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantResult: nil, - wantErr: true, - }, - { - name: "Get Setting by ID", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 200, - body: `{"objectId":"12345","externalId":"54321", "schemaVersion":"1.0","schemaId":"builtin:bla","scope":"tenant"}`, - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantResult: &DownloadSettingsObject{ - ExternalId: "54321", - SchemaVersion: "1.0", - SchemaId: "builtin:bla", - ObjectId: "12345", - Scope: "tenant", - Value: nil, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.Equal(t, tt.wantURLPath, req.URL.Path) - if resp := tt.givenTestServerResp; resp != nil { - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - } - - })) - defer server.Close() - - var envURL string - if tt.fields.environmentURL != "" { - envURL = tt.fields.environmentURL - } else { - envURL = server.URL - } - - d := DynatraceClient{ - environmentURL: envURL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: tt.fields.retrySettings, - settingsObjectAPIPath: "/api/v2/settings/objects", - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - settingsObj, err := d.GetSettingById(tt.args.objectID) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.wantResult, settingsObj) - - }) - } - -} - -func TestDeleteSettings(t *testing.T) { - type fields struct { - environmentURL string - retrySettings rest.RetrySettings - } - type args struct { - objectID string - } - tests := []struct { - name string - fields fields - args args - givenTestServerResp *testServerResponse - wantURLPath string - wantErr bool - }{ - { - name: "Delete Settings - server response != 2xx", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 500, - body: "{}", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantErr: true, - }, - { - name: "Delete Settings - server response 404 does not result in an err", - fields: fields{}, - args: args{ - objectID: "12345", - }, - givenTestServerResp: &testServerResponse{ - statusCode: 404, - body: "{}", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantErr: false, - }, - { - name: "Delete Settings - object ID is passed", - fields: fields{}, - args: args{ - objectID: "12345", - }, - wantURLPath: "/api/v2/settings/objects/12345", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.Equal(t, tt.wantURLPath, req.URL.Path) - if resp := tt.givenTestServerResp; resp != nil { - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - } - - })) - defer server.Close() - - var envURL string - if tt.fields.environmentURL != "" { - envURL = tt.fields.environmentURL - } else { - envURL = server.URL - } - - d := DynatraceClient{ - environmentURL: envURL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: tt.fields.retrySettings, - settingsObjectAPIPath: settingsObjectAPIPathClassic, - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - if err := d.DeleteSettings(tt.args.objectID); (err != nil) != tt.wantErr { - t.Errorf("DeleteSettings() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestUpsertSettingsRetries(t *testing.T) { - numAPICalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - rw.WriteHeader(200) - _, _ = rw.Write([]byte("{}")) - return - } - - numAPICalls++ - if numAPICalls < 3 { - rw.WriteHeader(409) - return - } - rw.WriteHeader(200) - _, _ = rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - client, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient, - WithRetrySettings(testRetrySettings), - WithClientRequestLimiter(concurrency.NewLimiter(5)), - WithExternalIDGenerator(idutils.GenerateExternalID)) - - _, err := client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - - assert.NoError(t, err) - assert.Equal(t, numAPICalls, 3) -} - -func TestUpsertSettingsFromCache(t *testing.T) { - numAPIGetCalls := 0 - numAPIPostCalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - numAPIGetCalls++ - rw.WriteHeader(200) - rw.Write([]byte("{}")) - return - } - - numAPIPostCalls++ - rw.WriteHeader(200) - rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) - })) - defer server.Close() - - restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) - client, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient, - WithRetrySettings(testRetrySettings), - WithClientRequestLimiter(concurrency.NewLimiter(5)), - WithExternalIDGenerator(idutils.GenerateExternalID)) - - _, err := client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - - assert.NoError(t, err) - assert.Equal(t, numAPIGetCalls, 1) - assert.Equal(t, numAPIPostCalls, 1) - - _, err = client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - - assert.NoError(t, err) - assert.Equal(t, numAPIGetCalls, 1) // still one - assert.Equal(t, numAPIPostCalls, 2) -} - -func TestUpsertSettingsFromCache_CacheInvalidated(t *testing.T) { - numGetAPICalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - numGetAPICalls++ - rw.WriteHeader(200) - _, _ = rw.Write([]byte("{}")) - return - } - - rw.WriteHeader(409) - rw.Write([]byte(`{}`)) - })) - defer server.Close() - - client := DynatraceClient{ - environmentURL: server.URL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: testRetrySettings, - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - settingsCache: &cache.DefaultCache[[]DownloadSettingsObject]{}, - classicConfigsCache: &cache.DefaultCache[[]Value]{}, - schemaConstraintsCache: &cache.DefaultCache[SchemaConstraints]{}, - } - - client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - assert.Equal(t, 1, numGetAPICalls) - - client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - assert.Equal(t, 2, numGetAPICalls) - - client.UpsertSettings(context.TODO(), SettingsObject{ - Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, - SchemaId: "some:schema", - Content: []byte("{}"), - }) - assert.Equal(t, 3, numGetAPICalls) - -} - -func TestListEntities(t *testing.T) { - - testType := "SOMETHING" - - tests := []struct { - name string - givenEntitiesType EntitiesType - givenServerResponses []testServerResponse - want []string - wantQueryParamsPerAPICall [][]testQueryParams - wantNumberOfAPICalls int - wantError bool - }{ - { - name: "Lists Entities objects as expected", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Handles Pagination when listing entities objects", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 2, - wantError: false, - }, - { - name: "Returns empty if list if no entities exist", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, `{ "entities": [ ] }`}, - }, - want: []string{}, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {400, `epic fail`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - }, - wantNumberOfAPICalls: 1, - wantError: true, - }, - { - name: "Retries on HTTP error on paginated request and returns eventual success", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {400, `get next page fail`}, - {400, `retry fail`}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 4, - wantError: false, - }, - { - name: "Returns error if HTTP error is encountered getting further paginated responses", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {400, `get next page fail`}, - {400, `retry fail 1`}, - {400, `retry fail 2`}, - {400, `retry fail 3`}, - }, - want: nil, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 5, - wantError: true, - }, - { - name: "Retries on empty paginated response", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {200, fmt.Sprintf(`{ "entities": [] }`)}, - {200, fmt.Sprintf(`{ "entities": [] }`)}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 4, - wantError: false, - }, - { - name: "Retries on wrong field for entity type", - givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, - givenServerResponses: []testServerResponse{ - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, - {400, fmt.Sprintf(`{{ - "error":{ - "code":400, - "message":"Constraints violated.", - "constraintViolations":[{ - "path":"fields", - "message":"'ipAddress' is not a valid property for type '%s'", - "parameterLocation":"QUERY", - "location":null - }] - } - } - }`, testType)}, - {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, - }, - want: []string{ - fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), - fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), - }, - wantQueryParamsPerAPICall: [][]testQueryParams{ - { - {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, - {"pageSize", defaultPageSizeEntities}, - {"fields", defaultListEntitiesFields}, - }, - { - {"nextPageKey", "page42"}, - }, - { - {"nextPageKey", "page42"}, - }, - }, - wantNumberOfAPICalls: 3, - wantError: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - apiCalls := 0 - server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if len(tt.wantQueryParamsPerAPICall) > 0 { - params := tt.wantQueryParamsPerAPICall[apiCalls] - for _, param := range params { - addedQueryParameter := req.URL.Query()[param.key] - assert.NotNil(t, addedQueryParameter) - assert.Greater(t, len(addedQueryParameter), 0) - assert.Equal(t, addedQueryParameter[0], param.value) - } - } else { - assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) - } - - resp := tt.givenServerResponses[apiCalls] - if resp.statusCode != 200 { - http.Error(rw, resp.body, resp.statusCode) - } else { - _, _ = rw.Write([]byte(resp.body)) - } - - apiCalls++ - assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) - })) - defer server.Close() - - client := DynatraceClient{ - environmentURL: server.URL, - platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), - retrySettings: testRetrySettings, - limiter: concurrency.NewLimiter(5), - generateExternalID: idutils.GenerateExternalID, - } - - res, err1 := client.ListEntities(context.TODO(), tt.givenEntitiesType) - - if tt.wantError { - assert.Error(t, err1) - } else { - assert.NoError(t, err1) - } - - assert.Equal(t, tt.want, res) - - assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) - }) - } -} - func TestCreateDynatraceClientWithAutoServerVersion(t *testing.T) { t.Run("Server version is correctly set to determined value", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { diff --git a/pkg/client/dtclient/config_client_test.go b/pkg/client/dtclient/config_client_test.go index 52ddd8126..4efdc9a35 100644 --- a/pkg/client/dtclient/config_client_test.go +++ b/pkg/client/dtclient/config_client_test.go @@ -21,10 +21,12 @@ package dtclient import ( "context" "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/trafficlogs" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/api" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" - "gotest.tools/assert" + "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "testing" @@ -47,8 +49,8 @@ func TestTranslateGenericValuesOnStandardResponse(t *testing.T) { values, err := translateGenericValues(context.TODO(), response, "extensions") - assert.NilError(t, err) - assert.Check(t, len(values) == 1) + assert.NoError(t, err) + assert.Len(t, values, 1) assert.Equal(t, values[0].Id, "foo") assert.Equal(t, values[0].Name, "bar") @@ -77,8 +79,8 @@ func TestTranslateGenericValuesOnNameMissing(t *testing.T) { values, err := translateGenericValues(context.TODO(), response, "extensions") - assert.NilError(t, err) - assert.Check(t, len(values) == 1) + assert.NoError(t, err) + assert.Len(t, values, 1) assert.Equal(t, values[0].Id, "foo") assert.Equal(t, values[0].Name, "foo") @@ -95,8 +97,8 @@ func TestTranslateGenericValuesForReportsEndpoint(t *testing.T) { values, err := translateGenericValues(context.TODO(), response, "reports") - assert.NilError(t, err) - assert.Check(t, len(values) == 1) + assert.NoError(t, err) + assert.Len(t, values, 1) assert.Equal(t, values[0].Id, "foo") assert.Equal(t, values[0].Name, "dashboardId") @@ -551,8 +553,8 @@ func Test_GetObjectIdIfAlreadyExists_WorksCorrectlyForAddedQueryParameters(t *te params := tt.expectedQueryParamsPerApiCall[apiCalls] for _, param := range params { addedQueryParameter := req.URL.Query()[param.key] - assert.Assert(t, addedQueryParameter != nil) - assert.Assert(t, len(addedQueryParameter) > 0) + assert.NotNil(t, addedQueryParameter) + assert.Greater(t, len(addedQueryParameter), 0) assert.Equal(t, addedQueryParameter[0], param.value) } } else { @@ -567,7 +569,7 @@ func Test_GetObjectIdIfAlreadyExists_WorksCorrectlyForAddedQueryParameters(t *te } apiCalls++ - assert.Check(t, apiCalls <= tt.expectedApiCalls, "expected at most %d API calls to happen, but encountered call %d", tt.expectedApiCalls, apiCalls) + assert.LessOrEqual(t, apiCalls, tt.expectedApiCalls, "expected at most %d API calls to happen, but encountered call %d", tt.expectedApiCalls, apiCalls) })) defer server.Close() testApi := api.API{ID: tt.apiKey} @@ -581,9 +583,9 @@ func Test_GetObjectIdIfAlreadyExists_WorksCorrectlyForAddedQueryParameters(t *te _, err := dtclient.getObjectIdIfAlreadyExists(context.TODO(), testApi, server.URL, "") if tt.expectError { - assert.Assert(t, err != nil) + assert.NotNil(t, err) } else { - assert.NilError(t, err) + assert.NoError(t, err) } assert.Equal(t, apiCalls, tt.expectedApiCalls, "expected exactly %d API calls to happen but %d calls where made", tt.expectedApiCalls, apiCalls) @@ -651,8 +653,8 @@ func Test_createDynatraceObject(t *testing.T) { for _, param := range tt.expectedQueryParams { addedQueryParameter := req.URL.Query()[param.key] - assert.Assert(t, addedQueryParameter != nil) - assert.Assert(t, len(addedQueryParameter) > 0) + assert.NotNil(t, addedQueryParameter) + assert.Greater(t, len(addedQueryParameter), 0) assert.Equal(t, addedQueryParameter[0], param.value) } } else { @@ -675,7 +677,7 @@ func Test_createDynatraceObject(t *testing.T) { t.Errorf("createDynatraceObject() error = %v, wantErr %v", err, tt.wantErr) return } - assert.DeepEqual(t, got, tt.want) + assert.Equal(t, got, tt.want) }) } } @@ -725,8 +727,59 @@ func TestDeployConfigsTargetingClassicConfigNonUnique(t *testing.T) { dtclient, _ := NewDynatraceClientForTesting(server.URL, server.Client(), WithRetrySettings(testRetrySettings)) got, err := dtclient.upsertDynatraceEntityByNonUniqueNameAndId(context.TODO(), generatedUuid, theConfigName, testApi, []byte("{}")) - assert.NilError(t, err) + assert.NoError(t, err) assert.Equal(t, got.Id, tt.expectedIdToBeUpserted) }) } } + +func TestReadByIdReturnsAnErrorUponEncounteringAnError(t *testing.T) { + testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + http.Error(res, "", http.StatusForbidden) + })) + defer func() { testServer.Close() }() + client := DynatraceClient{ + environmentURLClassic: testServer.URL, + classicClient: rest.NewRestClient(testServer.Client(), trafficlogs.NewFileBased(), rest.CreateRateLimitStrategy()), + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + + _, err := client.ReadConfigById(mockAPI, "test") + assert.ErrorContains(t, err, "Response was") +} + +func TestReadByIdEscapesTheId(t *testing.T) { + unescapedID := "ruxit.perfmon.dotnetV4:%TimeInGC:time_in_gc_alert_high_generic" + + testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {})) + defer func() { testServer.Close() }() + client := DynatraceClient{ + environmentURLClassic: testServer.URL, + classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + _, err := client.ReadConfigById(mockAPINotSingle, unescapedID) + assert.NoError(t, err) +} + +func TestReadByIdReturnsTheResponseGivenNoError(t *testing.T) { + body := []byte{1, 3, 3, 7} + + testServer := httptest.NewTLSServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + _, _ = res.Write(body) + })) + defer func() { testServer.Close() }() + + client := DynatraceClient{ + environmentURLClassic: testServer.URL, + classicClient: rest.NewRestClient(testServer.Client(), nil, rest.CreateRateLimitStrategy()), + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + + resp, err := client.ReadConfigById(mockAPI, "test") + assert.NoError(t, err, "there should not be an error") + assert.Equal(t, body, resp) +} diff --git a/pkg/client/dtclient/entities_client_test.go b/pkg/client/dtclient/entities_client_test.go index 4c01d2a52..348aa3165 100644 --- a/pkg/client/dtclient/entities_client_test.go +++ b/pkg/client/dtclient/entities_client_test.go @@ -20,7 +20,14 @@ package dtclient import ( "encoding/json" - "gotest.tools/assert" + "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" + "github.com/stretchr/testify/assert" + "golang.org/x/net/context" + "net/http" + "net/http/httptest" "testing" ) @@ -77,3 +84,283 @@ func Test_fields(t *testing.T) { }) } } + +func TestListEntities(t *testing.T) { + + testType := "SOMETHING" + + tests := []struct { + name string + givenEntitiesType EntitiesType + givenServerResponses []testServerResponse + want []string + wantQueryParamsPerAPICall [][]testQueryParams + wantNumberOfAPICalls int + wantError bool + }{ + { + name: "Lists Entities objects as expected", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Handles Pagination when listing entities objects", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 2, + wantError: false, + }, + { + name: "Returns empty if list if no entities exist", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, `{ "entities": [ ] }`}, + }, + want: []string{}, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {400, `epic fail`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: true, + }, + { + name: "Retries on HTTP error on paginated request and returns eventual success", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {400, `get next page fail`}, + {400, `retry fail`}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 4, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered getting further paginated responses", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {400, `get next page fail`}, + {400, `retry fail 1`}, + {400, `retry fail 2`}, + {400, `retry fail 3`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 5, + wantError: true, + }, + { + name: "Retries on empty paginated response", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {200, fmt.Sprintf(`{ "entities": [] }`)}, + {200, fmt.Sprintf(`{ "entities": [] }`)}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 4, + wantError: false, + }, + { + name: "Retries on wrong field for entity type", + givenEntitiesType: EntitiesType{EntitiesTypeId: testType}, + givenServerResponses: []testServerResponse{ + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-1A28B791C329D741", "type": "%s"} ], "nextPageKey": "page42" }`, testType, testType)}, + {400, fmt.Sprintf(`{{ + "error":{ + "code":400, + "message":"Constraints violated.", + "constraintViolations":[{ + "path":"fields", + "message":"'ipAddress' is not a valid property for type '%s'", + "parameterLocation":"QUERY", + "location":null + }] + } + } + }`, testType)}, + {200, fmt.Sprintf(`{ "entities": [ {"entityId": "%s-C329D7411A28B791", "type": "%s"} ] }`, testType, testType)}, + }, + want: []string{ + fmt.Sprintf(`{"entityId": "%s-1A28B791C329D741", "type": "%s"}`, testType, testType), + fmt.Sprintf(`{"entityId": "%s-C329D7411A28B791", "type": "%s"}`, testType, testType), + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"entitySelector", fmt.Sprintf(`type("%s")`, testType)}, + {"pageSize", defaultPageSizeEntities}, + {"fields", defaultListEntitiesFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 3, + wantError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiCalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if len(tt.wantQueryParamsPerAPICall) > 0 { + params := tt.wantQueryParamsPerAPICall[apiCalls] + for _, param := range params { + addedQueryParameter := req.URL.Query()[param.key] + assert.NotNil(t, addedQueryParameter) + assert.Greater(t, len(addedQueryParameter), 0) + assert.Equal(t, addedQueryParameter[0], param.value) + } + } else { + assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) + } + + resp := tt.givenServerResponses[apiCalls] + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + + apiCalls++ + assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) + })) + defer server.Close() + + client := DynatraceClient{ + environmentURL: server.URL, + platformClient: rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()), + retrySettings: testRetrySettings, + limiter: concurrency.NewLimiter(5), + generateExternalID: idutils.GenerateExternalID, + } + + res, err1 := client.ListEntities(context.TODO(), tt.givenEntitiesType) + + if tt.wantError { + assert.Error(t, err1) + } else { + assert.NoError(t, err1) + } + + assert.Equal(t, tt.want, res) + + assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) + }) + } +} diff --git a/pkg/client/dtclient/client_settings.go b/pkg/client/dtclient/settings_client.go similarity index 70% rename from pkg/client/dtclient/client_settings.go rename to pkg/client/dtclient/settings_client.go index 6f2b98b83..3d715d67a 100644 --- a/pkg/client/dtclient/client_settings.go +++ b/pkg/client/dtclient/settings_client.go @@ -22,12 +22,16 @@ import ( "encoding/json" "errors" "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/filter" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/version" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" + "github.com/google/go-cmp/cmp" + "golang.org/x/exp/maps" "net/http" "net/url" + "strings" ) type ( @@ -75,13 +79,15 @@ type ( ObjectId string `json:"objectId,omitempty"` } + schemaConstraint struct { + Type string `json:"type"` + UniqueProperties []string `json:"uniqueProperties"` + } + // schemaDetailsResponse is the response type returned by the fetchSchemasConstraints operation schemaDetailsResponse struct { - SchemaId string `json:"schemaId"` - SchemaConstraints []struct { - Type string `json:"type"` - UniqueProperties []string `json:"uniqueProperties"` - } `json:"schemaConstraints"` + SchemaId string `json:"schemaId"` + SchemaConstraints []schemaConstraint `json:"schemaConstraints"` } ) @@ -167,7 +173,6 @@ func (d *DynatraceClient) UpsertSettings(ctx context.Context, obj SettingsObject } func (d *DynatraceClient) upsertSettings(ctx context.Context, obj SettingsObject) (DynatraceEntity, error) { - // special handling for updating settings 2.0 objects on tenants with version pre 1.262.0 // Tenants with versions < 1.262 are not able to handle updates of existing // settings 2.0 objects that are non-deletable. @@ -187,6 +192,13 @@ func (d *DynatraceClient) upsertSettings(ctx context.Context, obj SettingsObject } } + if match, err := d.findObjectWithMatchingConstraints(ctx, obj); err != nil { + return DynatraceEntity{}, err + } else if match != nil { + log.WithCtxFields(ctx).Debug("Updating existing object %q with matching unique keys", match.ObjectId) + obj.OriginObjectId = match.ObjectId + } + // generate legacy external ID without project name. // and check if settings object with that external ID exists // This exists for avoiding breaking changes when we enhanced external id generation with full coordinates (incl. project name) @@ -247,6 +259,88 @@ func (d *DynatraceClient) upsertSettings(ctx context.Context, obj SettingsObject return entity, nil } +func (d *DynatraceClient) findObjectWithMatchingConstraints(ctx context.Context, source SettingsObject) (*DownloadSettingsObject, error) { + constraints, err := d.fetchSchemasConstraints(ctx, source.SchemaId) + if err != nil { + return nil, fmt.Errorf("unable to get details for schema %q: %w", source.SchemaId, err) + } + + if len(constraints.UniqueProperties) == 0 { + return nil, nil + } + + objects, err := d.listSettings(ctx, source.SchemaId, ListSettingsOptions{}) + if err != nil { + return nil, fmt.Errorf("unable to get existing settings objects for %q schema: %w", source.SchemaId, err) + } + + target, err := findObjectWithSameConstraints(constraints, source, objects) + if err != nil { + return nil, err + } + return target, nil +} + +func findObjectWithSameConstraints(schema SchemaConstraints, source SettingsObject, objects []DownloadSettingsObject) (*DownloadSettingsObject, error) { + candidates := make(map[int]struct{}) + + for _, uniqueKeys := range schema.UniqueProperties { + for j, o := range objects { + match, err := doObjectsMatchBasedOnUniqueKeys(uniqueKeys, source, o) + if err != nil { + return nil, err + } + if match { + candidates[j] = struct{}{} // candidate found, store index (same object might match for several constraints, set ensures we only count it once) + } + } + } + + if len(candidates) == 1 { // unique match found + index := maps.Keys(candidates)[0] + return &objects[index], nil + } + + if len(candidates) > 1 { + var objectIds []string + for i := range candidates { + objectIds = append(objectIds, objects[i].ObjectId) + } + + return nil, fmt.Errorf("can't update configuration %q - unable to find exact match, several existing objects with matching unique keys found: %v", source.Coordinate, strings.Join(objectIds, ", ")) + } + + return nil, nil // no matches found +} + +func doObjectsMatchBasedOnUniqueKeys(uniqueKeys []string, source SettingsObject, other DownloadSettingsObject) (bool, error) { + for _, key := range uniqueKeys { + same, err := isSameValueForKey(key, source.Content, other.Value) + if err != nil { + return false, err + } + if !same { + return false, nil + } + } + return true, nil +} + +func isSameValueForKey(key string, c1 []byte, c2 []byte) (bool, error) { + u := make(map[string]any) + if err := json.Unmarshal(c1, &u); err != nil { + return false, fmt.Errorf("failed to unmarshal data for key %q: %w", key, err) + } + v1 := u[key] + + if err := json.Unmarshal(c2, &u); err != nil { + return false, fmt.Errorf("failed to unmarshal data for key %q: %w", key, err) + } + v2 := u[key] + + return cmp.Equal(v1, v2), nil +} + // buildPostRequestPayload builds the json that is required as body in the settings api. // POST Request body: https://www.dynatrace.com/support/help/dynatrace-api/environment-api/settings/objects/post-object#request-body-json-model // @@ -308,3 +402,58 @@ func parsePostResponse(resp rest.Response) (DynatraceEntity, error) { Name: parsed[0].ObjectId, }, nil } + +func (d *DynatraceClient) ListSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) (res []DownloadSettingsObject, err error) { + d.limiter.ExecuteBlocking(func() { + res, err = d.listSettings(ctx, schemaId, opts) + }) + return +} + +func (d *DynatraceClient) listSettings(ctx context.Context, schemaId string, opts ListSettingsOptions) ([]DownloadSettingsObject, error) { + + if settings, cached := d.settingsCache.Get(schemaId); cached { + log.Debug("Using cached settings for schema %s", schemaId) + return filter.FilterSlice(settings, opts.Filter), nil + } + + log.Debug("Downloading all settings for schema %s", schemaId) + + listSettingsFields := defaultListSettingsFields + if opts.DiscardValue { + listSettingsFields = reducedListSettingsFields + } + params := url.Values{ + "schemaIds": []string{schemaId}, + "pageSize": []string{defaultPageSize}, + "fields": []string{listSettingsFields}, + } + + result := make([]DownloadSettingsObject, 0) + + addToResult := func(body []byte) (int, error) { + var parsed struct { + Items []DownloadSettingsObject `json:"items"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return 0, fmt.Errorf("failed to unmarshal response: %w", err) + } + + result = append(result, parsed.Items...) + return len(parsed.Items), nil + } + + u, err := buildUrl(d.environmentURL, d.settingsObjectAPIPath, params) + if err != nil { + return nil, fmt.Errorf("failed to list settings: %w", err) + } + + _, err = rest.ListPaginated(ctx, d.platformClient, d.retrySettings, u, schemaId, addToResult) + if err != nil { + return nil, err + } + + d.settingsCache.Set(schemaId, result) + + return filter.FilterSlice(result, opts.Filter), nil +} diff --git a/pkg/client/dtclient/settings_client_test.go b/pkg/client/dtclient/settings_client_test.go index b823210df..1ec226c56 100644 --- a/pkg/client/dtclient/settings_client_test.go +++ b/pkg/client/dtclient/settings_client_test.go @@ -21,18 +21,401 @@ package dtclient import ( "context" "encoding/json" + "fmt" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/concurrency" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/version" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/rest" - "gotest.tools/assert" + "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "strings" "testing" ) +func Test_schemaDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case settingsSchemaAPIPathPlatform + "/builtin:span-attribute": + r := []byte(` +{ + "schemaId": "builtin:span-attribute", + "schemaConstraints": [ + { + "type": "some another type", + "customMessage": "Attribute keys must be unique.", + "something": "example" + }, + { + "type": "UNIQUE", + "customMessage": "Attribute keys must be unique.", + "uniqueProperties": [ + "key0", + "key1" + ] + }, + { + "type": "UNIQUE", + "customMessage": "Attribute keys must be unique.", + "uniqueProperties": [ + "key2", + "key3" + ] + } + ] +}`) + rw.WriteHeader(http.StatusOK) + rw.Write(r) + default: + rw.WriteHeader(http.StatusNotFound) + + } + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + + d, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient) + + t.Run("unmarshall data", func(t *testing.T) { + expected := SchemaConstraints{SchemaId: "builtin:span-attribute", UniqueProperties: [][]string{{"key0", "key1"}, {"key2", "key3"}}} + + actual, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + }) +} + +func Test_FetchSchemaConstraintsUsesCache(t *testing.T) { + apiHits := 0 + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + apiHits++ + r := []byte(`{"schemaId": "builtin:span-attribute","schemaConstraints": []}`) + rw.WriteHeader(http.StatusOK) + rw.Write(r) + + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + d, _ := NewPlatformClient(server.URL, server.URL, restClient, restClient) + + _, err := d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") + assert.NoError(t, err) + assert.Equal(t, 1, apiHits) + _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:alerting.profile") + assert.NoError(t, err) + assert.Equal(t, 2, apiHits) + _, err = d.fetchSchemasConstraints(context.TODO(), "builtin:span-attribute") + assert.NoError(t, err) + assert.Equal(t, 2, apiHits) +} + +func Test_findObjectWithSameConstraints(t *testing.T) { + type ( + given struct { + schema SchemaConstraints + source SettingsObject + objects []DownloadSettingsObject + } + ) + + t.Run("normal cases", func(t *testing.T) { + tests := []struct { + name string + given given + expected *DownloadSettingsObject + }{ + { + name: "single constraint with boolean values- match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":true}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":true}`)}, + {Value: []byte(`{"A":false}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":true}`)}, + }, + { + name: "single constraint with int values - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":2}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":3}`)}, + {Value: []byte(`{"A":"x2"}`)}, + }, + }, + expected: nil, + }, + { + name: "single constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x"}`)}, + {Value: []byte(`{"A":"x1"}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":"x"}`)}, + }, + { + name: "single constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x1"}`)}, + {Value: []byte(`{"A":"x2"}`)}, + }, + }, + expected: nil, + }, + { + name: "single complex object constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": {"key":"x", "val":"y"}}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": {"key":"x", "val":"y"}}`)}, + {Value: []byte(`{"A": {"key":"x1", "val":"y"}}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A": {"key":"x", "val":"y"}}`)}, + }, + { + name: "single complex object constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": {"key":"x", "val":"y"}}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": {"key":"x1", "val":"y"}}`)}, + {Value: []byte(`{"A": {"key":"x", "val":"y1"}}`)}, + }, + }, + expected: nil, + }, + { + name: "single list value constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": [1,2,3]}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": [1,2,3]}`)}, + {Value: []byte(`{"A": [3,2,1]}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A": [1,2,3]}`)}, + }, + { + name: "single list value constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A": [1,2,3]}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A": []}`)}, + {Value: []byte(`{"A": [3,2,1]}`)}, + }, + }, + expected: nil, + }, + { + name: "signe composite constraint - match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A", "B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y"}`)}, + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":"x", "B":"y"}`)}, + }, + { + name: "signe composite constraint - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A", "B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + {Value: []byte(`{"A":"x", "B":"y2"}`)}, + }, + }, + expected: nil, + }, + { + name: "multiple simple constraints - one perfect match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"A", "B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {ObjectId: "obj_1", Value: []byte(`{"A":"x", "B":"y"}`)}, + {ObjectId: "obj_2", Value: []byte(`{"A":"x2", "B":"y"}`)}, + }, + }, + expected: &DownloadSettingsObject{ObjectId: "obj_1", Value: []byte(`{"A":"x", "B":"y"}`)}, + }, + { + name: "multiple simple constraints - one semi match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + {Value: []byte(`{"A":"x2", "B":"y2"}`)}, + }, + }, + expected: &DownloadSettingsObject{Value: []byte(`{"A":"x", "B":"y1"}`)}, + }, + { + name: "multiple simple constraints - no match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x1", "B":"y1"}`)}, + {Value: []byte(`{"A":"x2", "B":"y2"}`)}, + }, + }, + expected: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual, err := findObjectWithSameConstraints(tc.given.schema, tc.given.source, tc.given.objects) + + fmt.Println(actual) + assert.NoError(t, err) + if tc.expected != nil { + assert.NotNil(t, actual) + assert.Equal(t, tc.expected, actual) + } else { + assert.Nil(t, actual) + } + }) + } + }) + + t.Run("error cases", func(t *testing.T) { + tests := []struct { + name string + given given + }{ + { + name: "multiple simple constraints - multiple match", + given: given{ + schema: SchemaConstraints{ + UniqueProperties: [][]string{ + {"A"}, + {"B"}, + }, + }, + source: SettingsObject{ + SchemaId: "schemaID", Content: []byte(`{"A":"x", "B":"y"}`), + }, + objects: []DownloadSettingsObject{ + {Value: []byte(`{"A":"x", "B":"y1"}`)}, + {Value: []byte(`{"A":"x2", "B":"y"}`)}, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := findObjectWithSameConstraints(tc.given.schema, tc.given.source, tc.given.objects) + assert.Error(t, err) + }) + } + + }) +} + func TestUpsertSettings(t *testing.T) { tests := []struct { name string @@ -151,7 +534,11 @@ func TestUpsertSettings(t *testing.T) { t.Run(test.name, func(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) { - + if r.URL.Path == settingsSchemaAPIPathClassic+"/builtin:alerting.profile" { + writer.WriteHeader(http.StatusOK) + writer.Write([]byte("{}")) + return + } // GET settings requests if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v2/settings/objects") { // GET single settings obj request @@ -169,7 +556,7 @@ func TestUpsertSettings(t *testing.T) { // Build & assert object we expect Dynatrace to receive var expectedSettingsObject any err := json.Unmarshal([]byte(test.expectSettingsRequestValue), &expectedSettingsObject) - assert.NilError(t, err) + assert.NoError(t, err) extId, _ := idutils.GenerateExternalID(coordinate.Coordinate{ Project: "my-project", Type: "builtin:alerting.profile", @@ -186,15 +573,15 @@ func TestUpsertSettings(t *testing.T) { var obj []settingsRequest err = json.NewDecoder(r.Body).Decode(&obj) - assert.NilError(t, err) - assert.DeepEqual(t, obj, expectedRequestPayload) + assert.NoError(t, err) + assert.Equal(t, obj, expectedRequestPayload) // response to client if test.postSettingsResponseCode != 0 { http.Error(writer, test.postSettingsResponseContent, test.postSettingsResponseCode) } else { _, err := writer.Write([]byte(test.postSettingsResponseContent)) - assert.NilError(t, err) + assert.NoError(t, err) } })) @@ -214,7 +601,906 @@ func TestUpsertSettings(t *testing.T) { }) assert.Equal(t, err != nil, test.expectError) - assert.DeepEqual(t, resp, test.expectEntity) + assert.Equal(t, resp, test.expectEntity) + }) + } +} + +func TestUpsertSettingsRetries(t *testing.T) { + numAPICalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodGet { + rw.WriteHeader(200) + _, _ = rw.Write([]byte("{}")) + return + } + + numAPICalls++ + if numAPICalls < 3 { + rw.WriteHeader(409) + return + } + rw.WriteHeader(200) + _, _ = rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + _, err := client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + + assert.NoError(t, err) + assert.Equal(t, numAPICalls, 3) +} + +func TestUpsertSettingsFromCache(t *testing.T) { + numAPIGetCalls := 0 + numAPIPostCalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == settingsSchemaAPIPathClassic+"/some:schema" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("{}")) + return + } + if req.Method == http.MethodGet { + numAPIGetCalls++ + rw.WriteHeader(200) + rw.Write([]byte("{}")) + return + } + + numAPIPostCalls++ + rw.WriteHeader(200) + rw.Write([]byte(`[{"objectId": "abcdefg"}]`)) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + _, err := client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + + assert.NoError(t, err) + assert.Equal(t, 1, numAPIGetCalls) + assert.Equal(t, 1, numAPIPostCalls) + + _, err = client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + + assert.NoError(t, err) + assert.Equal(t, 1, numAPIGetCalls) // still one + assert.Equal(t, 2, numAPIPostCalls) +} + +func TestUpsertSettingsFromCache_CacheInvalidated(t *testing.T) { + numGetAPICalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == settingsSchemaAPIPathClassic+"/some:schema" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("{}")) + return + } + if req.Method == http.MethodGet { + numGetAPICalls++ + rw.WriteHeader(200) + _, _ = rw.Write([]byte("{}")) + return + } + + rw.WriteHeader(409) + rw.Write([]byte(`{}`)) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + assert.Equal(t, 1, numGetAPICalls) + + client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + assert.Equal(t, 2, numGetAPICalls) + + client.UpsertSettings(context.TODO(), SettingsObject{ + Coordinate: coordinate.Coordinate{Type: "some:schema", ConfigId: "id"}, + SchemaId: "some:schema", + Content: []byte("{}"), + }) + assert.Equal(t, 3, numGetAPICalls) + +} + +func TestUpsertSettingsConsidersUniqueKeyConstraints(t *testing.T) { + + type given struct { + schemaDetailsResponse schemaDetailsResponse + listSettingsResponse []DownloadSettingsObject + settingsObject SettingsObject + } + type want struct { + error bool + postSettingsRequest settingsRequest + } + tests := []struct { + name string + given given + want want + }{ + { + "Creates new object if none exists", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_2"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{}, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "a", "key_2": 42 }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": "a", + "key_2": float64(42), + }, + }, + }, + }, + { + "Creates new object if no matching unique key is found", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": "NOT A MATCH", "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": "NOT A MATCH EITHER", "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": "MATCH", + "key_2": "dont-care", + }, + }, + }, + }, + { + "Updates object if matching unique key is found", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": "NOT A MATCH", "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", // object ID of matching object + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": "MATCH", + "key_2": "dont-care", + }, + }, + }, + }, + { + "Updates object if matching unique key is found - complex key object", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": { "a": [false,true,false], "b": 21.0, "c": { "cK": "cV" } }, "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": { "a": [false,true,false], "b": 42.0, "c": { "cK": "cV" } }, "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": { "a": [false,true,false], "b": 42.0, "c": { "cK": "cV" } }, "key_2": "new value" }`), + }, + }, + want{ + error: false, + postSettingsRequest: settingsRequest{ + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", // object ID of matching object + ExternalId: "monaco:cCRidWlsdGluOmFsZXJ0aW5nLnByb2ZpbGUkaWQ=", + Value: map[string]interface{}{ + "key_1": map[string]interface{}{ + "a": []interface{}{false, true, false}, + "b": 42.0, + "c": map[string]interface{}{ + "cK": "cV", + }, + }, + "key_2": "new value", + }, + }, + }, + }, + { + "Returns error if several matching objects are found", + given{ + schemaDetailsResponse: schemaDetailsResponse{ + SchemaId: "builtin:alerting.profile", + SchemaConstraints: []schemaConstraint{ + { + Type: "UNIQUE", + UniqueProperties: []string{"key_1"}, + }, + }, + }, + listSettingsResponse: []DownloadSettingsObject{ + { + ExternalId: "externalID--1", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--1", + Value: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + { + ExternalId: "externalID--2", + SchemaId: "builtin:alerting.profile", + ObjectId: "objectID--2", + Value: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + settingsObject: SettingsObject{ + Coordinate: coordinate.Coordinate{"p", "builtin:alerting.profile", "id"}, + SchemaId: "builtin:alerting.profile", + Content: []byte(`{ "key_1": "MATCH", "key_2": "dont-care" }`), + }, + }, + want{ + error: true, + postSettingsRequest: settingsRequest{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) { + + // GET schema details + if r.URL.Path == settingsSchemaAPIPathClassic+"/builtin:alerting.profile" { + writer.WriteHeader(http.StatusOK) + b, err := json.Marshal(tt.given.schemaDetailsResponse) + assert.NoError(t, err) + _, _ = writer.Write(b) + return + } + + // GET settings objects + if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, settingsObjectAPIPathClassic) { + // response to client + writer.WriteHeader(http.StatusOK) + l := struct { + Items []DownloadSettingsObject `json:"items"` + }{ + tt.given.listSettingsResponse, + } + b, err := json.Marshal(l) + assert.NoError(t, err) + _, _ = writer.Write(b) + return + } + + // ASSERT expected object creation POST request + assert.Equal(t, http.MethodPost, r.Method) + var obj []settingsRequest + err := json.NewDecoder(r.Body).Decode(&obj) + assert.NoError(t, err) + assert.Len(t, obj, 1) + assert.Equal(t, tt.want.postSettingsRequest, obj[0]) + + writer.WriteHeader(200) + writer.Write([]byte(`[ { "objectId": "abcsd423==" } ]`)) + })) + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + c, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + _, err := c.UpsertSettings(context.TODO(), tt.given.settingsObject) + if tt.want.error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestListKnownSettings(t *testing.T) { + + tests := []struct { + name string + givenSchemaID string + givenListSettingsOpts ListSettingsOptions + givenServerResponses []testServerResponse + want []DownloadSettingsObject + wantQueryParamsPerAPICall [][]testQueryParams + wantNumberOfAPICalls int + wantError bool + }{ + { + name: "Lists Settings objects as expected", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Lists Settings objects without value field as expected", + givenSchemaID: "builtin:something", + givenListSettingsOpts: ListSettingsOptions{DiscardValue: true}, + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", reducedListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Lists Settings objects with filter as expected", + givenSchemaID: "builtin:something", + givenListSettingsOpts: ListSettingsOptions{Filter: func(o DownloadSettingsObject) bool { + return o.ExternalId == "RG9jdG9yIFdobwo=" + }}, + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ] }`}, + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4641", "externalId": "RG9jdG9yIabcdef="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Handles Pagination when listing settings objects", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, + {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + { + ExternalId: "VGhlIE1hc3Rlcgo=", + ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", + }, + }, + + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 2, + wantError: false, + }, + { + name: "Returns empty if list if no items exist", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ ] }`}, + }, + want: []DownloadSettingsObject{}, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered - 400", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {400, `epic fail`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: true, + }, + { + name: "Returns error if HTTP error is encountered - 403", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {403, `epic fail`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + }, + wantNumberOfAPICalls: 1, + wantError: true, + }, + { + name: "Retries on HTTP error on paginated request and returns eventual success", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, + {400, `get next page fail`}, + {400, `retry fail`}, + {200, `{ "items": [ {"objectId": "b1d4c623-25e0-4b54-9eb5-6734f1a72041", "externalId": "VGhlIE1hc3Rlcgo="} ] }`}, + }, + want: []DownloadSettingsObject{ + { + ExternalId: "RG9jdG9yIFdobwo=", + ObjectId: "f5823eca-4838-49d0-81d9-0514dd2c4640", + }, + { + ExternalId: "VGhlIE1hc3Rlcgo=", + ObjectId: "b1d4c623-25e0-4b54-9eb5-6734f1a72041", + }, + }, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 4, + wantError: false, + }, + { + name: "Returns error if HTTP error is encountered getting further paginated responses", + givenSchemaID: "builtin:something", + givenServerResponses: []testServerResponse{ + {200, `{ "items": [ {"objectId": "f5823eca-4838-49d0-81d9-0514dd2c4640", "externalId": "RG9jdG9yIFdobwo="} ], "nextPageKey": "page42" }`}, + {400, `get next page fail`}, + {400, `retry fail 1`}, + {400, `retry fail 2`}, + {400, `retry fail 3`}, + }, + want: nil, + wantQueryParamsPerAPICall: [][]testQueryParams{ + { + {"schemaIds", "builtin:something"}, + {"pageSize", "500"}, + {"fields", defaultListSettingsFields}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + { + {"nextPageKey", "page42"}, + }, + }, + wantNumberOfAPICalls: 5, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiCalls := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if len(tt.wantQueryParamsPerAPICall) > 0 { + params := tt.wantQueryParamsPerAPICall[apiCalls] + for _, param := range params { + addedQueryParameter := req.URL.Query()[param.key] + assert.NotNil(t, addedQueryParameter) + assert.NotEmpty(t, addedQueryParameter) + assert.Equal(t, addedQueryParameter[0], param.value) + } + } else { + assert.Equal(t, "", req.URL.RawQuery, "expected no query params - but '%s' was sent", req.URL.RawQuery) + } + + resp := tt.givenServerResponses[apiCalls] + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + + apiCalls++ + assert.LessOrEqualf(t, apiCalls, tt.wantNumberOfAPICalls, "expected at most %d API calls to happen, but encountered call %d", tt.wantNumberOfAPICalls, apiCalls) + })) + defer server.Close() + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(server.URL, restClient, + WithRetrySettings(testRetrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + res, err1 := client.ListSettings(context.TODO(), tt.givenSchemaID, tt.givenListSettingsOpts) + + if tt.wantError { + assert.Error(t, err1) + } else { + assert.NoError(t, err1) + } + + assert.Equal(t, tt.want, res) + + assert.Equal(t, apiCalls, tt.wantNumberOfAPICalls, "expected exactly %d API calls to happen but %d calls where made", tt.wantNumberOfAPICalls, apiCalls) + }) + } +} + +func TestGetSettingById(t *testing.T) { + type fields struct { + environmentURL string + retrySettings rest.RetrySettings + } + type args struct { + objectID string + } + tests := []struct { + name string + fields fields + args args + givenTestServerResp *testServerResponse + wantURLPath string + wantResult *DownloadSettingsObject + wantErr bool + }{ + { + name: "Get Setting by ID - server response != 2xx", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 500, + body: "{}", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantResult: nil, + wantErr: true, + }, + { + name: "Get Setting by ID - invalid server response", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 200, + body: `{bs}`, + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantResult: nil, + wantErr: true, + }, + { + name: "Get Setting by ID", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 200, + body: `{"objectId":"12345","externalId":"54321", "schemaVersion":"1.0","schemaId":"builtin:bla","scope":"tenant"}`, + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantResult: &DownloadSettingsObject{ + ExternalId: "54321", + SchemaVersion: "1.0", + SchemaId: "builtin:bla", + ObjectId: "12345", + Scope: "tenant", + Value: nil, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, tt.wantURLPath, req.URL.Path) + if resp := tt.givenTestServerResp; resp != nil { + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + } + + })) + defer server.Close() + + var envURL string + if tt.fields.environmentURL != "" { + envURL = tt.fields.environmentURL + } else { + envURL = server.URL + } + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(envURL, restClient, + WithRetrySettings(tt.fields.retrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + settingsObj, err := client.GetSettingById(tt.args.objectID) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantResult, settingsObj) + + }) + } + +} + +func TestDeleteSettings(t *testing.T) { + type fields struct { + environmentURL string + retrySettings rest.RetrySettings + } + type args struct { + objectID string + } + tests := []struct { + name string + fields fields + args args + givenTestServerResp *testServerResponse + wantURLPath string + wantErr bool + }{ + { + name: "Delete Settings - server response != 2xx", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 500, + body: "{}", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantErr: true, + }, + { + name: "Delete Settings - server response 404 does not result in an err", + fields: fields{}, + args: args{ + objectID: "12345", + }, + givenTestServerResp: &testServerResponse{ + statusCode: 404, + body: "{}", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantErr: false, + }, + { + name: "Delete Settings - object ID is passed", + fields: fields{}, + args: args{ + objectID: "12345", + }, + wantURLPath: "/api/v2/settings/objects/12345", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + server := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, tt.wantURLPath, req.URL.Path) + if resp := tt.givenTestServerResp; resp != nil { + if resp.statusCode != 200 { + http.Error(rw, resp.body, resp.statusCode) + } else { + _, _ = rw.Write([]byte(resp.body)) + } + } + + })) + defer server.Close() + + var envURL string + if tt.fields.environmentURL != "" { + envURL = tt.fields.environmentURL + } else { + envURL = server.URL + } + + restClient := rest.NewRestClient(server.Client(), nil, rest.CreateRateLimitStrategy()) + client, _ := NewClassicClient(envURL, restClient, + WithRetrySettings(tt.fields.retrySettings), + WithClientRequestLimiter(concurrency.NewLimiter(5)), + WithExternalIDGenerator(idutils.GenerateExternalID)) + + if err := client.DeleteSettings(tt.args.objectID); (err != nil) != tt.wantErr { + t.Errorf("DeleteSettings() error = %v, wantErr %v", err, tt.wantErr) + } }) } }