diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a20e2f..87e87d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # EDGEGRID GOLANG RELEASE NOTES +## 7.6.0 (February 8, 2024) + +#### FEATURES/ENHANCEMENTS: + +* General + * Enhanced error handling when Error is not in standard format. + +* Added Cloudlets V3 API support + * Cloudlet Info + * [ListCloudlets](https://techdocs.akamai.com/cloudlets/reference/get-cloudlets) + * Policies + * [ListPolicies](https://techdocs.akamai.com/cloudlets/reference/get-policies) + * [CreatePolicy](https://techdocs.akamai.com/cloudlets/reference/post-policy) + * [DeletePolicy](https://techdocs.akamai.com/cloudlets/reference/delete-policy) + * [GetPolicy](https://techdocs.akamai.com/cloudlets/reference/get-policy) + * [UpdatePolicy](https://techdocs.akamai.com/cloudlets/reference/put-policy) + * [ClonePolicy](https://techdocs.akamai.com/cloudlets/reference/post-policy-clone) + * Policy Properties + * [ListActivePolicyProperties](https://techdocs.akamai.com/cloudlets/reference/get-policy-properties) + * Policy Versions + * [ListPolicyVersions](https://techdocs.akamai.com/cloudlets/reference/get-policy-versions) + * [GetPolicyVersion](https://techdocs.akamai.com/cloudlets/reference/get-policy-version) + * [CreatePolicyVersion](https://techdocs.akamai.com/cloudlets/reference/post-policy-version) + * [DeletePolicyVersion](https://techdocs.akamai.com/cloudlets/reference/delete-policy-version) + * [UpdatePolicyVersion](https://techdocs.akamai.com/cloudlets/reference/put-policy-version) + * Policy Activations + * [ListPolicyActivations](https://techdocs.akamai.com/cloudlets/reference/get-policy-activations) + * [GetPolicyActivation](https://techdocs.akamai.com/cloudlets/reference/get-policy-activation) + * [ActivatePolicy and DeactivatePolicy](https://techdocs.akamai.com/cloudlets/reference/post-policy-activations) + * Supported cloudlet types + * API Prioritization (AP) + * Application Segmentation (AS) + * Edge Redirector (ER) + * Forward Rewrite (FR) + * Phased Release (PR aka CD) + * Request Control (RC aka IG) + +* DNS + * Added `ListGroups` method + * [ListGroups](https://techdocs.akamai.com/edge-dns/reference/get-data-groups) + +* Edgeworkers + * Added `note` field to `Activation` and `ActivateVersion` structs for EdgeWorkers Activation + +* GTM + * Added new fields to `DomainItem` struct + +* IVM + * Extended `OutputImage` for support of `AllowPristineOnDownsize` and `PreferModernFormats` + * Extended `PolicyInputImage` for support of `ServeStaleDuration` + * Extended `RolloutInfo` for support of `ServeStaleEndTime` + +#### BUG FIXES: + +* APPSEC + * Added `updateLatestNetworkStatus` query parameter in GetActivations request to resolve drift on manual changes to infrastructure + ## 7.5.0 (November 28, 2023) #### FEATURES/ENHANCEMENTS: diff --git a/Makefile b/Makefile index cd37659e..b10f6d5e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ COMMIT_SHA=$(shell git rev-parse --short HEAD) VERSION ?= $(shell git describe --tags --always | grep '^v\d' || \ echo $(FILEVERSION)-$(COMMIT_SHA)) BIN = $(CURDIR)/bin -GOLANGCI_LINT_VERSION = v1.52.2 +GOLANGCI_LINT_VERSION = v1.55.2 GO = go TIMEOUT = 15 V = 0 diff --git a/go.mod b/go.mod index c07ee2e4..2f248118 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,10 @@ require ( github.com/google/uuid v1.1.1 github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cast v1.3.1 - github.com/stretchr/testify v1.6.1 + github.com/stretchr/testify v1.8.4 github.com/tj/assert v0.0.3 go.uber.org/ratelimit v0.2.0 + golang.org/x/net v0.20.0 gopkg.in/ini.v1 v1.51.1 ) @@ -23,7 +24,9 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect - github.com/stretchr/objx v0.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect - gopkg.in/yaml.v3 v3.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index df261c41..1d8a8226 100644 --- a/go.sum +++ b/go.sum @@ -62,13 +62,18 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= @@ -82,10 +87,14 @@ go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA= go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -95,6 +104,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= @@ -107,5 +118,5 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/appsec/activations.go b/pkg/appsec/activations.go index 7ac0817d..456660db 100644 --- a/pkg/appsec/activations.go +++ b/pkg/appsec/activations.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "net/url" + "strconv" "time" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -168,11 +170,16 @@ func (p *appsec) GetActivations(ctx context.Context, params GetActivationsReques return nil, fmt.Errorf("%w: %s", ErrStructValidation, err.Error()) } - uri := fmt.Sprintf( - "/appsec/v1/activations/%d", - params.ActivationID) + uri, err := url.Parse(fmt.Sprintf("/appsec/v1/activations/%d", params.ActivationID)) + if err != nil { + return nil, fmt.Errorf("failed to parse url: %s", err) + } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + q := uri.Query() + q.Add("updateLatestNetworkStatus", strconv.FormatBool(true)) + uri.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) if err != nil { return nil, fmt.Errorf("failed to create GetActivations request: %w", err) } diff --git a/pkg/appsec/activations_test.go b/pkg/appsec/activations_test.go index 3376c25e..a7347abc 100644 --- a/pkg/appsec/activations_test.go +++ b/pkg/appsec/activations_test.go @@ -90,3 +90,81 @@ func TestAppSec_ListActivations(t *testing.T) { }) } } + +func TestAppSec_GetActivations(t *testing.T) { + + result := GetActivationsResponse{} + + respData := compactJSON(loadFixtureBytes("testdata/TestActivations/Activations.json")) + err := json.Unmarshal([]byte(respData), &result) + require.NoError(t, err) + + tests := map[string]struct { + params GetActivationsRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *GetActivationsResponse + withError error + headers http.Header + }{ + "200 OK": { + params: GetActivationsRequest{ + ActivationID: 32415, + }, + headers: http.Header{ + "Content-Type": []string{"application/json"}, + }, + responseStatus: http.StatusOK, + responseBody: string(respData), + expectedPath: "/appsec/v1/activations/32415?updateLatestNetworkStatus=true", + expectedResponse: &result, + }, + "500 internal server error": { + params: GetActivationsRequest{ + ActivationID: 32415, + }, + headers: http.Header{}, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "detail": "Error fetching activations", + "status": 500 +}`, + expectedPath: "/appsec/v1/activations/32415?updateLatestNetworkStatus=true", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Detail: "Error fetching activations", + StatusCode: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.GetActivations( + session.ContextWithOptions( + context.Background(), + session.WithContextHeaders(test.headers), + ), + test.params) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/appsec/errors.go b/pkg/appsec/errors.go index 65df0b47..760896c0 100644 --- a/pkg/appsec/errors.go +++ b/pkg/appsec/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) var ( @@ -42,8 +44,8 @@ func (p *appsec) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { p.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = "Failed to unmarshal error body" - e.Detail = err.Error() + e.Title = "Failed to unmarshal error body. Application Security API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/appsec/errors_test.go b/pkg/appsec/errors_test.go index 31b41280..06aba76e 100644 --- a/pkg/appsec/errors_test.go +++ b/pkg/appsec/errors_test.go @@ -53,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "Failed to unmarshal error body", - Detail: "invalid character 'e' in literal true (expecting 'r')", + Title: "Failed to unmarshal error body. Application Security API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -66,3 +66,60 @@ func TestNewError(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Application Security API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Application Security API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Application Security API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + as := appsec{ + Session: sess, + } + assert.Equal(t, test.expected, as.Error(test.input)) + }) + } +} diff --git a/pkg/appsec/testdata/TestActivations/Activations.json b/pkg/appsec/testdata/TestActivations/Activations.json new file mode 100644 index 00000000..9451ed1f --- /dev/null +++ b/pkg/appsec/testdata/TestActivations/Activations.json @@ -0,0 +1,18 @@ +{ + "dispatchCount" : 1, + "activationId" : 32415, + "action" : "deny", + "status" : "ACTIVATED", + "network" : "STAGING", + "estimate" : "test", + "createdBy" : "test_user", + "createDate" : "2022-05-05T14:19:17Z", + "activationConfigs" : [ + { + "configId" : 43253, + "ConfigName" : "activationConfig", + "ConfigVersion": 1, + "PreviousConfigVersion": 1 + } + ] +} \ No newline at end of file diff --git a/pkg/botman/errors.go b/pkg/botman/errors.go index 6df18347..55173b63 100644 --- a/pkg/botman/errors.go +++ b/pkg/botman/errors.go @@ -7,6 +7,8 @@ import ( "io/ioutil" "net/http" "strings" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -35,8 +37,8 @@ func (b *botman) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { b.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = "Failed to unmarshal error body" - e.Detail = err.Error() + e.Title = "Failed to unmarshal error body. Bot Manager API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/botman/errors_test.go b/pkg/botman/errors_test.go new file mode 100644 index 00000000..1e3acc0b --- /dev/null +++ b/pkg/botman/errors_test.go @@ -0,0 +1,77 @@ +package botman + +import ( + "context" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" + "github.com/stretchr/testify/require" + + "github.com/tj/assert" +) + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Bot Manager API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Bot Manager API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Bot Manager API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + b := botman{ + Session: sess, + } + assert.Equal(t, test.expected, b.Error(test.input)) + }) + } +} diff --git a/pkg/clientlists/errors.go b/pkg/clientlists/errors.go index 2a355d1f..173c208a 100644 --- a/pkg/clientlists/errors.go +++ b/pkg/clientlists/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -38,8 +40,8 @@ func (p *clientlists) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { p.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = "Failed to unmarshal error body" - e.Detail = err.Error() + e.Title = "Failed to unmarshal error body. Client Lists API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/clientlists/errors_test.go b/pkg/clientlists/errors_test.go new file mode 100644 index 00000000..ca5015d7 --- /dev/null +++ b/pkg/clientlists/errors_test.go @@ -0,0 +1,77 @@ +package clientlists + +import ( + "context" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" + "github.com/stretchr/testify/require" + + "github.com/tj/assert" +) + +func TestJsonErrorsUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Client Lists API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Client Lists API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Client Lists API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + c := clientlists{ + Session: sess, + } + assert.Equal(t, test.expected, c.Error(test.input)) + }) + } +} diff --git a/pkg/cloudlets/errors.go b/pkg/cloudlets/errors.go index 55166290..942c44ad 100644 --- a/pkg/cloudlets/errors.go +++ b/pkg/cloudlets/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -40,7 +42,8 @@ func (c *cloudlets) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { c.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = string(body) + e.Title = "Failed to unmarshal error body. Cloudlets API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/cloudlets/errors_test.go b/pkg/cloudlets/errors_test.go index 505f5602..11d4d711 100644 --- a/pkg/cloudlets/errors_test.go +++ b/pkg/cloudlets/errors_test.go @@ -1,6 +1,7 @@ package cloudlets import ( + "context" "encoding/json" "io/ioutil" "net/http" @@ -52,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "test", - Detail: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -101,3 +102,66 @@ func TestAs(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + c := cloudlets{ + Session: sess, + } + assert.Equal(t, test.expected, c.Error(test.input)) + }) + } +} diff --git a/pkg/cloudlets/policy_version_activation_test.go b/pkg/cloudlets/policy_version_activation_test.go index c19ff24c..e9e1a8e3 100644 --- a/pkg/cloudlets/policy_version_activation_test.go +++ b/pkg/cloudlets/policy_version_activation_test.go @@ -112,7 +112,7 @@ func TestListPolicyActivations(t *testing.T) { }, responseStatus: http.StatusNotFound, uri: "/cloudlets/api/v2/policies/1234/activations?network=staging&propertyName=www.rc-cloudlet.com", - withError: &Error{StatusCode: 404}, + withError: &Error{StatusCode: 404, Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information."}, }, "500 server error": { parameters: ListPolicyActivationsRequest{ @@ -122,7 +122,7 @@ func TestListPolicyActivations(t *testing.T) { }, responseStatus: http.StatusInternalServerError, uri: "/cloudlets/api/v2/policies/1234/activations?network=staging&propertyName=www.rc-cloudlet.com", - withError: &Error{StatusCode: 500}, + withError: &Error{StatusCode: 500, Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information."}, }, } for name, test := range tests { diff --git a/pkg/cloudlets/v3/cloudlets.go b/pkg/cloudlets/v3/cloudlets.go new file mode 100644 index 00000000..8f14eee9 --- /dev/null +++ b/pkg/cloudlets/v3/cloudlets.go @@ -0,0 +1,126 @@ +// Package v3 provides access to the Akamai Cloudlets V3 APIs +package v3 + +import ( + "context" + "errors" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" +) + +var ( + // ErrStructValidation is returned when given struct validation failed. + ErrStructValidation = errors.New("struct validation") +) + +type ( + // Cloudlets is the api interface for cloudlets. + Cloudlets interface { + // ListCloudlets returns details of Cloudlets that you can create a shared policy for, including a Cloudlet name and Cloudlet type + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-cloudlets + ListCloudlets(context.Context) ([]ListCloudletsItem, error) + + // ListPolicies returns shared policies that are available within your group + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policies + ListPolicies(context.Context, ListPoliciesRequest) (*ListPoliciesResponse, error) + + // CreatePolicy creates a shared policy for a specific Cloudlet type + // + // See: https://techdocs.akamai.com/cloudlets/reference/post-policy + CreatePolicy(context.Context, CreatePolicyRequest) (*Policy, error) + + // DeletePolicy deletes an existing Cloudlets policy + // + // See: https://techdocs.akamai.com/cloudlets/reference/delete-policy + DeletePolicy(context.Context, DeletePolicyRequest) error + + // GetPolicy returns information about a shared policy, including its activation status on the staging and production networks + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policy + GetPolicy(context.Context, GetPolicyRequest) (*Policy, error) + + // UpdatePolicy updates an existing policy + // + // See: https://techdocs.akamai.com/cloudlets/reference/put-policy + UpdatePolicy(context.Context, UpdatePolicyRequest) (*Policy, error) + + // ClonePolicy clones the staging, production, and last modified versions of a non-shared (API v2) or shared policy into a new shared policy + // + // See: https://techdocs.akamai.com/cloudlets/reference/post-policy-clone + ClonePolicy(context.Context, ClonePolicyRequest) (*Policy, error) + + // ListActivePolicyProperties returns all active properties that are assigned to the policy + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policy-properties + ListActivePolicyProperties(context.Context, ListActivePolicyPropertiesRequest) (*ListActivePolicyPropertiesResponse, error) + + // ListPolicyVersions lists policy versions by policyID. + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policy-versions + ListPolicyVersions(context.Context, ListPolicyVersionsRequest) (*ListPolicyVersions, error) + + // GetPolicyVersion gets policy version by policyID and version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policy-version + GetPolicyVersion(context.Context, GetPolicyVersionRequest) (*PolicyVersion, error) + + // CreatePolicyVersion creates policy version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/post-policy-version + CreatePolicyVersion(context.Context, CreatePolicyVersionRequest) (*PolicyVersion, error) + + // DeletePolicyVersion deletes policy version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/delete-policy-version + DeletePolicyVersion(context.Context, DeletePolicyVersionRequest) error + + // UpdatePolicyVersion updates policy version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/put-policy-version + UpdatePolicyVersion(context.Context, UpdatePolicyVersionRequest) (*PolicyVersion, error) + + // ListPolicyActivations returns the complete activation history for the selected policy in a reverse chronological order. + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policy-activations + ListPolicyActivations(context.Context, ListPolicyActivationsRequest) (*PolicyActivations, error) + + // ActivatePolicy initiates the activation of the selected cloudlet policy version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/post-policy-activations + ActivatePolicy(context.Context, ActivatePolicyRequest) (*PolicyActivation, error) + + // DeactivatePolicy initiates the deactivation of the selected cloudlet policy version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/post-policy-activations + DeactivatePolicy(context.Context, DeactivatePolicyRequest) (*PolicyActivation, error) + + // GetPolicyActivation activates the selected cloudlet policy version. + // + // See: https://techdocs.akamai.com/cloudlets/reference/get-policy-activation + GetPolicyActivation(context.Context, GetPolicyActivationRequest) (*PolicyActivation, error) + } + + cloudlets struct { + session.Session + } + + // Option defines a Cloudlets option. + Option func(*cloudlets) + + // ClientFunc is a Cloudlets client new method, this can be used for mocking. + ClientFunc func(sess session.Session, opts ...Option) Cloudlets +) + +// Client returns a new cloudlets Client instance with the specified controller. +func Client(sess session.Session, opts ...Option) Cloudlets { + c := &cloudlets{ + Session: sess, + } + + for _, opt := range opts { + opt(c) + } + return c +} diff --git a/pkg/cloudlets/v3/cloudlets_test.go b/pkg/cloudlets/v3/cloudlets_test.go new file mode 100644 index 00000000..59905a49 --- /dev/null +++ b/pkg/cloudlets/v3/cloudlets_test.go @@ -0,0 +1,62 @@ +package v3 + +import ( + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/edgegrid" + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockAPIClient(t *testing.T, mockServer *httptest.Server) Cloudlets { + serverURL, err := url.Parse(mockServer.URL) + require.NoError(t, err) + certPool := x509.NewCertPool() + certPool.AddCert(mockServer.Certificate()) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + }, + }, + } + s, err := session.New(session.WithClient(httpClient), session.WithSigner(&edgegrid.Config{Host: serverURL.Host})) + require.NoError(t, err) + return Client(s) +} + +func TestClient(t *testing.T) { + sess, err := session.New() + require.NoError(t, err) + tests := map[string]struct { + options []Option + expected *cloudlets + }{ + "no options provided, return default": { + options: nil, + expected: &cloudlets{ + Session: sess, + }, + }, + "option provided, overwrite session": { + options: []Option{func(c *cloudlets) { + c.Session = nil + }}, + expected: &cloudlets{ + Session: nil, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + res := Client(sess, test.options...) + assert.Equal(t, res, test.expected) + }) + } +} diff --git a/pkg/cloudlets/v3/errors.go b/pkg/cloudlets/v3/errors.go new file mode 100644 index 00000000..0d9e9b61 --- /dev/null +++ b/pkg/cloudlets/v3/errors.go @@ -0,0 +1,80 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" +) + +// Error is a cloudlets error interface. +type Error struct { + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Instance string `json:"instance,omitempty"` + Status int `json:"status,omitempty"` + Errors json.RawMessage `json:"errors,omitempty"` + Detail string `json:"detail"` + RequestID string `json:"requestId,omitempty"` + RequestTime string `json:"requestTime,omitempty"` + ClientIP string `json:"clientIp,omitempty"` + ServerIP string `json:"serverIp,omitempty"` + Method string `json:"method,omitempty"` +} + +// ErrPolicyNotFound is returned when policy was not found +var ErrPolicyNotFound = errors.New("policy not found") + +// Error parses an error from the response. +func (c *cloudlets) Error(r *http.Response) error { + var e Error + + var body []byte + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + c.Log(r.Request.Context()).Errorf("reading error response body: %s", err) + e.Status = r.StatusCode + e.Title = "Failed to read error body" + return &e + } + + if err := json.Unmarshal(body, &e); err != nil { + c.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) + e.Title = "Failed to unmarshal error body. Cloudlets API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) + } + + e.Status = r.StatusCode + + return &e +} + +func (e *Error) Error() string { + msg, err := json.MarshalIndent(e, "", "\t") + if err != nil { + return fmt.Sprintf("error marshaling API error: %s", err) + } + return fmt.Sprintf("API error: \n%s", msg) +} + +// Is handles error comparisons. +func (e *Error) Is(target error) bool { + var t *Error + if !errors.As(target, &t) { + return false + } + + if e == t { + return true + } + + if e.Status != t.Status { + return false + } + + return e.Error() == t.Error() +} diff --git a/pkg/cloudlets/v3/errors_test.go b/pkg/cloudlets/v3/errors_test.go new file mode 100644 index 00000000..bd9ba2c7 --- /dev/null +++ b/pkg/cloudlets/v3/errors_test.go @@ -0,0 +1,161 @@ +package v3 + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" + "github.com/stretchr/testify/require" + "github.com/tj/assert" +) + +func TestNewError(t *testing.T) { + sess, err := session.New() + require.NoError(t, err) + + req, err := http.NewRequest( + http.MethodHead, + "/", + nil) + require.NoError(t, err) + + tests := map[string]struct { + response *http.Response + expected *Error + }{ + "valid response, status code 500": { + response: &http.Response{ + Status: "Internal Server Error", + StatusCode: http.StatusInternalServerError, + Body: ioutil.NopCloser(strings.NewReader( + `{"type":"a","title":"b","detail":"c"}`), + ), + Request: req, + }, + expected: &Error{ + Type: "a", + Title: "b", + Detail: "c", + Status: http.StatusInternalServerError, + }, + }, + "invalid response body, assign status code": { + response: &http.Response{ + Status: "Internal Server Error", + StatusCode: http.StatusInternalServerError, + Body: ioutil.NopCloser(strings.NewReader( + `test`), + ), + Request: req, + }, + expected: &Error{ + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "test", + Status: http.StatusInternalServerError, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + res := Client(sess).(*cloudlets).Error(test.response) + assert.Equal(t, test.expected, res) + }) + } +} + +func TestAs(t *testing.T) { + someErrorMarshalled, _ := json.Marshal("some error") + tests := map[string]struct { + err Error + target Error + expected bool + }{ + "different error code": { + err: Error{Status: 404}, + target: Error{Status: 401}, + expected: false, + }, + "same error code": { + err: Error{Status: 404}, + target: Error{Status: 404}, + expected: true, + }, + "same error code and error message": { + err: Error{Status: 404, Errors: someErrorMarshalled}, + target: Error{Status: 404, Errors: someErrorMarshalled}, + expected: true, + }, + "same error code and different error message": { + err: Error{Status: 404, Errors: someErrorMarshalled}, + target: Error{Status: 404}, + expected: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.err.Is(&test.target), test.expected) + }) + } +} + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloudlets API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + as := cloudlets{ + Session: sess, + } + assert.Equal(t, test.expected, as.Error(test.input)) + }) + } +} diff --git a/pkg/cloudlets/v3/list_cloudlets.go b/pkg/cloudlets/v3/list_cloudlets.go new file mode 100644 index 00000000..b0460e22 --- /dev/null +++ b/pkg/cloudlets/v3/list_cloudlets.go @@ -0,0 +1,45 @@ +package v3 + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +type ( + // ListCloudletsItem contains the response data from ListCloudlets operation + ListCloudletsItem struct { + CloudletName string `json:"cloudletName"` + CloudletType CloudletType `json:"cloudletType"` + } +) + +var ( + // ErrListCloudlets is returned when ListCloudlets fails + ErrListCloudlets = errors.New("list cloudlets") +) + +func (c *cloudlets) ListCloudlets(ctx context.Context) ([]ListCloudletsItem, error) { + logger := c.Log(ctx) + logger.Debug("ListCloudlets") + + uri := "/cloudlets/v3/cloudlet-info" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrListCloudlets, err) + } + + var result []ListCloudletsItem + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrListCloudlets, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrListCloudlets, c.Error(resp)) + } + + return result, nil +} diff --git a/pkg/cloudlets/v3/list_cloudlets_test.go b/pkg/cloudlets/v3/list_cloudlets_test.go new file mode 100644 index 00000000..0ed2e40b --- /dev/null +++ b/pkg/cloudlets/v3/list_cloudlets_test.go @@ -0,0 +1,128 @@ +package v3 + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListCloudlets(t *testing.T) { + tests := map[string]struct { + responseStatus int + responseBody string + expectedPath string + expectedResponse []ListCloudletsItem + withError func(*testing.T, error) + }{ + "200 OK": { + responseStatus: http.StatusOK, + responseBody: ` +[ + { + "cloudletName": "API_PRIORITIZATION", + "cloudletType": "AP" + }, + { + "cloudletName": "AUDIENCE_SEGMENTATION", + "cloudletType": "AS" + }, + { + "cloudletName": "EDGE_REDIRECTOR", + "cloudletType": "ER" + }, + { + "cloudletName": "FORWARD_REWRITE", + "cloudletType": "FR" + }, + { + "cloudletName": "PHASED_RELEASE", + "cloudletType": "CD" + }, + { + "cloudletName": "REQUEST_CONTROL", + "cloudletType": "IG" + } +]`, + expectedPath: "/cloudlets/v3/cloudlet-info", + expectedResponse: []ListCloudletsItem{ + { + CloudletName: "API_PRIORITIZATION", + CloudletType: "AP", + }, + { + CloudletName: "AUDIENCE_SEGMENTATION", + CloudletType: "AS", + }, + { + CloudletName: "EDGE_REDIRECTOR", + CloudletType: "ER", + }, + { + CloudletName: "FORWARD_REWRITE", + CloudletType: "FR", + }, + { + CloudletName: "PHASED_RELEASE", + CloudletType: "CD", + }, + { + CloudletName: "REQUEST_CONTROL", + CloudletType: "IG", + }, + }, + }, + "500 Internal Server Error": { + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" +}`, + expectedPath: "/cloudlets/v3/cloudlet-info", + withError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.True(t, errors.Is(err, want), "want: %s; got: %s", want, err) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ListCloudlets(context.Background()) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/cloudlets/v3/match_rule.go b/pkg/cloudlets/v3/match_rule.go new file mode 100644 index 00000000..1ab2a0fd --- /dev/null +++ b/pkg/cloudlets/v3/match_rule.go @@ -0,0 +1,874 @@ +package v3 + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/edgegriderr" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type ( + // MatchRule is base interface for MatchRuleXX + MatchRule interface { + // cloudletType is a private method to ensure that only match rules for supported cloudlets can be used + cloudletType() string + Validate() error + } + + // MatchRules is an array of *MatchRuleXX depending on the cloudletId (9 or 0) of the policy + MatchRules []MatchRule + + // MatchRuleAP represents an API Prioritization (AP) match rule resource for create or update + MatchRuleAP struct { + Name string `json:"name,omitempty"` + Type MatchRuleType `json:"type,omitempty"` + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` + ID int64 `json:"id,omitempty"` + Matches []MatchCriteriaAP `json:"matches,omitempty"` + MatchURL string `json:"matchURL,omitempty"` + PassThroughPercent *float64 `json:"passThroughPercent"` + Disabled bool `json:"disabled,omitempty"` + } + + // MatchRuleAS represents an Application Segmentation (AS) match rule resource for create or update resource + MatchRuleAS struct { + Name string `json:"name,omitempty"` + Type MatchRuleType `json:"type,omitempty"` + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` + ID int64 `json:"id,omitempty"` + Matches []MatchCriteriaAS `json:"matches,omitempty"` + MatchURL string `json:"matchURL,omitempty"` + ForwardSettings ForwardSettingsAS `json:"forwardSettings"` + Disabled bool `json:"disabled,omitempty"` + } + + // ForwardSettingsAS represents forward settings for an Application Segmentation (AS) + ForwardSettingsAS struct { + PathAndQS string `json:"pathAndQS,omitempty"` + UseIncomingQueryString bool `json:"useIncomingQueryString,omitempty"` + OriginID string `json:"originId,omitempty"` + } + + // MatchRulePR represents a Phased Release (PR aka CD) match rule resource for create or update resource + MatchRulePR struct { + Name string `json:"name,omitempty"` + Type MatchRuleType `json:"type,omitempty"` + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` + ID int64 `json:"id,omitempty"` + Matches []MatchCriteriaPR `json:"matches,omitempty"` + MatchURL string `json:"matchURL,omitempty"` + ForwardSettings ForwardSettingsPR `json:"forwardSettings"` + Disabled bool `json:"disabled,omitempty"` + MatchesAlways bool `json:"matchesAlways,omitempty"` + } + + // ForwardSettingsPR represents forward settings for a Phased Release (PR aka CD) + ForwardSettingsPR struct { + OriginID string `json:"originId"` + Percent int `json:"percent"` + } + + // MatchRuleER represents an Edge Redirector (ER) match rule resource for create or update resource + MatchRuleER struct { + Name string `json:"name,omitempty"` + Type MatchRuleType `json:"type,omitempty"` + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` + ID int64 `json:"id,omitempty"` + Matches []MatchCriteriaER `json:"matches,omitempty"` + MatchesAlways bool `json:"matchesAlways,omitempty"` + UseRelativeURL string `json:"useRelativeUrl,omitempty"` + StatusCode int `json:"statusCode"` + RedirectURL string `json:"redirectURL"` + MatchURL string `json:"matchURL,omitempty"` + UseIncomingQueryString bool `json:"useIncomingQueryString"` + UseIncomingSchemeAndHost bool `json:"useIncomingSchemeAndHost"` + Disabled bool `json:"disabled,omitempty"` + } + + // MatchRuleFR represents a Forward Rewrite (FR) match rule resource for create or update resource + MatchRuleFR struct { + Name string `json:"name,omitempty"` + Type MatchRuleType `json:"type,omitempty"` + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` + ID int64 `json:"id,omitempty"` + Matches []MatchCriteriaFR `json:"matches,omitempty"` + MatchURL string `json:"matchURL,omitempty"` + ForwardSettings ForwardSettingsFR `json:"forwardSettings"` + Disabled bool `json:"disabled,omitempty"` + } + + // ForwardSettingsFR represents forward settings for a Forward Rewrite (FR) + ForwardSettingsFR struct { + PathAndQS string `json:"pathAndQS,omitempty"` + UseIncomingQueryString bool `json:"useIncomingQueryString,omitempty"` + OriginID string `json:"originId,omitempty"` + } + + // MatchRuleRC represents a Request Control (RC aka IG) match rule resource for create or update resource + MatchRuleRC struct { + Name string `json:"name,omitempty"` + Type MatchRuleType `json:"type,omitempty"` + Start int64 `json:"start,omitempty"` + End int64 `json:"end,omitempty"` + ID int64 `json:"id,omitempty"` + Matches []MatchCriteriaRC `json:"matches,omitempty"` + MatchesAlways bool `json:"matchesAlways,omitempty"` + AllowDeny AllowDeny `json:"allowDeny"` + Disabled bool `json:"disabled,omitempty"` + } + + // MatchCriteria represents a match criteria resource for match rule for cloudlet + MatchCriteria struct { + MatchType string `json:"matchType,omitempty"` + MatchValue string `json:"matchValue,omitempty"` + MatchOperator MatchOperator `json:"matchOperator,omitempty"` + CaseSensitive bool `json:"caseSensitive"` + Negate bool `json:"negate"` + CheckIPs CheckIPs `json:"checkIPs,omitempty"` + ObjectMatchValue interface{} `json:"objectMatchValue,omitempty"` + } + + // MatchCriteriaAP represents a match criteria resource for match rule for cloudlet API Prioritization (AP) + // ObjectMatchValue can contain ObjectMatchValueObject or ObjectMatchValueSimple + MatchCriteriaAP MatchCriteria + + // MatchCriteriaAS represents a match criteria resource for match rule for cloudlet Application Segmentation (AS) + // ObjectMatchValue can contain ObjectMatchValueObject or ObjectMatchValueSimple or ObjectMatchValueRange + MatchCriteriaAS MatchCriteria + + // MatchCriteriaPR represents a match criteria resource for match rule for cloudlet Phased Release (PR aka CD) + // ObjectMatchValue can contain ObjectMatchValueObject or ObjectMatchValueSimple + MatchCriteriaPR MatchCriteria + + // MatchCriteriaER represents a match criteria resource for match rule for cloudlet Edge Redirector (ER) + // ObjectMatchValue can contain ObjectMatchValueObject or ObjectMatchValueSimple + MatchCriteriaER MatchCriteria + + // MatchCriteriaFR represents a match criteria resource for match rule for cloudlet Forward Rewrite (FR) + // ObjectMatchValue can contain ObjectMatchValueObject or ObjectMatchValueSimple + MatchCriteriaFR MatchCriteria + + // MatchCriteriaRC represents a match criteria resource for match rule for cloudlet Request Control (RC aka IG) + // ObjectMatchValue can contain ObjectMatchValueObject or ObjectMatchValueSimple + MatchCriteriaRC MatchCriteria + + // ObjectMatchValueObject represents an object match value resource for match criteria of type object + ObjectMatchValueObject struct { + Name string `json:"name"` + Type ObjectMatchValueObjectType `json:"type"` + NameCaseSensitive bool `json:"nameCaseSensitive"` + NameHasWildcard bool `json:"nameHasWildcard"` + Options *Options `json:"options,omitempty"` + } + + // ObjectMatchValueSimple represents an object match value resource for match criteria of type simple + ObjectMatchValueSimple struct { + Type ObjectMatchValueSimpleType `json:"type"` + Value []string `json:"value,omitempty"` + } + + // ObjectMatchValueRange represents an object match value resource for match criteria of type range + ObjectMatchValueRange struct { + Type ObjectMatchValueRangeType `json:"type"` + Value []int64 `json:"value,omitempty"` + } + + // Options represents an option resource for ObjectMatchValueObject + Options struct { + Value []string `json:"value,omitempty"` + ValueHasWildcard bool `json:"valueHasWildcard,omitempty"` + ValueCaseSensitive bool `json:"valueCaseSensitive,omitempty"` + ValueEscaped bool `json:"valueEscaped,omitempty"` + } + + //MatchRuleType enum type + MatchRuleType string + // MatchRuleFormat enum type + MatchRuleFormat string + // MatchOperator enum type + MatchOperator string + // AllowDeny enum type + AllowDeny string + // CheckIPs enum type + CheckIPs string + // ObjectMatchValueRangeType enum type + ObjectMatchValueRangeType string + // ObjectMatchValueSimpleType enum type + ObjectMatchValueSimpleType string + // ObjectMatchValueObjectType enum type + ObjectMatchValueObjectType string +) + +const ( + // MatchRuleTypeAP represents rule type for API Prioritization (AP) cloudlets + MatchRuleTypeAP MatchRuleType = "apMatchRule" + // MatchRuleTypeAS represents rule type for Application Segmentation (AS) cloudlets + MatchRuleTypeAS MatchRuleType = "asMatchRule" + // MatchRuleTypePR represents rule type for Phased Release (PR aka CD) cloudlets + MatchRuleTypePR MatchRuleType = "cdMatchRule" + // MatchRuleTypeER represents rule type for Edge Redirector (ER) cloudlets + MatchRuleTypeER MatchRuleType = "erMatchRule" + // MatchRuleTypeFR represents rule type for Forward Rewrite (FR) cloudlets + MatchRuleTypeFR MatchRuleType = "frMatchRule" + // MatchRuleTypeRC represents rule type for Request Control (RC aka IG) cloudlets + MatchRuleTypeRC MatchRuleType = "igMatchRule" + // MatchRuleTypeVP represents rule type for Visitor Prioritization (VP) cloudlets + MatchRuleTypeVP MatchRuleType = "vpMatchRule" +) + +const ( + // MatchRuleFormat10 represents default match rule format + MatchRuleFormat10 MatchRuleFormat = "1.0" +) + +const ( + // MatchOperatorContains represents contains operator + MatchOperatorContains MatchOperator = "contains" + // MatchOperatorExists represents exists operator + MatchOperatorExists MatchOperator = "exists" + // MatchOperatorEquals represents equals operator + MatchOperatorEquals MatchOperator = "equals" +) + +const ( + // Allow represents allow option + Allow AllowDeny = "allow" + // Deny represents deny option + Deny AllowDeny = "deny" + // DenyBranded represents denybranded option + DenyBranded AllowDeny = "denybranded" +) + +const ( + // CheckIPsConnectingIP represents connecting ip option + CheckIPsConnectingIP CheckIPs = "CONNECTING_IP" + // CheckIPsXFFHeaders represents xff headers option + CheckIPsXFFHeaders CheckIPs = "XFF_HEADERS" + // CheckIPsConnectingIPXFFHeaders represents connecting ip + xff headers option + CheckIPsConnectingIPXFFHeaders CheckIPs = "CONNECTING_IP XFF_HEADERS" +) + +const ( + // Range represents range option + Range ObjectMatchValueRangeType = "range" + // Simple represents simple option + Simple ObjectMatchValueSimpleType = "simple" + // Object represents object option + Object ObjectMatchValueObjectType = "object" +) + +var ( + // ErrUnmarshallMatchCriteriaAP is returned when unmarshalling of MatchCriteriaAP fails + ErrUnmarshallMatchCriteriaAP = errors.New("unmarshalling MatchCriteriaAP") + // ErrUnmarshallMatchCriteriaAS is returned when unmarshalling of MatchCriteriaAS fails + ErrUnmarshallMatchCriteriaAS = errors.New("unmarshalling MatchCriteriaAS") + // ErrUnmarshallMatchCriteriaPR is returned when unmarshalling of MatchCriteriaPR fails + ErrUnmarshallMatchCriteriaPR = errors.New("unmarshalling MatchCriteriaPR") + // ErrUnmarshallMatchCriteriaER is returned when unmarshalling of MatchCriteriaER fails + ErrUnmarshallMatchCriteriaER = errors.New("unmarshalling MatchCriteriaER") + // ErrUnmarshallMatchCriteriaFR is returned when unmarshalling of MatchCriteriaFR fails + ErrUnmarshallMatchCriteriaFR = errors.New("unmarshalling MatchCriteriaFR") + // ErrUnmarshallMatchCriteriaRC is returned when unmarshalling of MatchCriteriaRC fails + ErrUnmarshallMatchCriteriaRC = errors.New("unmarshalling MatchCriteriaRC") + // ErrUnmarshallMatchCriteriaVP is returned when unmarshalling of MatchCriteriaVP fails + ErrUnmarshallMatchCriteriaVP = errors.New("unmarshalling MatchCriteriaVP") + // ErrUnmarshallMatchRules is returned when unmarshalling of MatchRules fails + ErrUnmarshallMatchRules = errors.New("unmarshalling MatchRules") +) + +// matchRuleHandlers contains mapping between name of the type for MatchRule and its implementation +// It makes the UnmarshalJSON more compact and easier to support more cloudlet types +var matchRuleHandlers = map[string]func() MatchRule{ + "apMatchRule": func() MatchRule { return &MatchRuleAP{} }, + "asMatchRule": func() MatchRule { return &MatchRuleAS{} }, + "cdMatchRule": func() MatchRule { return &MatchRulePR{} }, + "erMatchRule": func() MatchRule { return &MatchRuleER{} }, + "frMatchRule": func() MatchRule { return &MatchRuleFR{} }, + "igMatchRule": func() MatchRule { return &MatchRuleRC{} }, +} + +// objectOrRangeOrSimpleMatchValueHandlers contains mapping between name of the type for ObjectMatchValue and its implementation +// It makes the UnmarshalJSON more compact and easier to support more types +var objectOrRangeOrSimpleMatchValueHandlers = map[string]func() interface{}{ + "object": func() interface{} { return &ObjectMatchValueObject{} }, + "range": func() interface{} { return &ObjectMatchValueRange{} }, + "simple": func() interface{} { return &ObjectMatchValueSimple{} }, +} + +// simpleObjectMatchValueHandlers contains mapping between name of the types (simple or object) for ObjectMatchValue and their implementations +// It makes the UnmarshalJSON more compact and easier to support more types +var simpleObjectMatchValueHandlers = map[string]func() interface{}{ + "object": func() interface{} { return &ObjectMatchValueObject{} }, + "simple": func() interface{} { return &ObjectMatchValueSimple{} }, +} + +// Validate validates MatchRules +func (m MatchRules) Validate() error { + type matchRules MatchRules + + errs := validation.Errors{ + "MatchRules": validation.Validate(matchRules(m), validation.Length(0, 5000)), + } + return edgegriderr.ParseValidationErrors(errs) +} + +// Validate validates MatchRuleAP +func (m MatchRuleAP) Validate() error { + return validation.Errors{ + "Type": validation.Validate(m.Type, validation.Required, validation.In(MatchRuleTypeAP).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'apMatchRule'", (&m).Type))), + "Name": validation.Validate(m.Name, validation.Length(0, 8192)), + "Start": validation.Validate(m.Start, validation.Min(0)), + "End": validation.Validate(m.End, validation.Min(0)), + "MatchURL": validation.Validate(m.MatchURL, validation.Length(0, 8192)), + "PassThroughPercent": validation.Validate(m.PassThroughPercent, validation.By(passThroughPercentValidation)), + "Matches": validation.Validate(m.Matches), + }.Filter() +} + +// Validate validates MatchRuleAS +func (m MatchRuleAS) Validate() error { + return validation.Errors{ + "Type": validation.Validate(m.Type, validation.Required, validation.In(MatchRuleTypeAS).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'asMatchRule'", (&m).Type))), + "Name": validation.Validate(m.Name, validation.Length(0, 8192)), + "Start": validation.Validate(m.Start, validation.Min(0)), + "End": validation.Validate(m.End, validation.Min(0)), + "MatchURL": validation.Validate(m.MatchURL, validation.Length(0, 8192)), + "Matches": validation.Validate(m.Matches), + "ForwardSettings": validation.Validate(m.ForwardSettings, validation.Required), + "ForwardSettings.PathAndQS": validation.Validate(m.ForwardSettings.PathAndQS, validation.Length(1, 8192)), + "ForwardSettings.OriginID": validation.Validate(m.ForwardSettings.OriginID, validation.Length(0, 8192)), + }.Filter() +} + +// Validate validates MatchRulePR +func (m MatchRulePR) Validate() error { + return validation.Errors{ + "Type": validation.Validate(m.Type, validation.Required, validation.In(MatchRuleTypePR).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'cdMatchRule'", (&m).Type))), + "Name": validation.Validate(m.Name, validation.Length(0, 8192)), + "Start": validation.Validate(m.Start, validation.Min(0)), + "End": validation.Validate(m.End, validation.Min(0)), + "MatchURL": validation.Validate(m.MatchURL, validation.Length(0, 8192)), + "ForwardSettings": validation.Validate(m.ForwardSettings, validation.Required), + "ForwardSettings.OriginID": validation.Validate(m.ForwardSettings.OriginID, validation.Required, validation.Length(0, 8192)), + "ForwardSettings.Percent": validation.Validate(m.ForwardSettings.Percent, validation.Required, validation.Min(1), validation.Max(100)), + "Matches": validation.Validate(m.Matches), + "Matches/MatchesAlways": validation.Validate(len(m.Matches), validation.When(m.MatchesAlways, validation.Empty.Error("only one of [ \"Matches\", \"MatchesAlways\" ] can be specified"))), + }.Filter() +} + +// Validate validates MatchRuleER +func (m MatchRuleER) Validate() error { + return validation.Errors{ + "Type": validation.Validate(m.Type, validation.Required, validation.In(MatchRuleTypeER).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'erMatchRule'", (&m).Type))), + "Name": validation.Validate(m.Name, validation.Length(0, 8192)), + "Start": validation.Validate(m.Start, validation.Min(0)), + "End": validation.Validate(m.End, validation.Min(0)), + "MatchURL": validation.Validate(m.MatchURL, validation.Length(0, 8192)), + "RedirectURL": validation.Validate(m.RedirectURL, validation.Required, validation.Length(1, 8192)), + "UseRelativeURL": validation.Validate(m.UseRelativeURL, validation.In("none", "copy_scheme_hostname", "relative_url").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'none', 'copy_scheme_hostname', 'relative_url' or '' (empty)", (&m).UseRelativeURL))), + "StatusCode": validation.Validate(m.StatusCode, validation.Required, validation.In(301, 302, 303, 307, 308).Error( + fmt.Sprintf("value '%d' is invalid. Must be one of: 301, 302, 303, 307 or 308", (&m).StatusCode))), + "Matches": validation.Validate(m.Matches), + "Matches/MatchesAlways": validation.Validate(len(m.Matches), validation.When(m.MatchesAlways, validation.Empty.Error("only one of [ \"Matches\", \"MatchesAlways\" ] can be specified"))), + }.Filter() +} + +// Validate validates MatchRuleFR +func (m MatchRuleFR) Validate() error { + return validation.Errors{ + "Type": validation.Validate(m.Type, validation.Required, validation.In(MatchRuleTypeFR).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'frMatchRule'", (&m).Type))), + "Name": validation.Validate(m.Name, validation.Length(0, 8192)), + "Start": validation.Validate(m.Start, validation.Min(0)), + "End": validation.Validate(m.End, validation.Min(0)), + "MatchURL": validation.Validate(m.MatchURL, validation.Length(0, 8192)), + "Matches": validation.Validate(m.Matches), + "ForwardSettings": validation.Validate(m.ForwardSettings, validation.Required), + "ForwardSettings.PathAndQS": validation.Validate(m.ForwardSettings.PathAndQS, validation.Length(1, 8192)), + "ForwardSettings.OriginID": validation.Validate(m.ForwardSettings.OriginID, validation.Length(0, 8192)), + }.Filter() +} + +// Validate validates MatchRuleRC +func (m MatchRuleRC) Validate() error { + return validation.Errors{ + "Type": validation.Validate(m.Type, validation.Required, validation.In(MatchRuleTypeRC).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'igMatchRule'", (&m).Type))), + "Name": validation.Validate(m.Name, validation.Length(0, 8192)), + "Start": validation.Validate(m.Start, validation.Min(0)), + "End": validation.Validate(m.End, validation.Min(0)), + "Matches": validation.Validate(m.Matches), + "Matches/MatchesAlways": validation.Validate(len(m.Matches), validation.When(m.MatchesAlways, validation.Empty.Error("only one of [ \"Matches\", \"MatchesAlways\" ] can be specified"))), + "AllowDeny": validation.Validate(m.AllowDeny, validation.Required, validation.In(Allow, Deny, DenyBranded).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: '%s', '%s' or '%s'", (&m).AllowDeny, Allow, Deny, DenyBranded), + )), + }.Filter() +} + +// Validate validates MatchCriteriaAP +func (m MatchCriteriaAP) Validate() error { + return validation.Errors{ + "MatchType": validation.Validate(m.MatchType, validation.In( + "header", "hostname", "path", "extension", "query", "cookie", "deviceCharacteristics", "clientip", + "continent", "countrycode", "regioncode", "protocol", "method", "proxy").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'header', 'hostname', 'path', 'extension', 'query', 'cookie', "+ + "'deviceCharacteristics', 'clientip', 'continent', 'countrycode', 'regioncode', 'protocol', 'method', 'proxy'", (&m).MatchType))), + "MatchValue": validation.Validate(m.MatchValue, validation.Length(1, 8192), validation.Required.When(m.ObjectMatchValue == nil).Error("cannot be blank when ObjectMatchValue is blank"), + validation.Empty.When(m.ObjectMatchValue != nil).Error("must be blank when ObjectMatchValue is set")), + "MatchOperator": validation.Validate(m.MatchOperator, validation.In(MatchOperatorContains, MatchOperatorExists, MatchOperatorEquals).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'contains', 'exists', 'equals' or '' (empty)", (&m).MatchOperator))), + "CheckIPs": validation.Validate(m.CheckIPs, validation.In(CheckIPsConnectingIP, CheckIPsXFFHeaders, CheckIPsConnectingIPXFFHeaders).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'CONNECTING_IP', 'XFF_HEADERS', 'CONNECTING_IP XFF_HEADERS' or '' (empty)", (&m).CheckIPs))), + "ObjectMatchValue": validation.Validate(m.ObjectMatchValue, validation.Required.When(m.MatchValue == "").Error("cannot be blank when MatchValue is blank"), + validation.Empty.When(m.MatchValue != "").Error("must be blank when MatchValue is set"), validation.By(objectMatchValueSimpleOrObjectValidation)), + }.Filter() +} + +// Validate validates MatchCriteriaAS +func (m MatchCriteriaAS) Validate() error { + return validation.Errors{ + "MatchType": validation.Validate(m.MatchType, validation.In("header", "hostname", "path", "extension", "query", "range", + "regex", "cookie", "deviceCharacteristics", "clientip", "continent", "countrycode", "regioncode", "protocol", "method", "proxy").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'header', 'hostname', 'path', 'extension', 'query', 'range', "+ + "'regex', 'cookie', 'deviceCharacteristics', 'clientip', 'continent', 'countrycode', 'regioncode', 'protocol', 'method', 'proxy'", (&m).MatchType))), + "MatchValue": validation.Validate(m.MatchValue, validation.Length(1, 8192), validation.Required.When(m.ObjectMatchValue == nil).Error("cannot be blank when ObjectMatchValue is blank"), + validation.Empty.When(m.ObjectMatchValue != nil).Error("must be blank when ObjectMatchValue is set")), + "MatchOperator": validation.Validate(m.MatchOperator, validation.In(MatchOperatorContains, MatchOperatorExists, MatchOperatorEquals).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'contains', 'exists', 'equals' or '' (empty)", (&m).MatchOperator))), + "CheckIPs": validation.Validate(m.CheckIPs, validation.In(CheckIPsConnectingIP, CheckIPsXFFHeaders, CheckIPsConnectingIPXFFHeaders).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'CONNECTING_IP', 'XFF_HEADERS', 'CONNECTING_IP XFF_HEADERS' or '' (empty)", (&m).CheckIPs))), + "ObjectMatchValue": validation.Validate(m.ObjectMatchValue, validation.Required.When(m.MatchValue == "").Error("cannot be blank when MatchValue is blank"), + validation.Empty.When(m.MatchValue != "").Error("must be blank when MatchValue is set"), validation.By(objectMatchValueSimpleOrRangeOrObjectValidation)), + }.Filter() +} + +// Validate validates MatchCriteriaPR +func (m MatchCriteriaPR) Validate() error { + return validation.Errors{ + "MatchType": validation.Validate(m.MatchType, validation.In("header", "hostname", "path", "extension", + "query", "cookie", "deviceCharacteristics", "clientip", "continent", "countrycode", "regioncode", "protocol", + "method", "proxy").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'header', 'hostname', 'path', 'extension', 'query', 'cookie', "+ + "'deviceCharacteristics', 'clientip', 'continent', 'countrycode', 'regioncode', 'protocol', 'method', 'proxy'", (&m).MatchType))), + "MatchValue": validation.Validate(m.MatchValue, validation.Length(1, 8192), validation.Required.When(m.ObjectMatchValue == nil).Error("cannot be blank when ObjectMatchValue is blank"), + validation.Empty.When(m.ObjectMatchValue != nil).Error("must be blank when ObjectMatchValue is set")), + "MatchOperator": validation.Validate(m.MatchOperator, validation.In(MatchOperatorContains, MatchOperatorExists, MatchOperatorEquals).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'contains', 'exists', 'equals' or '' (empty)", (&m).MatchOperator))), + "CheckIPs": validation.Validate(m.CheckIPs, validation.In(CheckIPsConnectingIP, CheckIPsXFFHeaders, CheckIPsConnectingIPXFFHeaders).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'CONNECTING_IP', 'XFF_HEADERS', 'CONNECTING_IP XFF_HEADERS' or '' (empty)", (&m).CheckIPs))), + "ObjectMatchValue": validation.Validate(m.ObjectMatchValue, validation.Required.When(m.MatchValue == "").Error("cannot be blank when MatchValue is blank"), + validation.Empty.When(m.MatchValue != "").Error("must be blank when MatchValue is set"), validation.By(objectMatchValueSimpleOrObjectValidation)), + }.Filter() +} + +// Validate validates MatchCriteriaER +func (m MatchCriteriaER) Validate() error { + return validation.Errors{ + "MatchType": validation.Validate(m.MatchType, validation.In("header", "hostname", "path", "extension", "query", + "regex", "cookie", "deviceCharacteristics", "clientip", "continent", "countrycode", "regioncode", "protocol", "method", "proxy").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'header', 'hostname', 'path', 'extension', 'query', 'regex', 'cookie', "+ + "'deviceCharacteristics', 'clientip', 'continent', 'countrycode', 'regioncode', 'protocol', 'method', 'proxy' or '' (empty)", (&m).MatchType))), + "MatchValue": validation.Validate(m.MatchValue, validation.Length(1, 8192), validation.Required.When(m.ObjectMatchValue == nil).Error("cannot be blank when ObjectMatchValue is blank"), + validation.Empty.When(m.ObjectMatchValue != nil).Error("must be blank when ObjectMatchValue is set")), + "MatchOperator": validation.Validate(m.MatchOperator, validation.In(MatchOperatorContains, MatchOperatorExists, MatchOperatorEquals).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'contains', 'exists', 'equals' or '' (empty)", (&m).MatchOperator))), + "CheckIPs": validation.Validate(m.CheckIPs, validation.In(CheckIPsConnectingIP, CheckIPsXFFHeaders, CheckIPsConnectingIPXFFHeaders).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'CONNECTING_IP', 'XFF_HEADERS', 'CONNECTING_IP XFF_HEADERS' or '' (empty)", (&m).CheckIPs))), + "ObjectMatchValue": validation.Validate(m.ObjectMatchValue, validation.Required.When(m.MatchValue == "").Error("cannot be blank when MatchValue is blank"), + validation.Empty.When(m.MatchValue != "").Error("must be blank when MatchValue is set"), validation.By(objectMatchValueSimpleOrObjectValidation)), + }.Filter() +} + +// Validate validates MatchCriteriaFR +func (m MatchCriteriaFR) Validate() error { + return validation.Errors{ + "MatchType": validation.Validate(m.MatchType, validation.Required, validation.In("header", "hostname", "path", "extension", "query", "regex", + "cookie", "deviceCharacteristics", "clientip", "continent", "countrycode", "regioncode", "protocol", "method", "proxy").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'header', 'hostname', 'path', 'extension', 'query', 'regex', 'cookie', "+ + "'deviceCharacteristics', 'clientip', 'continent', 'countrycode', 'regioncode', 'protocol', 'method', 'proxy'", (&m).MatchType))), + "MatchValue": validation.Validate(m.MatchValue, validation.Length(1, 8192), validation.Required.When(m.ObjectMatchValue == nil).Error("cannot be blank when ObjectMatchValue is blank"), + validation.Empty.When(m.ObjectMatchValue != nil).Error("must be blank when ObjectMatchValue is set")), + "MatchOperator": validation.Validate(m.MatchOperator, validation.In(MatchOperatorContains, MatchOperatorExists, MatchOperatorEquals).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'contains', 'exists', 'equals' or '' (empty)", (&m).MatchOperator))), + "CheckIPs": validation.Validate(m.CheckIPs, validation.In(CheckIPsConnectingIP, CheckIPsXFFHeaders, CheckIPsConnectingIPXFFHeaders).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'CONNECTING_IP', 'XFF_HEADERS', 'CONNECTING_IP XFF_HEADERS' or '' (empty)", (&m).CheckIPs))), + "ObjectMatchValue": validation.Validate(m.ObjectMatchValue, validation.Required.When(m.MatchValue == "").Error("cannot be blank when MatchValue is blank"), + validation.Empty.When(m.MatchValue != "").Error("must be blank when MatchValue is set"), validation.By(objectMatchValueSimpleOrObjectValidation)), + }.Filter() +} + +// Validate validates MatchCriteriaRC +func (m MatchCriteriaRC) Validate() error { + return validation.Errors{ + "MatchType": validation.Validate(m.MatchType, validation.Required, validation.In("header", "hostname", "path", "extension", "query", "cookie", + "deviceCharacteristics", "clientip", "continent", "countrycode", "regioncode", "protocol", "method", "proxy").Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'header', 'hostname', 'path', 'extension', 'query', 'cookie', 'deviceCharacteristics', "+ + "'clientip', 'continent', 'countrycode', 'regioncode', 'protocol', 'method', 'proxy'", (&m).MatchType))), + "MatchValue": validation.Validate(m.MatchValue, validation.Length(1, 8192), validation.Required.When(m.ObjectMatchValue == nil).Error("cannot be blank when ObjectMatchValue is blank"), + validation.Empty.When(m.ObjectMatchValue != nil).Error("must be blank when ObjectMatchValue is set")), + "MatchOperator": validation.Validate(m.MatchOperator, validation.In(MatchOperatorContains, MatchOperatorExists, MatchOperatorEquals).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'contains', 'exists', 'equals' or '' (empty)", (&m).MatchOperator))), + "CheckIPs": validation.Validate(m.CheckIPs, validation.In(CheckIPsConnectingIP, CheckIPsXFFHeaders, CheckIPsConnectingIPXFFHeaders).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'CONNECTING_IP', 'XFF_HEADERS', 'CONNECTING_IP XFF_HEADERS' or '' (empty)", (&m).CheckIPs))), + "ObjectMatchValue": validation.Validate(m.ObjectMatchValue, validation.Required.When(m.MatchValue == "").Error("cannot be blank when MatchValue is blank"), + validation.Empty.When(m.MatchValue != "").Error("must be blank when MatchValue is set"), validation.By(objectMatchValueSimpleOrObjectValidation)), + }.Filter() +} + +func objectMatchValueSimpleOrObjectValidation(value interface{}) error { + if value == nil { + return nil + } + switch value.(type) { + case *ObjectMatchValueObject, *ObjectMatchValueSimple: + return nil + default: + return fmt.Errorf("type %T is invalid. Must be one of: 'simple' or 'object'", value) + } +} + +func objectMatchValueSimpleOrRangeOrObjectValidation(value interface{}) error { + if value == nil { + return nil + } + switch value.(type) { + case *ObjectMatchValueObject, *ObjectMatchValueSimple, *ObjectMatchValueRange: + return nil + default: + return fmt.Errorf("type %T is invalid. Must be one of: 'simple', 'range' or 'object'", value) + } +} + +func passThroughPercentValidation(value interface{}) error { + v, ok := value.(*float64) + if !ok { + return fmt.Errorf("type %T is invalid. Must be *float64", value) + } + if v == nil { + return fmt.Errorf("cannot be blank") + } + if *v < -1 { + return fmt.Errorf("must be no less than -1") + } + if *v > 100 { + return fmt.Errorf("must be no greater than 100") + } + return nil +} + +// Validate validates ObjectMatchValueRange +func (o ObjectMatchValueRange) Validate() error { + return validation.Errors{ + "Type": validation.Validate(o.Type, validation.In(Range).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'range'", (&o).Type))), + }.Filter() +} + +// Validate validates ObjectMatchValueSimple +func (o ObjectMatchValueSimple) Validate() error { + return validation.Errors{ + "Type": validation.Validate(o.Type, validation.In(Simple).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'simple'", (&o).Type))), + }.Filter() +} + +// Validate validates ObjectMatchValueObject +func (o ObjectMatchValueObject) Validate() error { + return validation.Errors{ + "Name": validation.Validate(o.Name, validation.Required, validation.Length(0, 8192)), + "Type": validation.Validate(o.Type, validation.Required, validation.In(Object).Error( + fmt.Sprintf("value '%s' is invalid. Must be: 'object'", (&o).Type))), + }.Filter() +} + +func (m MatchRuleAP) cloudletType() string { + return "apMatchRule" +} + +func (m MatchRuleAS) cloudletType() string { + return "asMatchRule" +} + +func (m MatchRulePR) cloudletType() string { + return "cdMatchRule" +} + +func (m MatchRuleER) cloudletType() string { + return "erMatchRule" +} + +func (m MatchRuleFR) cloudletType() string { + return "frMatchRule" +} + +func (m MatchRuleRC) cloudletType() string { + return "igMatchRule" +} + +// UnmarshalJSON helps to un-marshall items of MatchRules array as proper instances of or *MatchRuleER +func (m *MatchRules) UnmarshalJSON(b []byte) error { + data := make([]map[string]interface{}, 0) + if err := json.Unmarshal(b, &data); err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchRules, err) + } + for _, matchRule := range data { + cloudletType, ok := matchRule["type"] + if !ok { + return fmt.Errorf("%w: match rule entry should contain 'type' field", ErrUnmarshallMatchRules) + } + cloudletTypeName, ok := cloudletType.(string) + if !ok { + return fmt.Errorf("%w: 'type' field on match rule entry should be a string", ErrUnmarshallMatchRules) + } + byteArr, err := json.Marshal(matchRule) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchRules, err) + } + + matchRuleType, ok := matchRuleHandlers[cloudletTypeName] + if !ok { + return fmt.Errorf("%w: unsupported match rule type: %s", ErrUnmarshallMatchRules, cloudletTypeName) + } + dst := matchRuleType() + err = json.Unmarshal(byteArr, dst) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchRules, err) + } + *m = append(*m, dst) + } + return nil +} + +// UnmarshalJSON helps to un-marshall field ObjectMatchValue of MatchCriteriaAP as proper instance of *ObjectMatchValueObject or *ObjectMatchValueSimple +func (m *MatchCriteriaAP) UnmarshalJSON(b []byte) error { + // matchCriteriaAP is an alias for MatchCriteriaAP for un-marshalling purposes + type matchCriteriaAP MatchCriteriaAP + + // populate common attributes using default json unmarshaler using aliased type + err := json.Unmarshal(b, (*matchCriteriaAP)(m)) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaAP, err) + } + if m.ObjectMatchValue == nil { + return nil + } + + objectMatchValueTypeName, err := getObjectMatchValueType(m.ObjectMatchValue) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaAP, err) + } + + createObjectMatchValue, ok := simpleObjectMatchValueHandlers[objectMatchValueTypeName] + if !ok { + return fmt.Errorf("%w: objectMatchValue has unexpected type: '%s'", ErrUnmarshallMatchCriteriaAP, objectMatchValueTypeName) + } + convertedObjectMatchValue, err := convertObjectMatchValue(m.ObjectMatchValue, createObjectMatchValue()) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaAP, err) + } + m.ObjectMatchValue = convertedObjectMatchValue + + return nil +} + +// UnmarshalJSON helps to un-marshall field ObjectMatchValue of MatchCriteriaAS as proper instance of *ObjectMatchValueObject or *ObjectMatchValueSimple or *ObjectMatchValueRange +func (m *MatchCriteriaAS) UnmarshalJSON(b []byte) error { + // matchCriteriaAS is an alias for MatchCriteriaAS for un-marshalling purposes + type matchCriteriaAS MatchCriteriaAS + + // populate common attributes using default json unmarshaler using aliased type + err := json.Unmarshal(b, (*matchCriteriaAS)(m)) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaAS, err) + } + if m.ObjectMatchValue == nil { + return nil + } + + objectMatchValueTypeName, err := getObjectMatchValueType(m.ObjectMatchValue) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaAS, err) + } + + createObjectMatchValue, ok := objectOrRangeOrSimpleMatchValueHandlers[objectMatchValueTypeName] + if !ok { + return fmt.Errorf("%w: objectMatchValue has unexpected type: '%s'", ErrUnmarshallMatchCriteriaAS, objectMatchValueTypeName) + } + convertedObjectMatchValue, err := convertObjectMatchValue(m.ObjectMatchValue, createObjectMatchValue()) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaAS, err) + } + m.ObjectMatchValue = convertedObjectMatchValue + + return nil +} + +// UnmarshalJSON helps to un-marshall field ObjectMatchValue of MatchCriteriaPR as proper instance of *ObjectMatchValueObject or *ObjectMatchValueSimple +func (m *MatchCriteriaPR) UnmarshalJSON(b []byte) error { + // matchCriteriaPR is an alias for MatchCriteriaPR for un-marshalling purposes + type matchCriteriaPR MatchCriteriaPR + + // populate common attributes using default json unmarshaler using aliased type + err := json.Unmarshal(b, (*matchCriteriaPR)(m)) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaPR, err) + } + if m.ObjectMatchValue == nil { + return nil + } + + objectMatchValueTypeName, err := getObjectMatchValueType(m.ObjectMatchValue) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaPR, err) + } + + createObjectMatchValue, ok := simpleObjectMatchValueHandlers[objectMatchValueTypeName] + if !ok { + return fmt.Errorf("%w: objectMatchValue has unexpected type: '%s'", ErrUnmarshallMatchCriteriaPR, objectMatchValueTypeName) + } + convertedObjectMatchValue, err := convertObjectMatchValue(m.ObjectMatchValue, createObjectMatchValue()) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaPR, err) + } + m.ObjectMatchValue = convertedObjectMatchValue + + return nil +} + +// UnmarshalJSON helps to un-marshall field ObjectMatchValue of MatchCriteriaER as proper instance of *ObjectMatchValueObject or *ObjectMatchValueSimple +func (m *MatchCriteriaER) UnmarshalJSON(b []byte) error { + // matchCriteriaER is an alias for MatchCriteriaER for un-marshalling purposes + type matchCriteriaER MatchCriteriaER + + // populate common attributes using default json unmarshaler using aliased type + err := json.Unmarshal(b, (*matchCriteriaER)(m)) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaER, err) + } + if m.ObjectMatchValue == nil { + return nil + } + + objectMatchValueTypeName, err := getObjectMatchValueType(m.ObjectMatchValue) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaER, err) + } + + createObjectMatchValue, ok := simpleObjectMatchValueHandlers[objectMatchValueTypeName] + if !ok { + return fmt.Errorf("%w: objectMatchValue has unexpected type: '%s'", ErrUnmarshallMatchCriteriaER, objectMatchValueTypeName) + } + convertedObjectMatchValue, err := convertObjectMatchValue(m.ObjectMatchValue, createObjectMatchValue()) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaER, err) + } + m.ObjectMatchValue = convertedObjectMatchValue + + return nil +} + +// UnmarshalJSON helps to un-marshall field ObjectMatchValue of MatchCriteriaFR as proper instance of *ObjectMatchValueObject or *ObjectMatchValueSimple +func (m *MatchCriteriaFR) UnmarshalJSON(b []byte) error { + // matchCriteriaFR is an alias for MatchCriteriaFR for un-marshalling purposes + type matchCriteriaFR MatchCriteriaFR + + // populate common attributes using default json unmarshaler using aliased type + err := json.Unmarshal(b, (*matchCriteriaFR)(m)) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaFR, err) + } + if m.ObjectMatchValue == nil { + return nil + } + + objectMatchValueTypeName, err := getObjectMatchValueType(m.ObjectMatchValue) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaFR, err) + } + + createObjectMatchValue, ok := simpleObjectMatchValueHandlers[objectMatchValueTypeName] + if !ok { + return fmt.Errorf("%w: objectMatchValue has unexpected type: '%s'", ErrUnmarshallMatchCriteriaFR, objectMatchValueTypeName) + } + convertedObjectMatchValue, err := convertObjectMatchValue(m.ObjectMatchValue, createObjectMatchValue()) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaFR, err) + } + m.ObjectMatchValue = convertedObjectMatchValue + + return nil +} + +// UnmarshalJSON helps to un-marshall field ObjectMatchValue of MatchCriteriaRC as proper instance of *ObjectMatchValueObject or *ObjectMatchValueSimple +func (m *MatchCriteriaRC) UnmarshalJSON(b []byte) error { + // matchCriteriaRC is an alias for MatchCriteriaRC for un-marshalling purposes + type matchCriteriaRC MatchCriteriaRC + + // populate common attributes using default json unmarshaler using aliased type + err := json.Unmarshal(b, (*matchCriteriaRC)(m)) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaRC, err) + } + if m.ObjectMatchValue == nil { + return nil + } + + objectMatchValueTypeName, err := getObjectMatchValueType(m.ObjectMatchValue) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaRC, err) + } + + createObjectMatchValue, ok := simpleObjectMatchValueHandlers[objectMatchValueTypeName] + if !ok { + return fmt.Errorf("%w: objectMatchValue has unexpected type: '%s'", ErrUnmarshallMatchCriteriaRC, objectMatchValueTypeName) + } + convertedObjectMatchValue, err := convertObjectMatchValue(m.ObjectMatchValue, createObjectMatchValue()) + if err != nil { + return fmt.Errorf("%w: %s", ErrUnmarshallMatchCriteriaRC, err) + } + m.ObjectMatchValue = convertedObjectMatchValue + + return nil +} + +func getObjectMatchValueType(omv interface{}) (string, error) { + objectMatchValueMap, ok := omv.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("structure of objectMatchValue should be 'map', but was '%T'", omv) + } + objectMatchValueType, ok := objectMatchValueMap["type"] + if !ok { + return "", fmt.Errorf("objectMatchValue should contain 'type' field") + } + objectMatchValueTypeName, ok := objectMatchValueType.(string) + if !ok { + return "", fmt.Errorf("'type' should be a string") + } + return objectMatchValueTypeName, nil +} + +func convertObjectMatchValue(in, out interface{}) (interface{}, error) { + marshal, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("%s", err) + } + err = json.Unmarshal(marshal, out) + if err != nil { + return nil, fmt.Errorf("%s", err) + } + + return out, nil +} diff --git a/pkg/cloudlets/v3/match_rule_test.go b/pkg/cloudlets/v3/match_rule_test.go new file mode 100644 index 00000000..2f44d941 --- /dev/null +++ b/pkg/cloudlets/v3/match_rule_test.go @@ -0,0 +1,1101 @@ +package v3 + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tj/assert" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/tools" +) + +func TestUnmarshalJSONMatchRules(t *testing.T) { + tests := map[string]struct { + withError error + responseBody string + expectedObject MatchRules + }{ + "invalid MatchRuleXX": { + responseBody: ` + [ + { + "type": "xxMatchRule" + } + ] +`, + withError: errors.New("unmarshalling MatchRules: unsupported match rule type: xxMatchRule"), + }, + + "invalid type": { + withError: errors.New("unmarshalling MatchRules: 'type' field on match rule entry should be a string"), + responseBody: ` + [ + { + "type": 1 + } + ] +`, + }, + + "invalid JSON": { + withError: errors.New("unexpected end of JSON input"), + responseBody: ` + [ + { + "type": "erMatchRule" + } + +`, + }, + + "missing type": { + withError: errors.New("unmarshalling MatchRules: match rule entry should contain 'type' field"), + responseBody: ` + [ + { + } + ] +`, + }, + + "invalid objectMatchValue type for PR - range": { + withError: errors.New("unmarshalling MatchRules: unmarshalling MatchCriteriaPR: objectMatchValue has unexpected type: 'range'"), + responseBody: ` + [ + { + "type": "cdMatchRule", + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 50 + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + }, + + "invalid objectMatchValue type for ER - range": { + withError: errors.New("unmarshalling MatchRules: unmarshalling MatchCriteriaER: objectMatchValue has unexpected type: 'range'"), + responseBody: ` + [ + { + "type": "erMatchRule", + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 50 + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + }, + + "invalid objectMatchValue type for FR - range": { + withError: errors.New("unmarshalling MatchRules: unmarshalling MatchCriteriaFR: objectMatchValue has unexpected type: 'range'"), + responseBody: ` + [ + { + "type": "frMatchRule", + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 50 + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + }, + + "valid MatchRulePR": { + responseBody: ` + [ + { + "type": "cdMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "forwardSettings": { + "originId": "fr_test_krk_dc2", + "percent": 62 + }, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "protocol", + "matchValue": "https", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + expectedObject: MatchRules{ + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Matches: []MatchCriteriaPR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "protocol", + MatchValue: "https", + Negate: false, + }, + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + Name: "Rule3", + Start: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "fr_test_krk_dc2", + Percent: 62, + }, + }, + }, + }, + + "valid MatchRuleFR": { + responseBody: ` + [ + { + "type": "frMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "forwardSettings": {}, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "protocol", + "matchValue": "https", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + expectedObject: MatchRules{ + &MatchRuleFR{ + Type: "frMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Matches: []MatchCriteriaFR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "protocol", + MatchValue: "https", + Negate: false, + }, + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + Name: "Rule3", + Start: 0, + }, + }, + }, + + "invalid objectMatchValue type for AP - range": { + withError: errors.New("unmarshalling MatchRules: unmarshalling MatchCriteriaAP: objectMatchValue has unexpected type: 'range'"), + responseBody: ` + [ + { + "type": "apMatchRule", + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 50 + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + }, + + "valid MatchRuleAP": { + responseBody: ` + [ + { + "type": "apMatchRule", + "end": 0, + "passThroughPercent": 50.50, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "protocol", + "matchValue": "https", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + expectedObject: MatchRules{ + &MatchRuleAP{ + Type: "apMatchRule", + End: 0, + PassThroughPercent: tools.Float64Ptr(50.50), + ID: 0, + MatchURL: "", + Matches: []MatchCriteriaAP{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "protocol", + MatchValue: "https", + Negate: false, + }, + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + Name: "Rule3", + Start: 0, + }, + }, + }, + "valid MatchRuleAS": { + responseBody: ` + [ + { + "name": "rule 10", + "type": "asMatchRule", + "matchURL": "http://source.com/test1", + + "forwardSettings": { + "originId": "origin_remote_1", + "pathAndQS": "/cpaths/test1.html" + }, + + "matches": [ + { + "matchType": "range", + "objectMatchValue": { + "type": "range", + "value": [ 1, 100 ] + }, + "matchOperator": "equals", + "negate": false, + "caseSensitive": false + }, + { + "matchType": "header", + "objectMatchValue": { + "options": { + "value": [ "en" ] + }, + "type": "object", + "name": "Accept-Charset" + }, + "matchOperator": "equals", + "negate": false, + "caseSensitive": false + } + ] + } + ]`, + expectedObject: MatchRules{ + &MatchRuleAS{ + Name: "rule 10", + Type: "asMatchRule", + MatchURL: "http://source.com/test1", + ForwardSettings: ForwardSettingsAS{ + OriginID: "origin_remote_1", + PathAndQS: "/cpaths/test1.html", + }, + Matches: []MatchCriteriaAS{ + { + MatchType: "range", + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 100}, + }, + MatchOperator: "equals", + CaseSensitive: false, + Negate: false, + }, + { + MatchType: "header", + ObjectMatchValue: &ObjectMatchValueObject{ + Name: "Accept-Charset", + Type: "object", + Options: &Options{ + Value: []string{"en"}, + }, + }, + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + }, + }, + }, + + "valid MatchRuleRC": { + responseBody: ` + [ + { + "type": "igMatchRule", + "end": 0, + "allowDeny": "allow", + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "protocol", + "matchValue": "https", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ]`, + expectedObject: MatchRules{ + &MatchRuleRC{ + Name: "Rule3", + Type: "igMatchRule", + AllowDeny: Allow, + Matches: []MatchCriteriaRC{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "protocol", + MatchValue: "https", + Negate: false, + }, + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + + "invalid objectMatchValue type for RC - range": { + withError: errors.New("unmarshalling MatchRules: unmarshalling MatchCriteriaRC: objectMatchValue has unexpected type: 'range'"), + responseBody: ` + [ + { + "type": "igMatchRule", + "allowDeny": "allow", + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 50 + ] + } + } + ], + "name": "Rule3", + "start": 0 + } + ] +`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var matchRules MatchRules + err := json.Unmarshal([]byte(test.responseBody), &matchRules) + + if test.withError != nil { + assert.Equal(t, test.withError.Error(), err.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedObject, matchRules) + }) + } +} + +func TestGetObjectMatchValueType(t *testing.T) { + tests := map[string]struct { + withError error + input interface{} + expected string + }{ + "success getting objectMatchValue type": { + input: map[string]interface{}{ + "type": "range", + "value": []int{1, 50}, + }, + expected: "range", + }, + "error getting objectMatchValue type - invalid type": { + withError: errors.New("structure of objectMatchValue should be 'map', but was 'string'"), + input: "stringType", + }, + "error getting objectMatchValue type - missing type": { + withError: errors.New("objectMatchValue should contain 'type' field"), + input: map[string]interface{}{ + "value": []int{1, 50}, + }, + }, + "error getting objectMatchValue type - type not string": { + withError: errors.New("'type' should be a string"), + input: map[string]interface{}{ + "type": 50, + "value": []int{1, 50}, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + objectMatchValueType, err := getObjectMatchValueType(test.input) + + if test.withError != nil { + assert.Equal(t, test.withError.Error(), err.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, test.expected, objectMatchValueType) + }) + } +} + +func TestConvertObjectMatchValue(t *testing.T) { + tests := map[string]struct { + withError bool + input map[string]interface{} + output interface{} + expected interface{} + }{ + "success converting objectMatchValueRange": { + input: map[string]interface{}{ + "type": "range", + "value": []int{1, 50}, + }, + output: &ObjectMatchValueRange{}, + expected: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 50}, + }, + }, + "success converting objectMatchValueSimple": { + input: map[string]interface{}{ + "type": "simple", + "value": []string{"GET"}, + }, + output: &ObjectMatchValueSimple{}, + expected: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + "success converting objectMatchValueObject": { + input: map[string]interface{}{ + "type": "object", + "name": "ER", + "options": map[string]interface{}{ + "value": []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + "valueHasWildcard": true, + }, + }, + output: &ObjectMatchValueObject{}, + expected: &ObjectMatchValueObject{ + Type: "object", + Name: "ER", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + "error converting objectMatchValue": { + withError: true, + input: map[string]interface{}{ + "type": "range", + "value": []int{1, 50}, + }, + output: &ObjectMatchValueSimple{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + convertedObjectMatchValue, err := convertObjectMatchValue(test.input, test.output) + + if test.withError == true { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expected, convertedObjectMatchValue) + }) + } +} + +func TestValidateMatchRules(t *testing.T) { + tests := map[string]struct { + input MatchRules + withError string + }{ + "valid match rules AP": { + input: MatchRules{ + MatchRuleAP{ + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(-1), + }, + MatchRuleAP{ + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(50.5), + }, + MatchRuleAP{ + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(0), + }, + MatchRuleAP{ + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(100), + }, + }, + }, + "invalid match rules AP": { + input: MatchRules{ + MatchRuleAP{ + Type: "matchRule", + }, + MatchRuleAP{ + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(100.1), + }, + MatchRuleAP{ + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(-1.1), + }, + }, + withError: ` +MatchRules[0]: { + PassThroughPercent: cannot be blank + Type: value 'matchRule' is invalid. Must be: 'apMatchRule' +} +MatchRules[1]: { + PassThroughPercent: must be no greater than 100 +} +MatchRules[2]: { + PassThroughPercent: must be no less than -1 +}`, + }, + "valid match rules AS": { + input: MatchRules{ + MatchRuleAS{ + Type: "asMatchRule", + Start: 0, + End: 1, + }, + MatchRuleAS{ + Type: "asMatchRule", + ForwardSettings: ForwardSettingsAS{ + PathAndQS: "something", + OriginID: "something_else", + }, + }, + }, + }, + "invalid match rules AS": { + input: MatchRules{ + MatchRuleAS{ + Type: "matchRule", + }, + MatchRuleAS{ + Type: "asMatchRule", + Start: -2, + End: -1, + ForwardSettings: ForwardSettingsAS{ + OriginID: "some_id", + }, + }, + }, + + withError: ` +MatchRules[0]: { + Type: value 'matchRule' is invalid. Must be: 'asMatchRule' +} +MatchRules[1]: { + End: must be no less than 0 + Start: must be no less than 0 +}`, + }, + "valid match rules CD": { + input: MatchRules{ + MatchRulePR{ + Type: "cdMatchRule", + ForwardSettings: ForwardSettingsPR{ + OriginID: "testOriginID", + Percent: 100, + }, + }, + MatchRulePR{ + Type: "cdMatchRule", + ForwardSettings: ForwardSettingsPR{ + OriginID: "testOriginID", + Percent: 1, + }, + }, + }, + }, + "invalid match rules CD": { + input: MatchRules{ + MatchRulePR{ + Type: "matchRule", + }, + MatchRulePR{ + Type: "cdMatchRule", + ForwardSettings: ForwardSettingsPR{}, + }, + MatchRulePR{ + Type: "cdMatchRule", + ForwardSettings: ForwardSettingsPR{ + OriginID: "testOriginID", + Percent: 101, + }, + }, + MatchRulePR{ + Type: "cdMatchRule", + ForwardSettings: ForwardSettingsPR{ + OriginID: "testOriginID", + Percent: -1, + }, + }, + MatchRulePR{ + Type: "cdMatchRule", + ForwardSettings: ForwardSettingsPR{ + OriginID: "testOriginID", + Percent: 0, + }, + }, + }, + withError: ` +MatchRules[0]: { + ForwardSettings.OriginID: cannot be blank + ForwardSettings.Percent: cannot be blank + Type: value 'matchRule' is invalid. Must be: 'cdMatchRule' +} +MatchRules[1]: { + ForwardSettings.OriginID: cannot be blank + ForwardSettings.Percent: cannot be blank +} +MatchRules[2]: { + ForwardSettings.Percent: must be no greater than 100 +} +MatchRules[3]: { + ForwardSettings.Percent: must be no less than 1 +} +MatchRules[4]: { + ForwardSettings.Percent: cannot be blank +}`, + }, + "valid match rules ER": { + input: MatchRules{ + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + UseRelativeURL: "none", + StatusCode: 301, + }, + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + StatusCode: 301, + }, + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + MatchesAlways: true, + StatusCode: 301, + }, + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + Matches: []MatchCriteriaER{ + { + MatchValue: "asd", + }, + }, + StatusCode: 301, + }, + }, + }, + "invalid match rules ER": { + input: MatchRules{ + MatchRuleER{ + Type: "matchRule", + }, + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + UseRelativeURL: "test", + StatusCode: 404, + }, + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + UseRelativeURL: "none", + StatusCode: 301, + MatchesAlways: true, + Matches: []MatchCriteriaER{ + { + MatchValue: "asd", + }, + }, + }, + }, + withError: ` +MatchRules[0]: { + RedirectURL: cannot be blank + StatusCode: cannot be blank + Type: value 'matchRule' is invalid. Must be: 'erMatchRule' +} +MatchRules[1]: { + StatusCode: value '404' is invalid. Must be one of: 301, 302, 303, 307 or 308 + UseRelativeURL: value 'test' is invalid. Must be one of: 'none', 'copy_scheme_hostname', 'relative_url' or '' (empty) +} +MatchRules[2]: { + Matches/MatchesAlways: only one of [ "Matches", "MatchesAlways" ] can be specified +}`, + }, + "valid match rules FR": { + input: MatchRules{ + MatchRuleFR{ + Type: "frMatchRule", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "test", + OriginID: "testOriginID", + }, + }, + MatchRuleFR{ + Type: "frMatchRule", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "test", + OriginID: "testOriginID", + }, + }, + }, + }, + "invalid match rules FR": { + input: MatchRules{ + MatchRuleFR{ + Type: "matchRule", + }, + MatchRuleFR{ + Type: "frMatchRule", + ForwardSettings: ForwardSettingsFR{ + OriginID: "testOriginID", + PathAndQS: "", + }, + }, + }, + withError: ` +MatchRules[0]: { + Type: value 'matchRule' is invalid. Must be: 'frMatchRule' +}`, + }, + "valid match rules RC": { + input: MatchRules{ + MatchRuleRC{ + Type: "igMatchRule", + AllowDeny: Allow, + }, + MatchRuleRC{ + Type: "igMatchRule", + AllowDeny: Deny, + }, + MatchRuleRC{ + Type: "igMatchRule", + AllowDeny: DenyBranded, + }, + }, + }, + "invalid match rules RC": { + input: MatchRules{ + MatchRuleRC{ + Type: "invalidMatchRule", + }, + MatchRuleRC{ + Type: "igMatchRule", + AllowDeny: "allowBranded", + }, + MatchRuleRC{ + Type: "igMatchRule", + AllowDeny: Allow, + MatchesAlways: true, + Matches: []MatchCriteriaRC{ + { + CaseSensitive: false, + CheckIPs: "CONNECTING_IP", + MatchOperator: "equals", + MatchType: "clientip", + MatchValue: "1.2.3.4", + Negate: false, + }, + }, + }, + }, + withError: ` +MatchRules[0]: { + AllowDeny: cannot be blank + Type: value 'invalidMatchRule' is invalid. Must be: 'igMatchRule' +} +MatchRules[1]: { + AllowDeny: value 'allowBranded' is invalid. Must be one of: 'allow', 'deny' or 'denybranded' +} +MatchRules[2]: { + Matches/MatchesAlways: only one of [ "Matches", "MatchesAlways" ] can be specified +}`, + }, + "valid match criteria - matchValue": { + input: MatchRules{ + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + StatusCode: 301, + Matches: []MatchCriteriaER{ + { + MatchType: "method", + MatchOperator: "equals", + CheckIPs: "CONNECTING_IP", + MatchValue: "https", + }, + }, + }, + }, + }, + "valid match criteria - object match value": { + input: MatchRules{ + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + StatusCode: 301, + Matches: []MatchCriteriaER{ + { + MatchType: "header", + MatchOperator: "equals", + CheckIPs: "CONNECTING_IP", + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{ + "GET", + }, + }, + }, + }, + }, + }, + }, + "invalid match criteria - matchValue and omv combinations": { + input: MatchRules{ + MatchRuleER{ + Type: "erMatchRule", + RedirectURL: "abc.com", + StatusCode: 301, + Matches: []MatchCriteriaER{ + { + MatchType: "header", + MatchOperator: "equals", + CheckIPs: "CONNECTING_IP", + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{ + "GET", + }, + }, + MatchValue: "GET", + }, + { + MatchType: "header", + MatchOperator: "equals", + CheckIPs: "CONNECTING_IP", + }, + }, + }, + }, + withError: ` +MatchRules[0]: { + Matches[0]: { + MatchValue: must be blank when ObjectMatchValue is set + ObjectMatchValue: must be blank when MatchValue is set + } + Matches[1]: { + MatchValue: cannot be blank when ObjectMatchValue is blank + ObjectMatchValue: cannot be blank when MatchValue is blank + } +}`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := test.input.Validate() + if test.withError != "" { + require.Error(t, err) + assert.Equal(t, strings.TrimPrefix(test.withError, "\n"), err.Error()) + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/cloudlets/v3/mocks.go b/pkg/cloudlets/v3/mocks.go new file mode 100644 index 00000000..92f53df0 --- /dev/null +++ b/pkg/cloudlets/v3/mocks.go @@ -0,0 +1,145 @@ +//revive:disable:exported + +package v3 + +import ( + "context" + + "github.com/stretchr/testify/mock" +) + +type Mock struct { + mock.Mock +} + +var _ Cloudlets = &Mock{} + +func (m *Mock) ListActivePolicyProperties(ctx context.Context, req ListActivePolicyPropertiesRequest) (*ListActivePolicyPropertiesResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ListActivePolicyPropertiesResponse), args.Error(1) +} + +func (m *Mock) ListPolicyActivations(ctx context.Context, req ListPolicyActivationsRequest) (*PolicyActivations, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyActivations), args.Error(1) +} + +func (m *Mock) ActivatePolicy(ctx context.Context, req ActivatePolicyRequest) (*PolicyActivation, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyActivation), args.Error(1) +} + +func (m *Mock) DeactivatePolicy(ctx context.Context, req DeactivatePolicyRequest) (*PolicyActivation, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyActivation), args.Error(1) +} + +func (m *Mock) GetPolicyActivation(ctx context.Context, req GetPolicyActivationRequest) (*PolicyActivation, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyActivation), args.Error(1) +} + +func (m *Mock) ListPolicies(ctx context.Context, req ListPoliciesRequest) (*ListPoliciesResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ListPoliciesResponse), args.Error(1) +} + +func (m *Mock) CreatePolicy(ctx context.Context, req CreatePolicyRequest) (*Policy, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Policy), args.Error(1) +} + +func (m *Mock) DeletePolicy(ctx context.Context, req DeletePolicyRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + +func (m *Mock) GetPolicy(ctx context.Context, req GetPolicyRequest) (*Policy, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Policy), args.Error(1) +} + +func (m *Mock) UpdatePolicy(ctx context.Context, req UpdatePolicyRequest) (*Policy, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Policy), args.Error(1) +} + +func (m *Mock) ClonePolicy(ctx context.Context, req ClonePolicyRequest) (*Policy, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Policy), args.Error(1) +} + +func (m *Mock) CreatePolicyVersion(ctx context.Context, req CreatePolicyVersionRequest) (*PolicyVersion, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyVersion), args.Error(1) +} + +func (m *Mock) DeletePolicyVersion(ctx context.Context, req DeletePolicyVersionRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + +func (m *Mock) GetPolicyVersion(ctx context.Context, req GetPolicyVersionRequest) (*PolicyVersion, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyVersion), args.Error(1) +} + +func (m *Mock) ListPolicyVersions(ctx context.Context, req ListPolicyVersionsRequest) (*ListPolicyVersions, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*ListPolicyVersions), args.Error(1) +} + +func (m *Mock) UpdatePolicyVersion(ctx context.Context, req UpdatePolicyVersionRequest) (*PolicyVersion, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*PolicyVersion), args.Error(1) +} + +func (m *Mock) ListCloudlets(ctx context.Context) ([]ListCloudletsItem, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]ListCloudletsItem), args.Error(1) +} diff --git a/pkg/cloudlets/v3/policy.go b/pkg/cloudlets/v3/policy.go new file mode 100644 index 00000000..bdc860cf --- /dev/null +++ b/pkg/cloudlets/v3/policy.go @@ -0,0 +1,393 @@ +package v3 + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "time" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/edgegriderr" + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type ( + // ListPoliciesRequest contains request parameters for ListPolicies + ListPoliciesRequest struct { + Page int + Size int + } + + // CreatePolicyRequest contains request parameters for CreatePolicy + CreatePolicyRequest struct { + CloudletType CloudletType `json:"cloudletType"` + Description *string `json:"description,omitempty"` + GroupID int64 `json:"groupId"` + Name string `json:"name"` + PolicyType PolicyType `json:"policyType,omitempty"` + } + + // DeletePolicyRequest contains request parameters for DeletePolicy + DeletePolicyRequest struct { + PolicyID int64 + } + + // GetPolicyRequest contains request parameters for GetPolicy + GetPolicyRequest struct { + PolicyID int64 + } + + // UpdatePolicyRequest contains request parameters for UpdatePolicy + UpdatePolicyRequest struct { + PolicyID int64 + BodyParams UpdatePolicyBodyParams + } + + // ClonePolicyRequest contains request parameters for ClonePolicy + ClonePolicyRequest struct { + PolicyID int64 + BodyParams ClonePolicyBodyParams + } + + // ClonePolicyBodyParams contains request body parameters used in ClonePolicy operation + // GroupID is required only when cloning v2 + ClonePolicyBodyParams struct { + AdditionalVersions []int64 `json:"additionalVersions,omitempty"` + GroupID int64 `json:"groupId,omitempty"` + NewName string `json:"newName"` + } + + // UpdatePolicyBodyParams contains request body parameters used in UpdatePolicy operation + UpdatePolicyBodyParams struct { + GroupID int64 `json:"groupId"` + Description *string `json:"description,omitempty"` + } + + // PolicyType represents the type of the policy + PolicyType string + + // CloudletType represents the type of the cloudlet + CloudletType string + + // ListPoliciesResponse contains the response data from ListPolicies operation + ListPoliciesResponse struct { + Content []Policy `json:"content"` + Links []Link `json:"links"` + Page Page `json:"page"` + } + + // Policy contains information about shared policy + Policy struct { + CloudletType CloudletType `json:"cloudletType"` + CreatedBy string `json:"createdBy"` + CreatedDate time.Time `json:"createdDate"` + CurrentActivations CurrentActivations `json:"currentActivations"` + Description *string `json:"description"` + GroupID int64 `json:"groupId"` + ID int64 `json:"id"` + Links []Link `json:"links"` + ModifiedBy string `json:"modifiedBy"` + ModifiedDate *time.Time `json:"modifiedDate,omitempty"` + Name string `json:"name"` + PolicyType PolicyType `json:"policyType"` + } + + // CurrentActivations contains information about the active policy version that's currently in use and the status of the most recent activation + // or deactivation operation on the policy's versions for the production and staging networks + CurrentActivations struct { + Production ActivationInfo `json:"production"` + Staging ActivationInfo `json:"staging"` + } + + // ActivationInfo contains information about effective and latest activations + ActivationInfo struct { + Effective *PolicyActivation `json:"effective"` + Latest *PolicyActivation `json:"latest"` + } +) + +const ( + // PolicyTypeShared represents policy of type SHARED + PolicyTypeShared = PolicyType("SHARED") + // CloudletTypeAP represents cloudlet of type AP + CloudletTypeAP = CloudletType("AP") + // CloudletTypeAS represents cloudlet of type AS + CloudletTypeAS = CloudletType("AS") + // CloudletTypeCD represents cloudlet of type CD + CloudletTypeCD = CloudletType("CD") + // CloudletTypeER represents cloudlet of type ER + CloudletTypeER = CloudletType("ER") + // CloudletTypeFR represents cloudlet of type FR + CloudletTypeFR = CloudletType("FR") + // CloudletTypeIG represents cloudlet of type IG + CloudletTypeIG = CloudletType("IG") +) + +var ( + // ErrListPolicies is returned when ListPolicies fails + ErrListPolicies = errors.New("list shared policies") + // ErrCreatePolicy is returned when CreatePolicy fails + ErrCreatePolicy = errors.New("create shared policy") + // ErrDeletePolicy is returned when DeletePolicy fails + ErrDeletePolicy = errors.New("delete shared policy") + // ErrGetPolicy is returned when GetPolicy fails + ErrGetPolicy = errors.New("get shared policy") + // ErrUpdatePolicy is returned when UpdatePolicy fails + ErrUpdatePolicy = errors.New("update shared policy") + // ErrClonePolicy is returned when ClonePolicy fails + ErrClonePolicy = errors.New("clone policy") +) + +// Validate validates ListPoliciesRequest +func (r ListPoliciesRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "Page": validation.Validate(r.Page, validation.Min(0)), + "Size": validation.Validate(r.Size, validation.Min(10)), + }) +} + +// Validate validates CreatePolicyRequest +func (r CreatePolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "CloudletType": validation.Validate(r.CloudletType, validation.Required, validation.In(CloudletTypeAP, CloudletTypeAS, CloudletTypeCD, CloudletTypeER, CloudletTypeFR, CloudletTypeIG). + Error(fmt.Sprintf("value '%s' is invalid. Must be one of: '%s', '%s', '%s', '%s', '%s', '%s'", r.CloudletType, CloudletTypeAP, CloudletTypeAS, CloudletTypeCD, CloudletTypeER, CloudletTypeFR, CloudletTypeIG))), + "Name": validation.Validate(r.Name, validation.Required, validation.Length(0, 64), validation.Match(regexp.MustCompile("^[a-z_A-Z0-9]+$")). + Error(fmt.Sprintf("value '%s' is invalid. Must be of format: ^[a-z_A-Z0-9]+$", r.Name))), + "GroupID": validation.Validate(r.GroupID, validation.Required), + "Description": validation.Validate(r.Description, validation.Length(0, 255)), + "PolicyType": validation.Validate(r.PolicyType, validation.In(PolicyTypeShared).Error(fmt.Sprintf("value '%s' is invalid. Must be '%s'", r.PolicyType, PolicyTypeShared))), + }) +} + +// Validate validates DeletePolicyRequest +func (r DeletePolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + }) +} + +// Validate validates GetPolicyRequest +func (r GetPolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + }) +} + +// Validate validates UpdatePolicyRequest +func (r UpdatePolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "BodyParams": validation.Validate(r.BodyParams, validation.Required), + }) +} + +// Validate validates UpdatePolicyBodyParams +func (b UpdatePolicyBodyParams) Validate() error { + return validation.Errors{ + "GroupID": validation.Validate(b.GroupID, validation.Required), + "Description": validation.Validate(b.Description, validation.Length(0, 255)), + }.Filter() +} + +// Validate validates ClonePolicyRequest +func (r ClonePolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "BodyParams": validation.Validate(r.BodyParams, validation.Required), + }) +} + +// Validate validates ClonePolicyBodyParams +func (b ClonePolicyBodyParams) Validate() error { + return validation.Errors{ + "NewName": validation.Validate(b.NewName, validation.Required, validation.Length(0, 64), validation.Match(regexp.MustCompile("^[a-z_A-Z0-9]+$")). + Error(fmt.Sprintf("value '%s' is invalid. Must be of format: ^[a-z_A-Z0-9]+$", b.NewName))), + }.Filter() +} + +func (c *cloudlets) ListPolicies(ctx context.Context, params ListPoliciesRequest) (*ListPoliciesResponse, error) { + logger := c.Log(ctx) + logger.Debug("ListPolicies") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrListPolicies, ErrStructValidation, err) + } + + uri, err := url.Parse("/cloudlets/v3/policies") + if err != nil { + return nil, fmt.Errorf("%w: failed to parse url: %s", ErrListPolicies, err) + } + + q := uri.Query() + if params.Size != 0 { + q.Add("size", strconv.Itoa(params.Size)) + } + if params.Page != 0 { + q.Add("page", strconv.Itoa(params.Page)) + } + + uri.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrListPolicies, err) + } + + var result ListPoliciesResponse + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrListPolicies, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrListPolicies, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) CreatePolicy(ctx context.Context, params CreatePolicyRequest) (*Policy, error) { + logger := c.Log(ctx) + logger.Debug("CreatePolicy") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrCreatePolicy, ErrStructValidation, err) + } + + uri := "/cloudlets/v3/policies" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrCreatePolicy, err) + } + + var result Policy + resp, err := c.Exec(req, &result, params) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrCreatePolicy, err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("%s: %w", ErrCreatePolicy, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) DeletePolicy(ctx context.Context, params DeletePolicyRequest) error { + logger := c.Log(ctx) + logger.Debug("DeletePolicy") + + if err := params.Validate(); err != nil { + return fmt.Errorf("%s: %w: %s", ErrDeletePolicy, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d", params.PolicyID) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%w: failed to create request: %s", ErrDeletePolicy, err) + } + + resp, err := c.Exec(req, nil) + if err != nil { + return fmt.Errorf("%w: request failed: %s", ErrDeletePolicy, err) + } + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("%s: %w", ErrDeletePolicy, c.Error(resp)) + } + + return nil +} + +func (c *cloudlets) GetPolicy(ctx context.Context, params GetPolicyRequest) (*Policy, error) { + logger := c.Log(ctx) + logger.Debug("GetPolicy") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrGetPolicy, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d", params.PolicyID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrGetPolicy, err) + } + + var result Policy + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrGetPolicy, err) + } + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("%s: %w: %s", ErrGetPolicy, ErrPolicyNotFound, c.Error(resp)) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrGetPolicy, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) UpdatePolicy(ctx context.Context, params UpdatePolicyRequest) (*Policy, error) { + logger := c.Log(ctx) + logger.Debug("UpdatePolicy") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrUpdatePolicy, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d", params.PolicyID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrUpdatePolicy, err) + } + + var result Policy + resp, err := c.Exec(req, &result, params.BodyParams) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrUpdatePolicy, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrUpdatePolicy, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) ClonePolicy(ctx context.Context, params ClonePolicyRequest) (*Policy, error) { + logger := c.Log(ctx) + logger.Debug("ClonePolicy") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrClonePolicy, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/clone", params.PolicyID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrClonePolicy, err) + } + + var result Policy + resp, err := c.Exec(req, &result, params.BodyParams) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrClonePolicy, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrClonePolicy, c.Error(resp)) + } + + return &result, nil +} diff --git a/pkg/cloudlets/v3/policy_activation.go b/pkg/cloudlets/v3/policy_activation.go new file mode 100644 index 00000000..fd52e42c --- /dev/null +++ b/pkg/cloudlets/v3/policy_activation.go @@ -0,0 +1,278 @@ +package v3 + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/edgegriderr" + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +// ListPolicyActivationsRequest contains request parameters for ListPolicyActivations. +type ListPolicyActivationsRequest struct { + PolicyID int64 + Page int + Size int +} + +// GetPolicyActivationRequest contains request parameters for GetPolicyActivation. +type GetPolicyActivationRequest struct { + PolicyID int64 + ActivationID int64 +} + +// ActivatePolicyRequest contains request parameters for ActivatePolicy. +type ActivatePolicyRequest struct { + PolicyID int64 + Network Network + PolicyVersion int64 +} + +// DeactivatePolicyRequest contains request parameters for DeactivatePolicy. +type DeactivatePolicyRequest struct { + PolicyID int64 + Network Network + PolicyVersion int64 +} + +type policyActivationRequest struct { + Operation PolicyActivationOperation `json:"operation"` + Network Network `json:"network"` + PolicyVersion int64 `json:"policyVersion"` +} + +// PolicyActivation represents a single policy activation. +type PolicyActivation struct { + CreatedBy string `json:"createdBy"` + CreatedDate time.Time `json:"createdDate"` + FinishDate *time.Time `json:"finishDate"` + ID int64 `json:"id"` + Network Network `json:"network"` + Operation PolicyActivationOperation `json:"operation"` + PolicyID int64 `json:"policyId"` + Status ActivationStatus `json:"status"` + PolicyVersion int64 `json:"policyVersion"` + PolicyVersionDeleted bool `json:"policyVersionDeleted"` + Links []Link `json:"links"` +} + +// PolicyActivations represents the response data from ListPolicyActivations. +type PolicyActivations struct { + Page Page `json:"page"` + PolicyActivations []PolicyActivation `json:"content"` + Links []Link `json:"links"` +} + +// PolicyActivationOperation is an enum for policy activation operation +type PolicyActivationOperation string + +const ( + // OperationActivation represents an operation used for activating a policy + OperationActivation PolicyActivationOperation = "ACTIVATION" + // OperationDeactivation represents an operation used for deactivating a policy + OperationDeactivation PolicyActivationOperation = "DEACTIVATION" +) + +// ActivationStatus represents information about policy activation status. +type ActivationStatus string + +const ( + // ActivationStatusInProgress informs that activation is in progress. + ActivationStatusInProgress ActivationStatus = "IN_PROGRESS" + // ActivationStatusSuccess informs that activation succeeded. + ActivationStatusSuccess ActivationStatus = "SUCCESS" + // ActivationStatusFailed informs that activation failed. + ActivationStatusFailed ActivationStatus = "FAILED" +) + +// Network represents network on which policy version or property can be activated on. +type Network string + +const ( + // StagingNetwork represents staging network. + StagingNetwork Network = "STAGING" + // ProductionNetwork represents production network. + ProductionNetwork Network = "PRODUCTION" +) + +var ( + // ErrListPolicyActivations is returned when ListPolicyActivations fails. + ErrListPolicyActivations = errors.New("list policy activations") + // ErrActivatePolicy is returned when ActivatePolicy fails. + ErrActivatePolicy = errors.New("activate policy") + // ErrDeactivatePolicy is returned when DeactivatePolicy fails. + ErrDeactivatePolicy = errors.New("deactivate policy") + // ErrGetPolicyActivation is returned when GetPolicyActivation fails. + ErrGetPolicyActivation = errors.New("get policy activation") +) + +// Validate validates ListPolicyActivationsRequest. +func (r ListPolicyActivationsRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "Page": validation.Validate(r.Page, validation.Min(0)), + "Size": validation.Validate(r.Size, validation.Min(10)), + }) +} + +// Validate validates GetPolicyActivationRequest. +func (r GetPolicyActivationRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "ActivationID": validation.Validate(r.ActivationID, validation.Required), + }) +} + +// Validate validates ActivatePolicyRequest. +func (r ActivatePolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "PolicyVersion": validation.Validate(r.PolicyVersion, validation.Required), + "Network": validation.Validate(r.Network, validation.Required, validation.In(StagingNetwork, ProductionNetwork).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'STAGING' or 'PRODUCTION'", r.Network))), + }) +} + +// Validate validates DeactivatePolicyRequest. +func (r DeactivatePolicyRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "PolicyVersion": validation.Validate(r.PolicyVersion, validation.Required), + "Network": validation.Validate(r.Network, validation.Required, validation.In(StagingNetwork, ProductionNetwork).Error( + fmt.Sprintf("value '%s' is invalid. Must be one of: 'STAGING' or 'PRODUCTION'", r.Network))), + }) +} + +func (c *cloudlets) ListPolicyActivations(ctx context.Context, params ListPolicyActivationsRequest) (*PolicyActivations, error) { + c.Log(ctx).Debug("ListPolicyActivations") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrListPolicyActivations, ErrStructValidation, err) + } + + uri, err := url.Parse(fmt.Sprintf("/cloudlets/v3/policies/%d/activations", params.PolicyID)) + if err != nil { + return nil, fmt.Errorf("%w: failed to parse url: %s", ErrListPolicyActivations, err) + } + + q := uri.Query() + if params.Size != 0 { + q.Add("size", strconv.Itoa(params.Size)) + } + if params.Page != 0 { + q.Add("page", strconv.Itoa(params.Page)) + } + uri.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrListPolicyActivations, err) + } + + var result PolicyActivations + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrListPolicyActivations, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrListPolicyActivations, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) ActivatePolicy(ctx context.Context, params ActivatePolicyRequest) (*PolicyActivation, error) { + c.Log(ctx).Debug("ActivatePolicy") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrActivatePolicy, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/activations", params.PolicyID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrActivatePolicy, err) + } + + reqBody := policyActivationRequest{ + Network: params.Network, + PolicyVersion: params.PolicyVersion, + Operation: OperationActivation, + } + + var result PolicyActivation + resp, err := c.Exec(req, &result, reqBody) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrActivatePolicy, err) + } + + if resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("%s: %w", ErrActivatePolicy, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) DeactivatePolicy(ctx context.Context, params DeactivatePolicyRequest) (*PolicyActivation, error) { + c.Log(ctx).Debug("DeactivatePolicy") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrDeactivatePolicy, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/activations", params.PolicyID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrDeactivatePolicy, err) + } + + reqBody := policyActivationRequest{ + Network: params.Network, + PolicyVersion: params.PolicyVersion, + Operation: OperationDeactivation, + } + + var result PolicyActivation + resp, err := c.Exec(req, &result, reqBody) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrDeactivatePolicy, err) + } + + if resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("%s: %w", ErrDeactivatePolicy, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) GetPolicyActivation(ctx context.Context, params GetPolicyActivationRequest) (*PolicyActivation, error) { + c.Log(ctx).Debug("GetPolicyActivation") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrGetPolicyActivation, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/activations/%d", params.PolicyID, params.ActivationID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrGetPolicyActivation, err) + } + + var result PolicyActivation + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrGetPolicyActivation, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrGetPolicyActivation, c.Error(resp)) + } + + return &result, nil +} diff --git a/pkg/cloudlets/v3/policy_activation_test.go b/pkg/cloudlets/v3/policy_activation_test.go new file mode 100644 index 00000000..b8a152c3 --- /dev/null +++ b/pkg/cloudlets/v3/policy_activation_test.go @@ -0,0 +1,739 @@ +package v3 + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListPolicyActivations(t *testing.T) { + t.Parallel() + + var policyID int64 = 1234 + + tests := map[string]struct { + params ListPolicyActivationsRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *PolicyActivations + withError bool + assertError func(*testing.T, error) + }{ + "200 OK": { + params: ListPolicyActivationsRequest{ + PolicyID: policyID, + }, + responseStatus: http.StatusOK, + expectedPath: "/cloudlets/v3/policies/1234/activations", + expectedResponse: &PolicyActivations{ + PolicyActivations: []PolicyActivation{ + { + CreatedBy: "testUser", + CreatedDate: *newTimeFromString(t, "2023-10-25T10:33:47.982Z"), + FinishDate: nil, + ID: 234, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations/234", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationDeactivation, + PolicyID: policyID, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusInProgress, + }, + { + CreatedBy: "testUser", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 123, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations/123", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: policyID, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations?page=0&size=1000", + Rel: "self", + }, + }, + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 2, + TotalPages: 1, + }, + }, + responseBody: ` +{ + "content": [ + { + "createdBy": "testUser", + "createdDate": "2023-10-25T10:33:47.982Z", + "finishDate": null, + "id": 234, + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations/234", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "DEACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "IN_PROGRESS" + }, + { + "createdBy": "testUser", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 123, + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations/123", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + ], + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations?page=0&size=1000", + "rel": "self" + } + ], + "page": { + "number": 0, + "size": 1000, + "totalElements": 2, + "totalPages": 1 + } +}`, + }, + "200 OK with query": { + params: ListPolicyActivationsRequest{ + PolicyID: policyID, + Page: 1, + Size: 10, + }, + responseStatus: http.StatusOK, + expectedPath: "/cloudlets/v3/policies/1234/activations?page=1&size=10", + expectedResponse: &PolicyActivations{ + PolicyActivations: []PolicyActivation{}, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations?page=0&size=10", + Rel: "first", + }, + { + Href: "/cloudlets/v3/policies/1234/activations?page=0&size=10", + Rel: "prev", + }, + { + Href: "/cloudlets/v3/policies/1234/activations?page=1&size=10", + Rel: "self", + }, + { + Href: "/cloudlets/v3/policies/1234/activations?page=0&size=10", + Rel: "last", + }, + }, + Page: Page{ + Number: 1, + Size: 10, + TotalElements: 2, + TotalPages: 1, + }, + }, + responseBody: ` +{ + "content": [], + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations?page=0&size=10", + "rel": "first" + }, + { + "href": "/cloudlets/v3/policies/1234/activations?page=0&size=10", + "rel": "prev" + }, + { + "href": "/cloudlets/v3/policies/1234/activations?page=1&size=10", + "rel": "self" + }, + { + "href": "/cloudlets/v3/policies/1234/activations?page=0&size=10", + "rel": "last" + } + ], + "page": { + "number": 1, + "size": 10, + "totalElements": 2, + "totalPages": 1 + } +}`, + }, + "500 Internal Server Error": { + params: ListPolicyActivationsRequest{ + PolicyID: policyID, + }, + responseStatus: http.StatusInternalServerError, + expectedPath: "/cloudlets/v3/policies/1234/activations", + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" +}`, + withError: true, + assertError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.ErrorIs(t, err, want) + }, + }, + "request validation failed": { + params: ListPolicyActivationsRequest{ + Page: -1, + Size: 5, + }, + withError: true, + assertError: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "Page: must be no less than 0") + assert.ErrorContains(t, err, "PolicyID: cannot be blank") + assert.ErrorContains(t, err, "Size: must be no less than 10") + }, + }, + } + + for name, tc := range tests { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(tc.responseStatus) + _, err := w.Write([]byte(tc.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ListPolicyActivations(context.Background(), tc.params) + + if tc.withError { + tc.assertError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse, result) + }) + } +} + +func TestActivatePolicy(t *testing.T) { + t.Parallel() + + var policyID int64 = 1234 + + tests := map[string]struct { + params ActivatePolicyRequest + responseStatus int + responseBody string + expectedPath string + expectedReqBody string + expectedResponse *PolicyActivation + withError bool + assertError func(*testing.T, error) + }{ + "202 Accepted": { + params: ActivatePolicyRequest{ + PolicyID: policyID, + Network: StagingNetwork, + PolicyVersion: 1, + }, + responseStatus: http.StatusAccepted, + expectedPath: "/cloudlets/v3/policies/1234/activations", + expectedResponse: &PolicyActivation{ + CreatedBy: "testUser", + CreatedDate: *newTimeFromString(t, "2023-10-25T10:33:47.982Z"), + FinishDate: nil, + ID: 123, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations/123", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: policyID, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusInProgress, + }, + expectedReqBody: ` +{ + "network": "STAGING", + "policyVersion": 1, + "operation": "ACTIVATION" +}`, + responseBody: ` +{ + "createdBy": "testUser", + "createdDate": "2023-10-25T10:33:47.982Z", + "finishDate": null, + "id": 123, + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations/123", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "IN_PROGRESS" +}`, + }, + "500 Internal Server Error": { + params: ActivatePolicyRequest{ + PolicyID: policyID, + Network: StagingNetwork, + PolicyVersion: 1, + }, + responseStatus: http.StatusInternalServerError, + expectedPath: "/cloudlets/v3/policies/1234/activations", + expectedReqBody: ` +{ + "network": "STAGING", + "policyVersion": 1, + "operation": "ACTIVATION" +}`, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" +}`, + withError: true, + assertError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.ErrorIs(t, err, want) + }, + }, + "request validation failed": { + params: ActivatePolicyRequest{}, + withError: true, + assertError: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "PolicyID: cannot be blank") + assert.ErrorContains(t, err, "PolicyVersion: cannot be blank") + assert.ErrorContains(t, err, "Network: cannot be blank") + }, + }, + } + + for name, tc := range tests { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(tc.responseStatus) + _, err := w.Write([]byte(tc.responseBody)) + assert.NoError(t, err) + if tc.expectedReqBody != "" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.JSONEq(t, tc.expectedReqBody, string(body)) + } + })) + client := mockAPIClient(t, mockServer) + result, err := client.ActivatePolicy(context.Background(), tc.params) + + if tc.withError { + tc.assertError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse, result) + }) + } +} + +func TestDeactivatePolicy(t *testing.T) { + t.Parallel() + + var policyID int64 = 1234 + + tests := map[string]struct { + params DeactivatePolicyRequest + responseStatus int + responseBody string + expectedPath string + expectedReqBody string + expectedResponse *PolicyActivation + withError bool + assertError func(*testing.T, error) + }{ + "202 Accepted": { + params: DeactivatePolicyRequest{ + PolicyID: policyID, + Network: StagingNetwork, + PolicyVersion: 1, + }, + responseStatus: http.StatusAccepted, + expectedPath: "/cloudlets/v3/policies/1234/activations", + expectedResponse: &PolicyActivation{ + CreatedBy: "testUser", + CreatedDate: *newTimeFromString(t, "2023-10-25T10:33:47.982Z"), + FinishDate: nil, + ID: 123, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations/123", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationDeactivation, + PolicyID: policyID, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusInProgress, + }, + expectedReqBody: ` +{ + "network": "STAGING", + "policyVersion": 1, + "operation": "DEACTIVATION" +}`, + responseBody: ` +{ + "createdBy": "testUser", + "createdDate": "2023-10-25T10:33:47.982Z", + "finishDate": null, + "id": 123, + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations/123", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "DEACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "IN_PROGRESS" +}`, + }, + "500 Internal Server Error": { + params: DeactivatePolicyRequest{ + PolicyID: policyID, + Network: ProductionNetwork, + PolicyVersion: 1, + }, + responseStatus: http.StatusInternalServerError, + expectedPath: "/cloudlets/v3/policies/1234/activations", + expectedReqBody: ` +{ + "network": "PRODUCTION", + "policyVersion": 1, + "operation": "DEACTIVATION" +}`, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" +}`, + withError: true, + assertError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.ErrorIs(t, err, want) + }, + }, + "request validation failed": { + params: DeactivatePolicyRequest{ + Network: "OTHER", + }, + withError: true, + assertError: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "PolicyID: cannot be blank") + assert.ErrorContains(t, err, "PolicyVersion: cannot be blank") + assert.ErrorContains(t, err, "Network: value 'OTHER' is invalid. Must be one of: 'STAGING' or 'PRODUCTION'") + }, + }, + } + + for name, tc := range tests { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(tc.responseStatus) + _, err := w.Write([]byte(tc.responseBody)) + assert.NoError(t, err) + if tc.expectedReqBody != "" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.JSONEq(t, tc.expectedReqBody, string(body)) + } + })) + client := mockAPIClient(t, mockServer) + result, err := client.DeactivatePolicy(context.Background(), tc.params) + + if tc.withError { + tc.assertError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse, result) + }) + } +} + +func TestGetPolicyActivation(t *testing.T) { + t.Parallel() + + var policyID int64 = 1234 + var activationID int64 = 123 + + tests := map[string]struct { + params GetPolicyActivationRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *PolicyActivation + withError bool + assertError func(*testing.T, error) + }{ + "200 OK": { + params: GetPolicyActivationRequest{ + PolicyID: policyID, + ActivationID: activationID, + }, + responseStatus: http.StatusOK, + expectedPath: "/cloudlets/v3/policies/1234/activations/123", + expectedResponse: &PolicyActivation{ + CreatedBy: "testUser", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: activationID, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/1234/activations/123", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: policyID, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + responseBody: ` +{ + "createdBy": "testUser", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 123, + "links": [ + { + "href": "/cloudlets/v3/policies/1234/activations/123", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" +}`, + }, + "404 Not Found": { + params: GetPolicyActivationRequest{ + PolicyID: policyID, + ActivationID: 1, + }, + responseStatus: http.StatusNotFound, + expectedPath: "/cloudlets/v3/policies/1234/activations/1", + responseBody: ` +{ + "instance": "testInstance", + "status": 404, + "title": "Not found", + "type": "/cloudlets/v3/error-types/not-found", + "errors": [ + { + "detail": "Activation with id '1' not found.", + "title": "Not found" + } + ] +}`, + withError: true, + assertError: func(t *testing.T, err error) { + want := &Error{ + Type: "/cloudlets/v3/error-types/not-found", + Title: "Not found", + Status: http.StatusNotFound, + Instance: "testInstance", + Errors: json.RawMessage(`[{"detail": "Activation with id '1' not found.", "title": "Not found"}]`), + } + assert.ErrorIs(t, err, want) + }, + }, + "500 Internal Server Error": { + params: GetPolicyActivationRequest{ + PolicyID: policyID, + ActivationID: activationID, + }, + responseStatus: http.StatusInternalServerError, + expectedPath: "/cloudlets/v3/policies/1234/activations/123", + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" +}`, + withError: true, + assertError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.ErrorIs(t, err, want) + }, + }, + "request validation failed": { + params: GetPolicyActivationRequest{}, + withError: true, + assertError: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "PolicyID: cannot be blank") + assert.ErrorContains(t, err, "ActivationID: cannot be blank") + }, + }, + } + + for name, tc := range tests { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(tc.responseStatus) + _, err := w.Write([]byte(tc.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.GetPolicyActivation(context.Background(), tc.params) + + if tc.withError { + tc.assertError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse, result) + }) + } +} + +func newTimeFromString(t *testing.T, s string) *time.Time { + parsedTime, err := time.Parse(time.RFC3339Nano, s) + require.NoError(t, err) + return &parsedTime +} diff --git a/pkg/cloudlets/v3/policy_property.go b/pkg/cloudlets/v3/policy_property.go new file mode 100644 index 00000000..98b9e9d4 --- /dev/null +++ b/pkg/cloudlets/v3/policy_property.go @@ -0,0 +1,106 @@ +package v3 + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/edgegriderr" + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type ( + // ListActivePolicyPropertiesRequest contains request parameters for ListActivePolicyProperties + ListActivePolicyPropertiesRequest struct { + PolicyID int64 + Page int + Size int + } + + // ListActivePolicyPropertiesResponse contains the response data from ListActivePolicyProperties operation. + ListActivePolicyPropertiesResponse struct { + Page Page `json:"page"` + PolicyProperties []ListPolicyPropertiesItem `json:"content"` + Links []Link `json:"links"` + } + + // ListPolicyPropertiesItem represents associated active properties information. + ListPolicyPropertiesItem struct { + GroupID int64 `json:"groupId"` + ID int64 `json:"id"` + Name string `json:"name"` + Network Network `json:"network"` + PolicyVersion int64 `json:"version"` + } + + // Link represents hypermedia link to help navigate through the result set. + Link struct { + Href string `json:"href"` + Rel string `json:"rel"` + } + + // Page contains informational data about pagination. + Page struct { + Number int `json:"number"` + Size int `json:"size"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` + } +) + +var ( + // ErrListActivePolicyProperties is returned when ListActivePolicyProperties fails. + ErrListActivePolicyProperties = errors.New("list active policy properties") +) + +// Validate validates ListActivePolicyPropertiesRequest. +func (r ListActivePolicyPropertiesRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(r.PolicyID, validation.Required), + "Page": validation.Validate(r.Page, validation.Min(0)), + "Size": validation.Validate(r.Size, validation.Min(10)), + }) +} + +func (c *cloudlets) ListActivePolicyProperties(ctx context.Context, params ListActivePolicyPropertiesRequest) (*ListActivePolicyPropertiesResponse, error) { + logger := c.Log(ctx) + logger.Debug("ListActivePolicyProperties") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", ErrListActivePolicyProperties, ErrStructValidation, err) + } + + uri, err := url.Parse(fmt.Sprintf("/cloudlets/v3/policies/%d/properties", params.PolicyID)) + if err != nil { + return nil, fmt.Errorf("%w: failed to parse url: %s", ErrListActivePolicyProperties, err) + } + + q := uri.Query() + if params.Page != 0 { + q.Add("page", strconv.Itoa(params.Page)) + } + if params.Size != 0 { + q.Add("size", strconv.Itoa(params.Size)) + } + uri.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrListActivePolicyProperties, err) + } + + var result ListActivePolicyPropertiesResponse + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrListActivePolicyProperties, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrListActivePolicyProperties, c.Error(resp)) + } + + return &result, nil +} diff --git a/pkg/cloudlets/v3/policy_property_test.go b/pkg/cloudlets/v3/policy_property_test.go new file mode 100644 index 00000000..2d25a747 --- /dev/null +++ b/pkg/cloudlets/v3/policy_property_test.go @@ -0,0 +1,303 @@ +package v3 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ListActivePolicyProperties(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + params ListActivePolicyPropertiesRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *ListActivePolicyPropertiesResponse + withError func(*testing.T, error) + }{ + "200 OK - no query params": { + params: ListActivePolicyPropertiesRequest{ + PolicyID: 5, + }, + responseStatus: http.StatusOK, + responseBody: ` + { + "page": { + "number": 0, + "size": 1000, + "totalElements": 2, + "totalPages": 1 + }, + "content": [ + { + "groupId": 5, + "id": 1234, + "name": "property", + "network": "PRODUCTION", + "version": 1 + }, + { + "groupId": 5, + "id": 1233, + "name": "property", + "network": "STAGING", + "version": 1 + } + ], + "links": [ + { + "href": "/cloudlets/v3/policies/101/properties?page=0&size=1000", + "rel": "self" + } + ] +}`, + expectedPath: "/cloudlets/v3/policies/5/properties", + expectedResponse: &ListActivePolicyPropertiesResponse{ + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 2, + TotalPages: 1, + }, + PolicyProperties: []ListPolicyPropertiesItem{ + { + GroupID: 5, + ID: 1234, + Name: "property", + Network: "PRODUCTION", + PolicyVersion: 1, + }, + { + GroupID: 5, + ID: 1233, + Name: "property", + Network: "STAGING", + PolicyVersion: 1, + }, + }, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/101/properties?page=0&size=1000", + Rel: "self", + }, + }, + }, + }, + "200 OK - with query params": { + params: ListActivePolicyPropertiesRequest{ + PolicyID: 5, + Page: 50, + Size: 1000, + }, + responseStatus: http.StatusOK, + responseBody: ` + { + "page": { + "number": 50, + "size": 1000, + "totalElements": 2, + "totalPages": 1 + }, + "content": [ + { + "groupId": 5, + "id": 1234, + "name": "property", + "network": "PRODUCTION", + "version": 1 + }, + { + "groupId": 5, + "id": 1233, + "name": "property", + "network": "STAGING", + "version": 1 + } + ], + "links": [ + { + "href": "/cloudlets/v3/policies/101/properties?page=50&size=1000", + "rel": "self" + } + ] +}`, + expectedPath: "/cloudlets/v3/policies/5/properties?page=50&size=1000", + expectedResponse: &ListActivePolicyPropertiesResponse{ + Page: Page{ + Number: 50, + Size: 1000, + TotalElements: 2, + TotalPages: 1, + }, + PolicyProperties: []ListPolicyPropertiesItem{ + { + GroupID: 5, + ID: 1234, + Name: "property", + Network: "PRODUCTION", + PolicyVersion: 1, + }, + { + GroupID: 5, + ID: 1233, + Name: "property", + Network: "STAGING", + PolicyVersion: 1, + }, + }, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/101/properties?page=50&size=1000", + Rel: "self", + }, + }, + }, + }, + "200 OK - empty": { + params: ListActivePolicyPropertiesRequest{ + PolicyID: 5, + Page: 0, + Size: 1000, + }, + responseStatus: http.StatusOK, + responseBody: ` + { + "page": { + "number": 0, + "size": 1000, + "totalElements": 2, + "totalPages": 1 + }, + "content": [], + "links": [] +}`, + expectedPath: "/cloudlets/v3/policies/5/properties?size=1000", + expectedResponse: &ListActivePolicyPropertiesResponse{ + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 2, + TotalPages: 1, + }, + PolicyProperties: []ListPolicyPropertiesItem{}, + Links: []Link{}, + }, + }, + "validation errors - missing required params": { + params: ListActivePolicyPropertiesRequest{}, + withError: func(t *testing.T, err error) { + assert.Equal(t, "get policy properties: struct validation: PolicyID: cannot be blank", err.Error()) + }, + }, + "validation errors - size lower than 10, negative page number": { + params: ListActivePolicyPropertiesRequest{ + PolicyID: 1, + Page: -2, + Size: 5, + }, + withError: func(t *testing.T, err error) { + assert.Equal(t, "get policy properties: struct validation: Page: must be no less than 0\nSize: must be no less than 10", err.Error()) + }, + }, + "500 Internal Server Error": { + params: ListActivePolicyPropertiesRequest{ + PolicyID: 1, + Page: 0, + Size: 1000, + }, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" +}`, + expectedPath: "/cloudlets/v3/policies/1/properties?size=1000", + withError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.ErrorIs(t, err, want) + }, + }, + "404 Not found": { + params: ListActivePolicyPropertiesRequest{ + PolicyID: 1, + Page: 0, + Size: 1000, + }, + responseStatus: http.StatusNotFound, + responseBody: ` +{ + "instance": "TestInstance", + "status": 404, + "title": "Not found", + "type": "/cloudlets/v3/error-types/not-found", + "errors": [ + { + "detail": "Policy with id 1 not found.", + "title": "Not found" + } + ] +}`, + expectedPath: "/cloudlets/v3/policies/1/properties?size=1000", + withError: func(t *testing.T, err error) { + want := &Error{ + Type: "/cloudlets/v3/error-types/not-found", + Title: "Not found", + Status: http.StatusNotFound, + Instance: "TestInstance", + Errors: json.RawMessage(` +[ + { + "detail": "Policy with id 1 not found.", + "title": "Not found" + } +]`)} + assert.ErrorIs(t, err, want) + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ListActivePolicyProperties(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/cloudlets/v3/policy_test.go b/pkg/cloudlets/v3/policy_test.go new file mode 100644 index 00000000..aa3ed6d6 --- /dev/null +++ b/pkg/cloudlets/v3/policy_test.go @@ -0,0 +1,1823 @@ +package v3 + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListPolicies(t *testing.T) { + tests := map[string]struct { + params ListPoliciesRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *ListPoliciesResponse + withError func(*testing.T, error) + }{ + "200 OK - two policies, one minimal and one with activation data": { + params: ListPoliciesRequest{}, + responseStatus: http.StatusOK, + responseBody: ` +{ + "content": [ + { + "cloudletType": "CD", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": null, + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User2", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "Name1", + "policyType": "SHARED" + }, + { + "cloudletType": "CD", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 123, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 321, + "links": [ + { + "href": "Link2", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 4321, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + }, + "staging": { + "effective": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 789, + "links": [ + { + "href": "Link3", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 6789, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 987, + "links": [ + { + "href": "Link4", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 9876, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + } + }, + "description": "Test", + "groupId": 1, + "id": 22, + "links": [ + { + "href": "Link5", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" + } + ], + "links": [ + { + "href": "/cloudlets/v3/policies?page=0&size=1000", + "rel": "self" + } + ], + "page": { + "number": 0, + "size": 1000, + "totalElements": 54, + "totalPages": 1 + } +} +`, + expectedPath: "/cloudlets/v3/policies", + expectedResponse: &ListPoliciesResponse{ + Content: []Policy{ + { + CloudletType: CloudletTypeCD, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: nil, + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User2", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "Name1", + PolicyType: PolicyTypeShared, + }, + { + CloudletType: CloudletTypeCD, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{ + Production: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 123, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 1234, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 321, + Links: []Link{ + { + Href: "Link2", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 4321, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + Staging: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 789, + Links: []Link{ + { + Href: "Link3", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 6789, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 987, + Links: []Link{ + { + Href: "Link4", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 9876, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + }, + Description: tools.StringPtr("Test"), + GroupID: 1, + ID: 22, + Links: []Link{ + { + Href: "Link5", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + Links: []Link{ + { + Href: "/cloudlets/v3/policies?page=0&size=1000", + Rel: "self", + }, + }, + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 54, + TotalPages: 1, + }, + }, + }, + "200 OK - with query params": { + params: ListPoliciesRequest{ + Page: 2, + Size: 12, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "content": [ + { + "cloudletType": "CD", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": null, + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User2", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "Name1", + "policyType": "SHARED" + } + ], + "links": [ + { + "href": "/cloudlets/v3/policies?page=0&size=1000", + "rel": "self" + } + ], + "page": { + "number": 0, + "size": 1000, + "totalElements": 54, + "totalPages": 1 + } +} +`, + expectedPath: "/cloudlets/v3/policies?page=2&size=12", + expectedResponse: &ListPoliciesResponse{ + Content: []Policy{ + { + CloudletType: CloudletTypeCD, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: nil, + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User2", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "Name1", + PolicyType: PolicyTypeShared, + }, + }, + Links: []Link{ + { + Href: "/cloudlets/v3/policies?page=0&size=1000", + Rel: "self", + }, + }, + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 54, + TotalPages: 1, + }, + }, + }, + "200 OK - empty content": { + params: ListPoliciesRequest{}, + responseStatus: http.StatusOK, + responseBody: ` +{ + "content": [], + "links": [ + { + "href": "/cloudlets/v3/policies?page=0&size=1000", + "rel": "self" + } + ], + "page": { + "number": 0, + "size": 1000, + "totalElements": 0, + "totalPages": 1 + } +} +`, + expectedPath: "/cloudlets/v3/policies", + expectedResponse: &ListPoliciesResponse{ + Content: []Policy{}, + Links: []Link{ + { + Href: "/cloudlets/v3/policies?page=0&size=1000", + Rel: "self", + }, + }, + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 0, + TotalPages: 1, + }, + }, + }, + "validation errors - size lower than 10, negative page number": { + params: ListPoliciesRequest{ + Page: -2, + Size: 5, + }, + withError: func(t *testing.T, err error) { + assert.Equal(t, "list shared policies: struct validation: Page: must be no less than 0\nSize: must be no less than 10", err.Error()) + }, + }, + "500 Internal Server Error": { + params: ListPoliciesRequest{}, + responseStatus: http.StatusInternalServerError, + responseBody: ` + { + "type": "internal_error", + "title": "Internal Server Error", + "status": 500, + "requestId": "1", + "requestTime": "12:00", + "clientIp": "1.1.1.1", + "serverIp": "2.2.2.2", + "method": "GET" + }`, + expectedPath: "/cloudlets/v3/policies", + withError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + RequestID: "1", + RequestTime: "12:00", + ClientIP: "1.1.1.1", + ServerIP: "2.2.2.2", + Method: "GET", + } + assert.True(t, errors.Is(err, want), "want: %s; got: %s", want, err) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ListPolicies(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestCreatePolicy(t *testing.T) { + tests := map[string]struct { + params CreatePolicyRequest + responseStatus int + responseBody string + expectedPath string + expectedRequestBody string + expectedResponse *Policy + withError func(*testing.T, error) + }{ + "200 OK - minimal data": { + params: CreatePolicyRequest{ + CloudletType: CloudletTypeFR, + GroupID: 1, + Name: "TestName", + }, + responseStatus: http.StatusCreated, + responseBody: ` +{ + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": null, + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" +} +`, + expectedPath: "/cloudlets/v3/policies", + expectedRequestBody: ` +{ + "cloudletType": "FR", + "groupId": 1, + "name": "TestName" +}`, + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: nil, + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "200 OK - all data": { + params: CreatePolicyRequest{ + CloudletType: CloudletTypeFR, + Description: tools.StringPtr("Description"), + GroupID: 1, + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + responseStatus: http.StatusCreated, + responseBody: ` +{ + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": "Description", + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" +} +`, + expectedPath: "/cloudlets/v3/policies", + expectedRequestBody: ` +{ + "cloudletType": "FR", + "description": "Description", + "groupId": 1, + "name": "TestName", + "policyType": "SHARED" +} +`, + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: tools.StringPtr("Description"), + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "validation errors": { + params: CreatePolicyRequest{ + CloudletType: "Wrong Cloudlet Type", + Description: tools.StringPtr(strings.Repeat("Too long description", 20)), + GroupID: 1, + Name: "TestName not match", + PolicyType: "Wrong Policy Type", + }, + withError: func(t *testing.T, err error) { + assert.Equal(t, "create shared policy: struct validation: CloudletType: value 'Wrong Cloudlet Type' is invalid. Must be one of: 'AP', 'AS', 'CD', 'ER', 'FR', 'IG'\nDescription: the length must be no more than 255\nName: value 'TestName not match' is invalid. Must be of format: ^[a-z_A-Z0-9]+$\nPolicyType: value 'Wrong Policy Type' is invalid. Must be 'SHARED'", err.Error()) + }, + }, + "validation errors - missing required params": { + params: CreatePolicyRequest{}, + withError: func(t *testing.T, err error) { + assert.Equal(t, "create shared policy: struct validation: CloudletType: cannot be blank\nGroupID: cannot be blank\nName: cannot be blank", err.Error()) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + if test.expectedRequestBody != "" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.JSONEq(t, test.expectedRequestBody, string(body)) + } + })) + client := mockAPIClient(t, mockServer) + result, err := client.CreatePolicy(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestDeletePolicy(t *testing.T) { + tests := map[string]struct { + params DeletePolicyRequest + responseStatus int + responseBody string + expectedPath string + withError func(*testing.T, error) + }{ + "204": { + params: DeletePolicyRequest{ + PolicyID: 1, + }, + responseStatus: http.StatusNoContent, + expectedPath: "/cloudlets/v3/policies/1", + }, + "validation errors - missing required param": { + params: DeletePolicyRequest{}, + withError: func(t *testing.T, err error) { + assert.Equal(t, "delete shared policy: struct validation: PolicyID: cannot be blank", err.Error()) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + err := client.DeletePolicy(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestGetPolicy(t *testing.T) { + tests := map[string]struct { + params GetPolicyRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *Policy + withError func(*testing.T, error) + }{ + "200 OK - minimal data": { + params: GetPolicyRequest{ + PolicyID: 1, + }, + responseStatus: http.StatusOK, + responseBody: ` + + { + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": null, + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" + } + +`, + + expectedPath: "/cloudlets/v3/policies/1", + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: nil, + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "200 OK - with activation information": { + params: GetPolicyRequest{ + PolicyID: 1, + }, + responseStatus: http.StatusOK, + responseBody: ` + + { + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 123, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 321, + "links": [ + { + "href": "Link2", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 4321, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + }, + "staging": { + "effective": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 789, + "links": [ + { + "href": "Link3", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 6789, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 987, + "links": [ + { + "href": "Link4", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 9876, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + } + }, + "description": "Description", + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" + } + +`, + + expectedPath: "/cloudlets/v3/policies/1", + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{ + Production: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 123, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 1234, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 321, + Links: []Link{ + { + Href: "Link2", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 4321, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + Staging: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 789, + Links: []Link{ + { + Href: "Link3", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 6789, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 987, + Links: []Link{ + { + Href: "Link4", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 9876, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + }, + Description: tools.StringPtr("Description"), + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "200 OK - one network is active": { + params: GetPolicyRequest{ + PolicyID: 1, + }, + responseStatus: http.StatusOK, + responseBody: ` + + { + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 789, + "links": [ + { + "href": "Link3", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 6789, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 987, + "links": [ + { + "href": "Link4", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 9876, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + } + }, + "description": "Description", + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" + } + +`, + + expectedPath: "/cloudlets/v3/policies/1", + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{ + Production: ActivationInfo{ + Effective: nil, + Latest: nil, + }, + Staging: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 789, + Links: []Link{ + { + Href: "Link3", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 6789, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 987, + Links: []Link{ + { + Href: "Link4", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 9876, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + }, + Description: tools.StringPtr("Description"), + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "validation errors - missing required params": { + params: GetPolicyRequest{}, + withError: func(t *testing.T, err error) { + assert.Equal(t, "get shared policy: struct validation: PolicyID: cannot be blank", err.Error()) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.GetPolicy(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestUpdatePolicy(t *testing.T) { + tests := map[string]struct { + params UpdatePolicyRequest + responseStatus int + responseBody string + expectedPath string + expectedRequestBody string + expectedResponse *Policy + withError func(*testing.T, error) + }{ + "200 OK - minimal data": { + params: UpdatePolicyRequest{ + PolicyID: 1, + BodyParams: UpdatePolicyBodyParams{ + GroupID: 11, + }, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": null, + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" +} +`, + expectedPath: "/cloudlets/v3/policies/1", + expectedRequestBody: ` +{ + "groupId": 11 +} +`, + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: nil, + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "200 OK - with description and activations": { + params: UpdatePolicyRequest{ + PolicyID: 1, + BodyParams: UpdatePolicyBodyParams{ + GroupID: 11, + Description: tools.StringPtr("Description"), + }, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 123, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 321, + "links": [ + { + "href": "Link2", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 4321, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + }, + "staging": { + "effective": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 789, + "links": [ + { + "href": "Link3", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 6789, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 987, + "links": [ + { + "href": "Link4", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 9876, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + } + }, + "description": "Description", + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "TestName", + "policyType": "SHARED" +} +`, + expectedPath: "/cloudlets/v3/policies/1", + expectedRequestBody: ` +{ + "groupId": 11, + "description": "Description" +} +`, + expectedResponse: &Policy{ + CloudletType: CloudletTypeFR, + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{ + Production: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 123, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 1234, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 321, + Links: []Link{ + { + Href: "Link2", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 4321, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + Staging: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 789, + Links: []Link{ + { + Href: "Link3", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 6789, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 987, + Links: []Link{ + { + Href: "Link4", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 9876, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + }, + Description: tools.StringPtr("Description"), + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "TestName", + PolicyType: PolicyTypeShared, + }, + }, + "validation errors - missing required params": { + params: UpdatePolicyRequest{}, + withError: func(t *testing.T, err error) { + assert.Equal(t, "update shared policy: struct validation: GroupID: cannot be blank\nPolicyID: cannot be blank", err.Error()) + }, + }, + "validation errors - description too long": { + params: UpdatePolicyRequest{ + PolicyID: 1, + BodyParams: UpdatePolicyBodyParams{ + GroupID: 11, + Description: tools.StringPtr(strings.Repeat("TestDescription", 30)), + }, + }, + withError: func(t *testing.T, err error) { + assert.Equal(t, "update shared policy: struct validation: Description: the length must be no more than 255", err.Error()) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPut, r.Method) + if test.expectedRequestBody != "" { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, test.expectedRequestBody, string(body)) + } + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.UpdatePolicy(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestClonePolicy(t *testing.T) { + tests := map[string]struct { + params ClonePolicyRequest + responseStatus int + responseBody string + expectedPath string + expectedRequestBody string + expectedResponse *Policy + withError func(*testing.T, error) + }{ + "200 OK - minimal data": { + params: ClonePolicyRequest{ + PolicyID: 1, + BodyParams: ClonePolicyBodyParams{ + NewName: "NewName", + }, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": null, + "latest": null + }, + "staging": { + "effective": null, + "latest": null + } + }, + "description": null, + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "NewName", + "policyType": "SHARED" +} +`, + expectedPath: "/cloudlets/v3/policies/1/clone", + expectedRequestBody: ` +{ + "newName": "NewName" +} +`, + expectedResponse: &Policy{ + CloudletType: "FR", + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{}, + Description: nil, + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "NewName", + PolicyType: PolicyTypeShared, + }, + }, + "200 OK - all data": { + params: ClonePolicyRequest{ + PolicyID: 1, + BodyParams: ClonePolicyBodyParams{ + AdditionalVersions: []int64{1, 2}, + GroupID: 11, + NewName: "NewName", + }, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "cloudletType": "FR", + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "currentActivations": { + "production": { + "effective": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 123, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 1234, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User1", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 321, + "links": [ + { + "href": "Link2", + "rel": "self" + } + ], + "network": "PRODUCTION", + "operation": "ACTIVATION", + "policyId": 4321, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + }, + "staging": { + "effective": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 789, + "links": [ + { + "href": "Link3", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 6789, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + }, + "latest": { + "createdBy": "User3", + "createdDate": "2023-10-23T11:21:19.896Z", + "finishDate": "2023-10-23T11:22:57.589Z", + "id": 987, + "links": [ + { + "href": "Link4", + "rel": "self" + } + ], + "network": "STAGING", + "operation": "ACTIVATION", + "policyId": 9876, + "policyVersion": 1, + "policyVersionDeleted": false, + "status": "SUCCESS" + } + } + }, + "description": "Description", + "groupId": 1, + "id": 11, + "links": [ + { + "href": "Link1", + "rel": "self" + } + ], + "modifiedBy": "User1", + "modifiedDate": "2023-10-23T11:21:19.896Z", + "name": "NewName", + "policyType": "SHARED" +} +`, + expectedPath: "/cloudlets/v3/policies/1/clone", + expectedRequestBody: ` +{ + "additionalVersions": [1, 2], + "groupId": 11, + "newName": "NewName" +} +`, + expectedResponse: &Policy{ + CloudletType: "FR", + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + CurrentActivations: CurrentActivations{ + Production: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 123, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 1234, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User1", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 321, + Links: []Link{ + { + Href: "Link2", + Rel: "self", + }, + }, + Network: ProductionNetwork, + Operation: OperationActivation, + PolicyID: 4321, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + Staging: ActivationInfo{ + Effective: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 789, + Links: []Link{ + { + Href: "Link3", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 6789, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + Latest: &PolicyActivation{ + CreatedBy: "User3", + CreatedDate: *newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + FinishDate: newTimeFromString(t, "2023-10-23T11:22:57.589Z"), + ID: 987, + Links: []Link{ + { + Href: "Link4", + Rel: "self", + }, + }, + Network: StagingNetwork, + Operation: OperationActivation, + PolicyID: 9876, + PolicyVersion: 1, + PolicyVersionDeleted: false, + Status: ActivationStatusSuccess, + }, + }, + }, + Description: tools.StringPtr("Description"), + GroupID: 1, + ID: 11, + Links: []Link{ + { + Href: "Link1", + Rel: "self", + }, + }, + ModifiedBy: "User1", + ModifiedDate: newTimeFromString(t, "2023-10-23T11:21:19.896Z"), + Name: "NewName", + PolicyType: PolicyTypeShared, + }, + }, + "validation errors - missing required params": { + params: ClonePolicyRequest{}, + withError: func(t *testing.T, err error) { + assert.Equal(t, "clone policy: struct validation: NewName: cannot be blank\nPolicyID: cannot be blank", err.Error()) + }, + }, + "validation errors - newName too long": { + params: ClonePolicyRequest{ + PolicyID: 1, + BodyParams: ClonePolicyBodyParams{ + GroupID: 11, + NewName: strings.Repeat("TestNameTooLong", 10), + }, + }, + withError: func(t *testing.T, err error) { + assert.Equal(t, "clone policy: struct validation: NewName: the length must be no more than 64", err.Error()) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + + if test.expectedRequestBody != "" { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, test.expectedRequestBody, string(body)) + } + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ClonePolicy(context.Background(), test.params) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/cloudlets/v3/policy_version.go b/pkg/cloudlets/v3/policy_version.go new file mode 100644 index 00000000..17f00661 --- /dev/null +++ b/pkg/cloudlets/v3/policy_version.go @@ -0,0 +1,302 @@ +package v3 + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/edgegriderr" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type ( + // ListPolicyVersions is response returned by ListPolicyVersions + ListPolicyVersions struct { + PolicyVersions []ListPolicyVersionsItem `json:"content"` + Links []Link `json:"links"` + Page Page `json:"page"` + } + + // ListPolicyVersionsItem is a content struct of ListPolicyVersion response + ListPolicyVersionsItem struct { + CreatedBy string `json:"createdBy"` + CreatedDate time.Time `json:"createdDate"` + Description *string `json:"description"` + ID int64 `json:"id"` + Immutable bool `json:"immutable"` + Links []Link `json:"links"` + ModifiedBy string `json:"modifiedBy"` + ModifiedDate *time.Time `json:"modifiedDate,omitempty"` + PolicyID int64 `json:"policyId"` + PolicyVersion int64 `json:"version"` + } + + // PolicyVersion is response returned by GetPolicyVersion, CreatePolicyVersion or UpdatePolicyVersion + PolicyVersion struct { + CreatedBy string `json:"createdBy"` + CreatedDate time.Time `json:"createdDate"` + Description *string `json:"description"` + ID int64 `json:"id"` + Immutable bool `json:"immutable"` + MatchRules MatchRules `json:"matchRules"` + MatchRulesWarnings []MatchRulesWarning `json:"matchRulesWarnings"` + ModifiedBy string `json:"modifiedBy"` + ModifiedDate *time.Time `json:"modifiedDate,omitempty"` + PolicyID int64 `json:"policyId"` + PolicyVersion int64 `json:"version"` + } + + // MatchRulesWarning describes the warnings struct + MatchRulesWarning struct { + Detail string `json:"detail"` + JSONPointer string `json:"jsonPointer,omitempty"` + Title string `json:"title"` + Type string `json:"type"` + } + + // ListPolicyVersionsRequest describes the parameters needed to list policy versions + ListPolicyVersionsRequest struct { + PolicyID int64 + Page int + Size int + } + + // GetPolicyVersionRequest describes the parameters needed to get policy version + GetPolicyVersionRequest struct { + PolicyID int64 + PolicyVersion int64 + } + + // CreatePolicyVersionRequest describes the body of the create policy request + CreatePolicyVersionRequest struct { + CreatePolicyVersion + PolicyID int64 + } + + // CreatePolicyVersion describes the body of the create policy request + CreatePolicyVersion struct { + Description *string `json:"description,omitempty"` + MatchRules MatchRules `json:"matchRules"` + } + + // UpdatePolicyVersion describes the body of the update policy version request + UpdatePolicyVersion struct { + Description *string `json:"description,omitempty"` + MatchRules MatchRules `json:"matchRules"` + } + + // DeletePolicyVersionRequest describes the parameters of the delete policy version request + DeletePolicyVersionRequest struct { + PolicyID int64 + PolicyVersion int64 + } + + // UpdatePolicyVersionRequest describes the parameters of the update policy version request + UpdatePolicyVersionRequest struct { + UpdatePolicyVersion + PolicyID int64 + PolicyVersion int64 + } +) + +// Validate validates ListPolicyVersionsRequest +func (c ListPolicyVersionsRequest) Validate() error { + errs := validation.Errors{ + "PolicyID": validation.Validate(c.PolicyID, validation.Required), + "Size": validation.Validate(c.Size, validation.Min(10)), + "Page": validation.Validate(c.Page, validation.Min(0)), + } + return edgegriderr.ParseValidationErrors(errs) +} + +// Validate validates CreatePolicyVersionRequest +func (c CreatePolicyVersionRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(c.PolicyID, validation.Required), + "Description": validation.Validate(c.Description, validation.Length(0, 255)), + "MatchRules": validation.Validate(c.MatchRules, validation.Length(0, 5000)), + }) +} + +// Validate validates UpdatePolicyVersionRequest +func (c UpdatePolicyVersionRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(c.PolicyID, validation.Required), + "PolicyVersion": validation.Validate(c.PolicyVersion, validation.Required), + "Description": validation.Validate(c.Description, validation.Length(0, 255)), + "MatchRules": validation.Validate(c.MatchRules, validation.Length(0, 5000)), + }) +} + +// Validate validates DeletePolicyVersionRequest +func (c DeletePolicyVersionRequest) Validate() error { + return edgegriderr.ParseValidationErrors(validation.Errors{ + "PolicyID": validation.Validate(c.PolicyID, validation.Required), + "PolicyVersion": validation.Validate(c.PolicyVersion, validation.Required), + }) +} + +var ( + // ErrListPolicyVersions is returned when ListPolicyVersions fails + ErrListPolicyVersions = errors.New("list policy versions") + // ErrGetPolicyVersion is returned when GetPolicyVersion fails + ErrGetPolicyVersion = errors.New("get policy versions") + // ErrCreatePolicyVersion is returned when CreatePolicyVersion fails + ErrCreatePolicyVersion = errors.New("create policy versions") + // ErrDeletePolicyVersion is returned when DeletePolicyVersion fails + ErrDeletePolicyVersion = errors.New("delete policy versions") + // ErrUpdatePolicyVersion is returned when UpdatePolicyVersion fails + ErrUpdatePolicyVersion = errors.New("update policy versions") +) + +func (c *cloudlets) ListPolicyVersions(ctx context.Context, params ListPolicyVersionsRequest) (*ListPolicyVersions, error) { + logger := c.Log(ctx) + logger.Debug("ListPolicyVersions") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w:\n%s", ErrListPolicyVersions, ErrStructValidation, err) + } + + uri, err := url.Parse(fmt.Sprintf("/cloudlets/v3/policies/%d/versions", params.PolicyID)) + if err != nil { + return nil, fmt.Errorf("%w: failed to parse url: %s", ErrListPolicyVersions, err) + } + + q := uri.Query() + q.Add("page", fmt.Sprintf("%d", params.Page)) + if params.Size != 0 { + q.Add("size", fmt.Sprintf("%d", params.Size)) + } + uri.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrListPolicyVersions, err) + } + + var result *ListPolicyVersions + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrListPolicyVersions, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrListPolicyVersions, c.Error(resp)) + } + + return result, nil +} + +func (c *cloudlets) GetPolicyVersion(ctx context.Context, params GetPolicyVersionRequest) (*PolicyVersion, error) { + logger := c.Log(ctx) + logger.Debug("GetPolicyVersion") + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/versions/%d", params.PolicyID, params.PolicyVersion) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrGetPolicyVersion, err) + } + + var result PolicyVersion + + resp, err := c.Exec(req, &result) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrGetPolicyVersion, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrGetPolicyVersion, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) CreatePolicyVersion(ctx context.Context, params CreatePolicyVersionRequest) (*PolicyVersion, error) { + logger := c.Log(ctx) + logger.Debug("CreatePolicyVersion") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w:\n%s", ErrCreatePolicyVersion, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/versions", params.PolicyID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrCreatePolicyVersion, err) + } + + var result PolicyVersion + + resp, err := c.Exec(req, &result, params.CreatePolicyVersion) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrCreatePolicyVersion, err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("%s: %w", ErrCreatePolicyVersion, c.Error(resp)) + } + + return &result, nil +} + +func (c *cloudlets) DeletePolicyVersion(ctx context.Context, params DeletePolicyVersionRequest) error { + logger := c.Log(ctx) + logger.Debug("DeletePolicyVersion") + + if err := params.Validate(); err != nil { + return fmt.Errorf("%s: %w:\n%s", ErrDeletePolicyVersion, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/versions/%d", params.PolicyID, params.PolicyVersion) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%w: failed to create request: %s", ErrDeletePolicyVersion, err) + } + + resp, err := c.Exec(req, nil) + if err != nil { + return fmt.Errorf("%w: request failed: %s", ErrDeletePolicyVersion, err) + } + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("%s: %w", ErrDeletePolicyVersion, c.Error(resp)) + } + + return nil +} + +func (c *cloudlets) UpdatePolicyVersion(ctx context.Context, params UpdatePolicyVersionRequest) (*PolicyVersion, error) { + logger := c.Log(ctx) + logger.Debug("UpdatePolicyVersion") + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w:\n%s", ErrUpdatePolicyVersion, ErrStructValidation, err) + } + + uri := fmt.Sprintf("/cloudlets/v3/policies/%d/versions/%d", params.PolicyID, params.PolicyVersion) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uri, nil) + if err != nil { + return nil, fmt.Errorf("%w: failed to create request: %s", ErrUpdatePolicyVersion, err) + } + + var result PolicyVersion + + resp, err := c.Exec(req, &result, params.UpdatePolicyVersion) + if err != nil { + return nil, fmt.Errorf("%w: request failed: %s", ErrUpdatePolicyVersion, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %w", ErrUpdatePolicyVersion, c.Error(resp)) + } + + return &result, nil +} diff --git a/pkg/cloudlets/v3/policy_version_test.go b/pkg/cloudlets/v3/policy_version_test.go new file mode 100644 index 00000000..35b377de --- /dev/null +++ b/pkg/cloudlets/v3/policy_version_test.go @@ -0,0 +1,3302 @@ +package v3 + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/tools" + "github.com/stretchr/testify/require" + "github.com/tj/assert" +) + +func TestListPolicyVersions(t *testing.T) { + t.Parallel() + tests := map[string]struct { + request ListPolicyVersionsRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *ListPolicyVersions + withError func(*testing.T, error) + }{ + "200 OK": { + request: ListPolicyVersionsRequest{ + PolicyID: 670790, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "content": [ + { + "createdBy": "jsmith", + "createdDate": "2023-10-19T08:50:47.350Z", + "description": null, + "id": 6551184, + "immutable": false, + "links": [], + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "policyId": 670790, + "version": 3 + }, + { + "createdBy": "jsmith", + "createdDate": "2023-10-19T08:50:46.225Z", + "description": null, + "id": 6551183, + "immutable": false, + "links": [], + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:46.225Z", + "policyId": 670790, + "version": 2 + }, + { + "createdBy": "jsmith", + "createdDate": "2023-10-19T08:44:34.398Z", + "description": null, + "id": 6551182, + "immutable": false, + "links": [], + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:44:34.398Z", + "policyId": 670790, + "version": 1 + } + ], + "links": [ + { + "href": "/cloudlets/v3/policies/670790/versions?page=0&size=1000", + "rel": "self" + } + ], + "page": { + "number": 0, + "size": 1000, + "totalElements": 3, + "totalPages": 1 + } +}`, + expectedPath: "/cloudlets/v3/policies/670790/versions?page=0", + expectedResponse: &ListPolicyVersions{ + PolicyVersions: []ListPolicyVersionsItem{ + { + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + Description: nil, + ID: 6551184, + Immutable: false, + Links: []Link{}, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 670790, + PolicyVersion: 3, + }, + { + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:46.225Z"), + Description: nil, + ID: 6551183, + Immutable: false, + Links: []Link{}, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:46.225Z"), + PolicyID: 670790, + PolicyVersion: 2, + }, + { + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T08:44:34.398Z"), + Description: nil, + ID: 6551182, + Immutable: false, + Links: []Link{}, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:44:34.398Z"), + PolicyID: 670790, + PolicyVersion: 1, + }, + }, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/670790/versions?page=0&size=1000", + Rel: "self", + }, + }, + Page: Page{ + Number: 0, + Size: 1000, + TotalElements: 3, + TotalPages: 1, + }, + }, + }, + "200 OK with params": { + request: ListPolicyVersionsRequest{ + PolicyID: 670790, + Page: 4, + Size: 10, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "content": [], + "links": [ + { + "href": "/cloudlets/v3/policies/670790/versions?page=0&size=10", + "rel": "first" + }, + { + "href": "/cloudlets/v3/policies/670790/versions?page=3&size=10", + "rel": "prev" + }, + { + "href": "/cloudlets/v3/policies/670790/versions?page=4&size=10", + "rel": "self" + }, + { + "href": "/cloudlets/v3/policies/670790/versions?page=0&size=10", + "rel": "last" + } + ], + "page": { + "number": 4, + "size": 10, + "totalElements": 3, + "totalPages": 1 + } +}`, + expectedPath: "/cloudlets/v3/policies/670790/versions?page=4&size=10", + expectedResponse: &ListPolicyVersions{ + PolicyVersions: []ListPolicyVersionsItem{}, + Links: []Link{ + { + Href: "/cloudlets/v3/policies/670790/versions?page=0&size=10", + Rel: "first", + }, + { + Href: "/cloudlets/v3/policies/670790/versions?page=3&size=10", + Rel: "prev", + }, + { + Href: "/cloudlets/v3/policies/670790/versions?page=4&size=10", + Rel: "self", + }, + { + Href: "/cloudlets/v3/policies/670790/versions?page=0&size=10", + Rel: "last", + }, + }, + Page: Page{ + Number: 4, + Size: 10, + TotalElements: 3, + TotalPages: 1, + }, + }, + }, + "500 internal server error": { + request: ListPolicyVersionsRequest{ + PolicyID: 284823, + }, + responseStatus: http.StatusInternalServerError, + responseBody: ` + { + "type": "internal_error", + "title": "Internal Server Error", + "status": 500 + }`, + expectedPath: "/cloudlets/v3/policies/284823/versions?page=0", + withError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + } + assert.True(t, errors.Is(err, want), "want: %s; got: %s", want, err) + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ListPolicyVersions(context.Background(), test.request) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestGetPolicyVersion(t *testing.T) { + t.Parallel() + tests := map[string]struct { + request GetPolicyVersionRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *PolicyVersion + withError func(*testing.T, error) + }{ + "200 OK": { + request: GetPolicyVersionRequest{ + PolicyID: 670798, + PolicyVersion: 2, + }, + responseStatus: http.StatusOK, + responseBody: ` + { + "createdBy": "jsmith", + "createdDate": "2023-10-19T09:46:57.395Z", + "description": "test description", + "id": 6551191, + "immutable": false, + "matchRules": [], + "matchRulesWarnings": [], + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T09:46:57.395Z", + "policyId": 670798, + "version": 2 + }`, + expectedPath: "/cloudlets/v3/policies/670798/versions/2", + expectedResponse: &PolicyVersion{ + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + Description: tools.StringPtr("test description"), + ID: 6551191, + MatchRules: nil, + MatchRulesWarnings: []MatchRulesWarning{}, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + PolicyID: 670798, + PolicyVersion: 2, + }, + }, + "200 OK, ER with disabled rule": { + request: GetPolicyVersionRequest{ + PolicyID: 276858, + PolicyVersion: 1, + }, + responseStatus: http.StatusOK, + responseBody: `{ + "createdBy": "jsmith", + "createdDate": "2023-10-19T10:45:30.619Z", + "description": null, + "id": 6551242, + "immutable": false, + "matchRules": [ + { + "type": "erMatchRule", + "id": 0, + "name": "er_rule", + "start": 0, + "end": 0, + "matchURL": null, + "disabled": true, + "matches": [], + "akaRuleId": "6d3bbc891fc0d8ce", + "statusCode": 301, + "redirectURL": "/path", + "useIncomingQueryString": false + } + ], + "matchRulesWarnings": [], + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T10:45:30.619Z", + "policyId": 276858, + "version": 1 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions/1", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T10:45:30.619Z"), + CreatedBy: "jsmith", + Description: nil, + PolicyID: 276858, + PolicyVersion: 1, + ID: 6551242, + MatchRules: MatchRules{ + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "er_rule", + Matches: []MatchCriteriaER{}, + RedirectURL: "/path", + Start: 0, + StatusCode: 301, + UseIncomingQueryString: false, + Disabled: true, + }, + }, + MatchRulesWarnings: []MatchRulesWarning{}, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T10:45:30.619Z"), + }, + }, + "200 OK, AS rule with disabled=false": { + request: GetPolicyVersionRequest{ + PolicyID: 355557, + PolicyVersion: 2, + }, + responseStatus: http.StatusOK, + responseBody: `{ + "createdDate": "2023-10-19T09:46:57.395Z", + "createdBy": "jsmith", + "description": "Initial version", + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T10:45:30.619Z", + "matchRules": [ + { + "type": "asMatchRule", + "akaRuleId": "f58014ee0cc17ce", + "end": 0, + "forwardSettings": { + "originId": "originremote2", + "pathAndQS": "/sales/Q1/", + "useIncomingQueryString": true + }, + "id": 0, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "range", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 25 + ] + } + } + ], + "name": "Q1Sales", + "start": 0 + } + ], + "policyId": 355557, + "version": 2 + }`, + expectedPath: "/cloudlets/v3/policies/355557/versions/2", + expectedResponse: &PolicyVersion{ + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + Description: tools.StringPtr("Initial version"), + MatchRules: MatchRules{ + &MatchRuleAS{ + Type: "asMatchRule", + End: 0, + ForwardSettings: ForwardSettingsAS{ + OriginID: "originremote2", + PathAndQS: "/sales/Q1/", + UseIncomingQueryString: true, + }, + ID: 0, + Matches: []MatchCriteriaAS{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "range", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{ + 1, 25}, + }, + }, + }, + Name: "Q1Sales", + Start: 0, + }, + }, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T10:45:30.619Z"), + PolicyID: 355557, + PolicyVersion: 2, + }, + }, + "200 OK, PR rule": { + request: GetPolicyVersionRequest{ + PolicyID: 276858, + PolicyVersion: 6, + }, + responseStatus: http.StatusOK, + responseBody: `{ + "createdDate": "2023-10-19T09:46:57.395Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T09:46:57.395Z", + "matchRules": [ + { + "type": "cdMatchRule", + "akaRuleId": "b151ca68e51f5a61", + "end": 0, + "forwardSettings": { + "originId": "fr_test_krk_dc2", + "percent": 11 + }, + "id": 0, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "rule 1", + "start": 0 + } + ], + "policyId": 325401, + "version": 3 + }`, + expectedPath: "/cloudlets/v3/policies/276858/versions/6", + expectedResponse: &PolicyVersion{ + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + MatchRules: MatchRules{ + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "fr_test_krk_dc2", + Percent: 11, + }, + ID: 0, + Matches: []MatchCriteriaPR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{ + "GET"}, + }, + }, + }, + Name: "rule 1", + Start: 0, + }, + }, + PolicyID: 325401, + PolicyVersion: 3, + }, + }, + "200 OK, ER rule with disabled=false": { + request: GetPolicyVersionRequest{ + PolicyID: 276858, + PolicyVersion: 6, + }, + responseStatus: http.StatusOK, + responseBody: `{ + "createdDate": "2023-10-19T09:46:57.395Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T09:46:57.395Z", + "matchRules": [ + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "statusCode": 307, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + } + ], + "policyId": 276858, + "version": 6 + }`, + expectedPath: "/cloudlets/v3/policies/276858/versions/6", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T09:46:57.395Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + RedirectURL: "/abc/sss", + Start: 0, + StatusCode: 307, + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + Disabled: false, + }, + }, + }, + }, + "200 OK, FR with disabled rule": { + request: GetPolicyVersionRequest{ + PolicyID: 276858, + PolicyVersion: 6, + }, + responseStatus: http.StatusOK, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [{ + "type": "frMatchRule", + "disabled": true, + "end": 0, + "id": 0, + "matchURL": null, + "forwardSettings": { + "pathAndQS": "/test_images/simpleimg.jpg", + "useIncomingQueryString": true, + "originId": "1234" + }, + "name": "rule 1", + "start": 0 + }], + "policyId": 276858, + "version": 6 + }`, + expectedPath: "/cloudlets/v3/policies/276858/versions/6", + expectedResponse: &PolicyVersion{ + CreatedBy: "jsmith", + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + Description: nil, + PolicyID: 276858, + PolicyVersion: 6, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + MatchRules: MatchRules{ + &MatchRuleFR{ + Name: "rule 1", + Type: "frMatchRule", + Start: 0, + End: 0, + ID: 0, + MatchURL: "", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/simpleimg.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + Disabled: true, + }, + }, + }, + }, + "500 internal server error": { + request: GetPolicyVersionRequest{ + PolicyID: 1, + }, + responseStatus: http.StatusInternalServerError, + responseBody: ` + { + "type": "internal_error", + "title": "Internal Server Error", + "status": 500 + }`, + expectedPath: "/cloudlets/v3/policies/1/versions/0", + withError: func(t *testing.T, err error) { + want := &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + } + assert.True(t, errors.Is(err, want), "want: %s; got: %s", want, err) + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.GetPolicyVersion(context.Background(), test.request) + if test.withError != nil { + test.withError(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestCreatePolicyVersion(t *testing.T) { + t.Parallel() + tests := map[string]struct { + request CreatePolicyVersionRequest + requestBody string + responseStatus int + responseBody string + expectedPath string + expectedResponse *PolicyVersion + withError error + }{ + "201 created, simple ER": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + Description: tools.StringPtr("Description for the policy"), + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: ` +{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": "Description for the policy", + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": null, + "policyId": 276858, + "version": 2 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: tools.StringPtr("Description for the policy"), + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + MatchRules: nil, + PolicyID: 276858, + PolicyVersion: 2, + }, + }, + "201 created, complex AS": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleAS{ + Start: 0, + End: 0, + Type: "asMatchRule", + Name: "Q1Sales", + ID: 0, + ForwardSettings: ForwardSettingsAS{ + OriginID: "originremote2", + PathAndQS: "/sales/Q1/", + UseIncomingQueryString: true, + }, + Matches: []MatchCriteriaAS{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "range", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 25}, + }, + }, + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "AS", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + PolicyID: 355557, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": "Initial version", + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "asMatchRule", + "akaRuleId": "f58014ee0cc17ce", + "end": 0, + "forwardSettings": { + "originId": "originremote2", + "pathAndQS": "/sales/Q1/", + "useIncomingQueryString": true + }, + "id": 0, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "range", + "negate": false, + "objectMatchValue": { + "type": "range", + "value": [ + 1, + 25 + ] + } + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + }, + { + "matchOperator": "equals", + "matchType": "header", + "negate": false, + "objectMatchValue": { + "type": "object", + "name": "AS", + "options": { + "value": [ + "text/html*", + "text/css*", + "application/x-javascript*" + ], + "valueHasWildcard": true + } + } + } + ], + "name": "Q1Sales", + "start": 0 + } + ], + "policyId": 355557, + "version": 2 +}`, + expectedPath: "/cloudlets/v3/policies/355557/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: tools.StringPtr("Initial version"), + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + MatchRules: MatchRules{ + &MatchRuleAS{ + Type: "asMatchRule", + End: 0, + ForwardSettings: ForwardSettingsAS{ + OriginID: "originremote2", + PathAndQS: "/sales/Q1/", + UseIncomingQueryString: true, + }, + ID: 0, + Matches: []MatchCriteriaAS{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "range", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{ + 1, 25}, + }, + }, + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "AS", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + Name: "Q1Sales", + Start: 0, + }, + }, + PolicyID: 355557, + PolicyVersion: 2, + }, + }, + "201 created, complex PR": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rul3", + ID: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + }, + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rule 2", + MatchURL: "ddd.aaa", + ID: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + }, + &MatchRulePR{ + Type: "cdMatchRule", + ID: 0, + Name: "r1", + Start: 0, + End: 0, + MatchURL: "abc.com", + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "cdMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "hostname", + "matchValue": "3333.dom", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "cookie", + "matchValue": "cookie=cookievalue", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "extension", + "matchValue": "txt", + "negate": false + } + ], + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "forwardSettings": { + "originId": "some_origin", + "percent": 10 + } + }, + { + "type": "cdMatchRule", + "end": 0, + "id": 0, + "matchURL": "ddd.aaa", + "name": "rule 2", + "redirectURL": "sss.com", + "start": 0, + "statusCode": 301, + "useIncomingQueryString": true, + "useRelativeUrl": "none" + }, + { + "type": "cdMatchRule", + "end": 0, + "id": 0, + "matchURL": "abc.com", + "name": "r1", + "redirectURL": "/ddd", + "start": 0, + "statusCode": 301, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + Start: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + }, + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ID: 0, + MatchURL: "ddd.aaa", + Name: "rule 2", + Start: 0, + }, + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ID: 0, + MatchURL: "abc.com", + Name: "r1", + Start: 0, + }, + }, + }, + }, + "201 created, complex PR with objectMatchValue - simple": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rul3", + ID: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + requestBody: `{"matchRules":[{"name":"rul3","type":"cdMatchRule","matches":[{"matchType":"method","matchOperator":"equals","caseSensitive":true,"negate":false,"objectMatchValue":{"type":"simple","value":["GET"]}}],"forwardSettings":{"originId":"some_origin","percent":10}}]}`, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "cdMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "forwardSettings": { + "originId": "some_origin", + "percent": 10 + } + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + Start: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + }, + "201 created, complex PR with objectMatchValue - object": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rul3", + ID: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "PR", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "cdMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "matchOperator": "equals", + "matchType": "hostname", + "negate": false, + "objectMatchValue": { + "type": "object", + "name": "PR", + "options": { + "value": [ + "text/html*", + "text/css*", + "application/x-javascript*" + ], + "valueHasWildcard": true + } + } + } + ], + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "forwardSettings": { + "originId": "some_origin", + "percent": 10 + } + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRulePR{ + Type: "cdMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + Start: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + MatchOperator: "equals", + MatchType: "hostname", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "PR", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + }, + "validation error, complex PR with unavailable objectMatchValue type - range": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rul3", + ID: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 50}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + "validation error, complex PR missing forwardSettings": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rul3", + ID: 0, + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + Matches: []MatchCriteriaPR{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + }, + &MatchRulePR{ + Start: 0, + End: 0, + Type: "cdMatchRule", + Name: "rule 2", + MatchURL: "ddd.aaa", + ID: 0, + }, + &MatchRulePR{ + Type: "cdMatchRule", + ID: 0, + Name: "r1", + Start: 0, + End: 0, + MatchURL: "abc.com", + ForwardSettings: ForwardSettingsPR{ + OriginID: "some_origin", + Percent: 10, + }, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + + "201 created, complex ER": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + UseRelativeURL: "copy_scheme_hostname", + Name: "rul3", + StatusCode: 307, + RedirectURL: "/abc/sss", + ID: 0, + Matches: []MatchCriteriaER{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + }, + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + UseRelativeURL: "none", + Name: "rule 2", + MatchURL: "ddd.aaa", + RedirectURL: "sss.com", + StatusCode: 301, + UseIncomingQueryString: true, + ID: 0, + }, + &MatchRuleER{ + Type: "erMatchRule", + ID: 0, + Name: "r1", + Start: 0, + End: 0, + MatchURL: "abc.com", + StatusCode: 301, + RedirectURL: "/ddd", + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "hostname", + "matchValue": "3333.dom", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "cookie", + "matchValue": "cookie=cookievalue", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "extension", + "matchValue": "txt", + "negate": false + } + ], + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "statusCode": 307, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + }, + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": "ddd.aaa", + "name": "rule 2", + "redirectURL": "sss.com", + "start": 0, + "statusCode": 301, + "useIncomingQueryString": true, + "useRelativeUrl": "none" + }, + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": "abc.com", + "name": "r1", + "redirectURL": "/ddd", + "start": 0, + "statusCode": 301, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + RedirectURL: "/abc/sss", + Start: 0, + StatusCode: 307, + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + Matches: []MatchCriteriaER{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + }, + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "ddd.aaa", + Name: "rule 2", + RedirectURL: "sss.com", + Start: 0, + StatusCode: 301, + UseIncomingQueryString: true, + UseRelativeURL: "none", + }, + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "abc.com", + Name: "r1", + RedirectURL: "/ddd", + Start: 0, + StatusCode: 301, + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + }, + }, + }, + }, + "201 created, complex ER with objectMatchValue - simple": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + UseRelativeURL: "copy_scheme_hostname", + Name: "rul3", + StatusCode: 307, + RedirectURL: "/abc/sss", + ID: 0, + Matches: []MatchCriteriaER{ + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "statusCode": 307, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + RedirectURL: "/abc/sss", + Start: 0, + StatusCode: 307, + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + Matches: []MatchCriteriaER{ + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + }, + "201 created, complex ER with objectMatchValue - object": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + UseRelativeURL: "copy_scheme_hostname", + Name: "rul3", + StatusCode: 307, + RedirectURL: "/abc/sss", + ID: 0, + Matches: []MatchCriteriaER{ + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "ER", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "matchOperator": "equals", + "matchType": "hostname", + "negate": false, + "objectMatchValue": { + "type": "object", + "name": "ER", + "options": { + "value": [ + "text/html*", + "text/css*", + "application/x-javascript*" + ], + "valueHasWildcard": true + } + } + } + ], + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "statusCode": 307, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + RedirectURL: "/abc/sss", + Start: 0, + StatusCode: 307, + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + Matches: []MatchCriteriaER{ + { + MatchOperator: "equals", + MatchType: "hostname", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "ER", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + }, + "201 created, ER with empty/no useRelativeURL": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + Name: "rule 2", + MatchURL: "ddd.aaa", + RedirectURL: "sss.com", + StatusCode: 301, + UseIncomingQueryString: true, + ID: 0, + }, + &MatchRuleER{ + Type: "erMatchRule", + ID: 0, + Name: "r1", + Start: 0, + End: 0, + MatchURL: "abc.com", + StatusCode: 301, + RedirectURL: "/ddd", + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "", + }, + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + Name: "rul3", + StatusCode: 307, + RedirectURL: "/abc/sss", + ID: 0, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": "ddd.aaa", + "name": "rule 2", + "redirectURL": "sss.com", + "start": 0, + "statusCode": 301, + "useIncomingQueryString": true + }, + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "matchURL": "abc.com", + "name": "r1", + "redirectURL": "/ddd", + "start": 0, + "statusCode": 301, + "useIncomingQueryString": false, + "useIncomingSchemeAndHost": true, + "useRelativeUrl": "copy_scheme_hostname" + }, + { + "type": "erMatchRule", + "end": 0, + "id": 0, + "name": "rul3", + "redirectURL": "/abc/sss", + "start": 0, + "statusCode": 307 + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "ddd.aaa", + Name: "rule 2", + RedirectURL: "sss.com", + Start: 0, + StatusCode: 301, + UseIncomingQueryString: true, + }, + &MatchRuleER{ + Type: "erMatchRule", + End: 0, + ID: 0, + MatchURL: "abc.com", + Name: "r1", + RedirectURL: "/ddd", + Start: 0, + StatusCode: 301, + UseIncomingQueryString: false, + UseIncomingSchemeAndHost: true, + UseRelativeURL: "copy_scheme_hostname", + }, + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + Name: "rul3", + StatusCode: 307, + RedirectURL: "/abc/sss", + ID: 0, + }, + }, + }, + }, + "validation error, complex ER with unavailable objectMatchValue type - range": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleER{ + Start: 0, + End: 0, + Type: "erMatchRule", + UseRelativeURL: "copy_scheme_hostname", + Name: "rul3", + StatusCode: 307, + RedirectURL: "/abc/sss", + ID: 0, + Matches: []MatchCriteriaER{ + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 50}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + + "201 created, complex FR": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleFR{ + Start: 0, + End: 0, + Type: "frMatchRule", + Name: "rul3", + ID: 0, + Matches: []MatchCriteriaFR{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/simpleimg.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + }, + &MatchRuleFR{ + Name: "rule 1", + Type: "frMatchRule", + Start: 0, + End: 0, + ID: 0, + MatchURL: "ddd.aaa", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/simpleimg.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + }, + &MatchRuleFR{ + Name: "rule 2", + Type: "frMatchRule", + Start: 0, + End: 0, + ID: 0, + MatchURL: "abc.com", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/otherimage.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "frMatchRule", + "akaRuleId": "893947a3d5a85c1b", + "end": 0, + "forwardSettings": { + "pathAndQS": "/test_images/otherimage.jpg", + "useIncomingQueryString": true, + "originId": "1234" + }, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "hostname", + "matchValue": "3333.dom", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "cookie", + "matchValue": "cookie=cookievalue", + "negate": false + }, + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "extension", + "matchValue": "txt", + "negate": false + } + ], + "name": "rul3", + "start": 0 + }, + { + "type": "frMatchRule", + "akaRuleId": "aa379d230efcded0", + "end": 0, + "forwardSettings": { + "pathAndQS": "/test_images/simpleimg.jpg", + "useIncomingQueryString": true, + "originId": "1234" + }, + "id": 0, + "matchURL": "ddd.aaa", + "name": "rule 1", + "start": 0 + }, + { + "type": "frMatchRule", + "akaRuleId": "1afe03d843996766", + "end": 0, + "forwardSettings": { + "pathAndQS": "/test_images/otherimage.jpg", + "useIncomingQueryString": true, + "originId": "1234" + }, + "id": 0, + "matchURL": "abc.com", + "name": "rule 2", + "start": 0 + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleFR{ + Type: "frMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + Start: 0, + Matches: []MatchCriteriaFR{ + { + MatchType: "hostname", + MatchValue: "3333.dom", + MatchOperator: "equals", + CaseSensitive: true, + Negate: false, + }, + { + MatchType: "cookie", + MatchValue: "cookie=cookievalue", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + { + MatchType: "extension", + MatchValue: "txt", + MatchOperator: "equals", + Negate: false, + CaseSensitive: false, + }, + }, + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/otherimage.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + }, + &MatchRuleFR{ + Name: "rule 1", + Type: "frMatchRule", + Start: 0, + End: 0, + ID: 0, + MatchURL: "ddd.aaa", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/simpleimg.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + }, + &MatchRuleFR{ + Name: "rule 2", + Type: "frMatchRule", + Start: 0, + End: 0, + ID: 0, + MatchURL: "abc.com", + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/otherimage.jpg", + UseIncomingQueryString: true, + OriginID: "1234", + }, + }, + }, + }, + }, + "201 created, complex FR with objectMatchValue - object": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + Description: tools.StringPtr("New version 1630480693371"), + MatchRules: MatchRules{ + &MatchRuleFR{ + ForwardSettings: ForwardSettingsFR{}, + Matches: []MatchCriteriaFR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "Accept", + NameCaseSensitive: false, + NameHasWildcard: false, + Options: &Options{ + Value: []string{"asd", "qwe"}, + ValueHasWildcard: false, + ValueCaseSensitive: true, + ValueEscaped: false, + }, + }, + }, + }, + Start: 0, + End: 0, + Type: "frMatchRule", + Name: "rul3", + ID: 0, + }, + }, + }, + PolicyID: 139743, + }, + responseStatus: http.StatusCreated, + responseBody: ` +{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": "New version 1630480693371", + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "frMatchRule", + "akaRuleId": "f2168e71692e6d9f", + "end": 0, + "forwardSettings": {}, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "header", + "negate": false, + "objectMatchValue": { + "type": "object", + "name": "Accept", + "options": { + "value": [ + "asd", + "qwe" + ], + "valueCaseSensitive": true + } + } + } + ], + "name": "rul3", + "start": 0 + } + ], + "policyId": 139743, + "version": 798 +}`, + expectedPath: "/cloudlets/v3/policies/139743/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: tools.StringPtr("New version 1630480693371"), + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + MatchRules: MatchRules{ + &MatchRuleFR{ + ForwardSettings: ForwardSettingsFR{}, + Matches: []MatchCriteriaFR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Name: "Accept", + Type: "object", + NameCaseSensitive: false, + NameHasWildcard: false, + Options: &Options{ + Value: []string{"asd", "qwe"}, + ValueHasWildcard: false, + ValueCaseSensitive: true, + ValueEscaped: false, + }, + }, + }, + }, + Start: 0, + End: 0, + Type: "frMatchRule", + Name: "rul3", + ID: 0, + }, + }, + PolicyID: 139743, + PolicyVersion: 798, + }, + }, + "201 created, complex FR with objectMatchValue - simple": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + Description: tools.StringPtr("New version 1630480693371"), + MatchRules: MatchRules{ + &MatchRuleFR{ + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/otherimage.jpg", + UseIncomingQueryString: true, + }, + Matches: []MatchCriteriaFR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + Start: 0, + End: 0, + Type: "frMatchRule", + Name: "rul3", + ID: 0, + }, + }, + }, + PolicyID: 139743, + }, + responseStatus: http.StatusCreated, + responseBody: ` +{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": "New version 1630480693371", + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "frMatchRule", + "akaRuleId": "f2168e71692e6d9f", + "end": 0, + "forwardSettings": { + "pathAndQS": "/test_images/otherimage.jpg", + "useIncomingQueryString": true + }, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "rul3", + "start": 0 + } + ], + "policyId": 139743, + "version": 798 +}`, + expectedPath: "/cloudlets/v3/policies/139743/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: tools.StringPtr("New version 1630480693371"), + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + MatchRules: MatchRules{ + &MatchRuleFR{ + ForwardSettings: ForwardSettingsFR{ + PathAndQS: "/test_images/otherimage.jpg", + UseIncomingQueryString: true, + }, + Matches: []MatchCriteriaFR{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + Start: 0, + End: 0, + Type: "frMatchRule", + Name: "rul3", + ID: 0, + }, + }, + PolicyID: 139743, + PolicyVersion: 798, + }, + }, + "201 created, complex AP with objectMatchValue - simple": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleAP{ + Start: 0, + End: 0, + Type: "apMatchRule", + Name: "rul3", + PassThroughPercent: tools.Float64Ptr(0), + ID: 0, + Matches: []MatchCriteriaAP{ + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "apMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + } + ], + "name": "rul3", + "start": 0, + "useIncomingQueryString": false, + "passThroughPercent": -1 + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleAP{ + Type: "apMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + PassThroughPercent: tools.Float64Ptr(-1), + Start: 0, + Matches: []MatchCriteriaAP{ + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + }, + }, + }, + }, + }, + "201 created, complex AP with objectMatchValue - object": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleAP{ + Start: 0, + End: 0, + Type: "apMatchRule", + Name: "rul3", + PassThroughPercent: tools.Float64Ptr(-1), + ID: 0, + Matches: []MatchCriteriaAP{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "AP", + Options: &Options{ + Value: []string{"y"}, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": null, + "matchRules": [ + { + "type": "apMatchRule", + "end": 0, + "id": 0, + "matchURL": null, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "header", + "negate": false, + "objectMatchValue": { + "type": "object", + "name": "AP", + "options": { + "value": [ + "y" + ], + "valueHasWildcard": true + } + } + } + ], + "name": "rul3", + "start": 0, + "passThroughPercent": -1 + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: nil, + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleAP{ + Type: "apMatchRule", + End: 0, + ID: 0, + MatchURL: "", + Name: "rul3", + PassThroughPercent: tools.Float64Ptr(-1), + Start: 0, + Matches: []MatchCriteriaAP{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "AP", + Options: &Options{ + Value: []string{"y"}, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + }, + "201 created, complex RC": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleRC{ + Start: 0, + End: 0, + Type: "igMatchRule", + Name: "rul3", + AllowDeny: DenyBranded, + ID: 0, + Matches: []MatchCriteriaRC{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "protocol", + MatchValue: "https", + Negate: false, + }, + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "RC", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + responseStatus: http.StatusCreated, + responseBody: `{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": null, + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": [ + { + "type": "igMatchRule", + "end": 0, + "id": 0, + "matchesAlways": false, + "matches": [ + { + "caseSensitive": false, + "matchOperator": "equals", + "matchType": "protocol", + "negate": false, + "matchValue": "https" + }, + { + "caseSensitive": true, + "matchOperator": "equals", + "matchType": "method", + "negate": false, + "objectMatchValue": { + "type": "simple", + "value": [ + "GET" + ] + } + }, + { + "matchOperator": "equals", + "matchType": "header", + "negate": false, + "objectMatchValue": { + "type": "object", + "name": "RC", + "options": { + "value": [ + "text/html*", + "text/css*", + "application/x-javascript*" + ], + "valueHasWildcard": true + } + } + } + ], + "name": "rul3", + "start": 0, + "allowDeny": "denybranded" + } + ], + "policyId": 276858, + "version": 6 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: nil, + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + PolicyID: 276858, + PolicyVersion: 6, + MatchRules: MatchRules{ + &MatchRuleRC{ + Type: "igMatchRule", + End: 0, + ID: 0, + MatchesAlways: false, + Name: "rul3", + AllowDeny: DenyBranded, + Start: 0, + Matches: []MatchCriteriaRC{ + { + CaseSensitive: false, + MatchOperator: "equals", + MatchType: "protocol", + MatchValue: "https", + Negate: false, + }, + { + CaseSensitive: true, + MatchOperator: "equals", + MatchType: "method", + Negate: false, + ObjectMatchValue: &ObjectMatchValueSimple{ + Type: "simple", + Value: []string{"GET"}, + }, + }, + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueObject{ + Type: "object", + Name: "RC", + Options: &Options{ + Value: []string{ + "text/html*", + "text/css*", + "application/x-javascript*", + }, + ValueHasWildcard: true, + }, + }, + }, + }, + }, + }, + }, + }, + "validation error, complex RC with unavailable objectMatchValue type - range": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleRC{ + Start: 0, + End: 0, + Type: "igMatchRule", + AllowDeny: Allow, + Name: "rul3", + ID: 0, + Matches: []MatchCriteriaRC{ + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 50}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + "validation error, complex AP with unavailable objectMatchValue type - range": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleAP{ + Start: 0, + End: 0, + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(50.50), + Name: "rul3", + ID: 0, + Matches: []MatchCriteriaAP{ + { + MatchOperator: "equals", + MatchType: "header", + Negate: false, + ObjectMatchValue: &ObjectMatchValueRange{ + Type: "range", + Value: []int64{1, 50}, + }, + }, + }, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + + "validation error, simple RC missing allowDeny": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleRC{ + Start: 0, + End: 0, + Type: "igMatchRule", + Name: "rul3", + ID: 0, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + + "validation error, simple AP missing passThrughPercent": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleAP{ + Start: 0, + End: 0, + Type: "apMatchRule", + Name: "rul3", + ID: 0, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + + "validation error, simple AP passThroughPercent out of range": { + request: CreatePolicyVersionRequest{ + CreatePolicyVersion: CreatePolicyVersion{ + MatchRules: MatchRules{ + &MatchRuleAP{ + Start: 0, + End: 0, + Type: "apMatchRule", + PassThroughPercent: tools.Float64Ptr(101), + Name: "rul3", + ID: 0, + }, + }, + }, + PolicyID: 276858, + }, + withError: ErrStructValidation, + }, + + "500 internal server error": { + request: CreatePolicyVersionRequest{ + PolicyID: 1, + }, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500 +}`, + expectedPath: "/cloudlets/v3/policies/1/versions", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + if test.requestBody != "" { + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(r.Body) + assert.NoError(t, err) + req := buf.String() + assert.Equal(t, test.requestBody, req) + } + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.CreatePolicyVersion(context.Background(), test.request) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +func TestDeletePolicyVersion(t *testing.T) { + t.Parallel() + tests := map[string]struct { + request DeletePolicyVersionRequest + responseStatus int + responseBody string + expectedPath string + withError error + }{ + "204 no content": { + request: DeletePolicyVersionRequest{ + PolicyID: 276858, + PolicyVersion: 5, + }, + responseStatus: http.StatusNoContent, + responseBody: "", + expectedPath: "/cloudlets/v3/policies/276858/versions/5", + }, + + "missing required fields": { + request: DeletePolicyVersionRequest{}, + withError: ErrStructValidation, + }, + + "500 internal server error": { + request: DeletePolicyVersionRequest{ + PolicyID: 1, + PolicyVersion: 2, + }, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500 +}`, + expectedPath: "/cloudlets/v3/policies/1/versions/2", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + err := client.DeletePolicyVersion(context.Background(), test.request) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestUpdatePolicyVersion(t *testing.T) { + t.Parallel() + tests := map[string]struct { + request UpdatePolicyVersionRequest + requestBody string + responseStatus int + responseBody string + expectedPath string + expectedResponse *PolicyVersion + withError error + }{ + "201 updated simple ER": { + request: UpdatePolicyVersionRequest{ + UpdatePolicyVersion: UpdatePolicyVersion{ + Description: tools.StringPtr("Updated description"), + }, + PolicyID: 276858, + PolicyVersion: 5, + }, + responseStatus: http.StatusOK, + responseBody: ` +{ + "createdDate": "2023-10-19T08:50:47.350Z", + "createdBy": "jsmith", + "description": "Updated description", + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-19T08:50:47.350Z", + "matchRules": null, + "policyId": 276858, + "version": 5 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions/5", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + CreatedBy: "jsmith", + Description: tools.StringPtr("Updated description"), + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-19T08:50:47.350Z"), + MatchRules: nil, + PolicyID: 276858, + PolicyVersion: 5, + }, + }, + "201 updated simple ER with warnings": { + request: UpdatePolicyVersionRequest{ + UpdatePolicyVersion: UpdatePolicyVersion{ + Description: tools.StringPtr("Updated description"), + MatchRules: MatchRules{ + &MatchRuleER{ + Name: "er_rule", + Type: "erMatchRule", + Matches: []MatchCriteriaER{}, + MatchesAlways: false, + StatusCode: 301, + RedirectURL: "/path", + }, + }, + }, + PolicyID: 276858, + PolicyVersion: 5, + }, + requestBody: ` +{ + "description": "Updated description", + "id": 276858, + "matchRules": [ + { + "type": "erMatchRule", + "name": "er_rule", + "matchURL": null, + "statusCode": 301, + "redirectURL": "/path", + "useIncomingQueryString": false + } + ] +}`, + responseStatus: http.StatusOK, + responseBody: ` +{ + "createdBy": "jsmith", + "createdDate": "2023-10-20T09:21:04.180Z", + "description": "Updated description", + "id": 6552305, + "immutable": false, + "matchRules": [ + { + "type": "erMatchRule", + "id": 0, + "name": "er_rule", + "start": 0, + "end": 0, + "matchURL": null, + "statusCode": 301, + "redirectURL": "/path", + "useIncomingQueryString": false + } + ], + "matchRulesWarnings": [ + { + "detail": "No match match conditions.", + "title": "Missing Match Criteria", + "type": "/cloudlets/error-types/missing-match-criteria", + "jsonPointer": "/matchRules/0" + } + ], + "modifiedBy": "jsmith", + "modifiedDate": "2023-10-20T10:32:56.316Z", + "policyId": 670831, + "version": 3 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions/5", + expectedResponse: &PolicyVersion{ + CreatedDate: *newTimeFromString(t, "2023-10-20T09:21:04.180Z"), + CreatedBy: "jsmith", + Description: tools.StringPtr("Updated description"), + ModifiedBy: "jsmith", + ModifiedDate: newTimeFromString(t, "2023-10-20T10:32:56.316Z"), + ID: 6552305, + MatchRules: MatchRules{ + &MatchRuleER{ + Name: "er_rule", + Type: "erMatchRule", + MatchesAlways: false, + StatusCode: 301, + RedirectURL: "/path", + }, + }, + PolicyID: 670831, + PolicyVersion: 3, + MatchRulesWarnings: []MatchRulesWarning{ + { + Detail: "No match match conditions.", + Title: "Missing Match Criteria", + Type: "/cloudlets/error-types/missing-match-criteria", + JSONPointer: "/matchRules/0", + }, + }, + }, + }, + "500 internal server error": { + request: UpdatePolicyVersionRequest{ + PolicyID: 276858, + PolicyVersion: 3, + }, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "status": 500 +}`, + expectedPath: "/cloudlets/v3/policies/276858/versions/3", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Status: http.StatusInternalServerError, + }, + }, + "validation error": { + expectedPath: "/cloudlets/v3/policies/0", + request: UpdatePolicyVersionRequest{ + UpdatePolicyVersion: UpdatePolicyVersion{ + Description: tools.StringPtr(strings.Repeat("A", 256)), + }, + }, + withError: ErrStructValidation, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodPut, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.UpdatePolicyVersion(context.Background(), test.request) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/cloudlets/v3/warning.go b/pkg/cloudlets/v3/warning.go new file mode 100644 index 00000000..2fee3dfe --- /dev/null +++ b/pkg/cloudlets/v3/warning.go @@ -0,0 +1,10 @@ +package v3 + +// Warning represents warning information regarding the policy version and loadbalancer version request +type Warning struct { + Detail string `json:"detail,omitempty"` + JSONPointer string `json:"jsonPointer,omitempty"` + Status int `json:"status,omitempty"` + Title string `json:"title,omitempty"` + Type string `json:"type,omitempty"` +} diff --git a/pkg/cloudlets/warning.go b/pkg/cloudlets/warning.go index 1f1b6742..1f1c08ef 100644 --- a/pkg/cloudlets/warning.go +++ b/pkg/cloudlets/warning.go @@ -1,7 +1,7 @@ package cloudlets type ( - // Warning represents warning information regarding the policy version and loadbalancer version request + // Warning represents warning information about the policy version request. Warning struct { Detail string `json:"detail,omitempty"` JSONPointer string `json:"jsonPointer,omitempty"` diff --git a/pkg/cloudwrapper/errors.go b/pkg/cloudwrapper/errors.go index 6ee32c6c..aa0c15e9 100644 --- a/pkg/cloudwrapper/errors.go +++ b/pkg/cloudwrapper/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -62,7 +64,8 @@ func (c *cloudwrapper) Error(r *http.Response) error { if err = json.Unmarshal(body, &result); err != nil { c.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - result.Title = string(body) + result.Title = "Failed to unmarshal error body. Cloud Wrapper API failed. Check details for more information." + result.Detail = errs.UnescapeContent(string(body)) result.Status = r.StatusCode } return &result diff --git a/pkg/cloudwrapper/errors_test.go b/pkg/cloudwrapper/errors_test.go index 3172ef13..478fdc94 100644 --- a/pkg/cloudwrapper/errors_test.go +++ b/pkg/cloudwrapper/errors_test.go @@ -1,6 +1,7 @@ package cloudwrapper import ( + "context" "io/ioutil" "net/http" "strings" @@ -94,8 +95,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "test", - Detail: "", + Title: "Failed to unmarshal error body. Cloud Wrapper API failed. Check details for more information.", + Detail: "test", Status: http.StatusInternalServerError, }, }, @@ -142,3 +143,60 @@ func TestIs(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloud Wrapper API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloud Wrapper API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Cloud Wrapper API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + c := cloudwrapper{ + Session: sess, + } + assert.Equal(t, test.expected, c.Error(test.input)) + }) + } +} diff --git a/pkg/cps/errors.go b/pkg/cps/errors.go index 2970c640..7a3f2d9f 100644 --- a/pkg/cps/errors.go +++ b/pkg/cps/errors.go @@ -40,7 +40,8 @@ func (c *cps) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { c.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = string(body) + e.Title = "Failed to unmarshal error body. CPS API failed. Check details for more information." + e.Detail = string(body) } e.StatusCode = r.StatusCode diff --git a/pkg/cps/errors_test.go b/pkg/cps/errors_test.go index f0490b00..b9d94abc 100644 --- a/pkg/cps/errors_test.go +++ b/pkg/cps/errors_test.go @@ -53,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "test", - Detail: "", + Title: "Failed to unmarshal error body. CPS API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -66,3 +66,66 @@ func TestNewError(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. CPS API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. CPS API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. CPS API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + c := cps{ + Session: sess, + } + assert.Equal(t, test.expected, c.Error(test.input)) + }) + } +} diff --git a/pkg/datastream/errors.go b/pkg/datastream/errors.go index 8c4eb94c..386c99df 100644 --- a/pkg/datastream/errors.go +++ b/pkg/datastream/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -45,8 +47,8 @@ func (d *ds) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { d.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = fmt.Sprintf("Failed to unmarshal error body") - e.Detail = err.Error() + e.Title = fmt.Sprintf("Failed to unmarshal error body. DataStream2 API failed. Check details for more information.") + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/datastream/errors_test.go b/pkg/datastream/errors_test.go index 94e8f2df..b84dc82d 100644 --- a/pkg/datastream/errors_test.go +++ b/pkg/datastream/errors_test.go @@ -53,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "Failed to unmarshal error body", - Detail: "invalid character 'e' in literal true (expecting 'r')", + Title: "Failed to unmarshal error body. DataStream2 API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -66,3 +66,66 @@ func TestNewError(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. DataStream2 API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. DataStream2 API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. DataStream2 API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + d := ds{ + Session: sess, + } + assert.Equal(t, test.expected, d.Error(test.input)) + }) + } +} diff --git a/pkg/dns/data.go b/pkg/dns/data.go new file mode 100644 index 00000000..d3d4c892 --- /dev/null +++ b/pkg/dns/data.go @@ -0,0 +1,75 @@ +package dns + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" +) + +type ( + // Data contains operations available on Data resources. + Data interface { + // ListGroups returns group list associated with particular user + // + // See: https://techdocs.akamai.com/edge-dns/reference/get-data-groups + ListGroups(context.Context, ListGroupRequest) (*ListGroupResponse, error) + } + + // ListGroupResponse lists the groups accessible to the current user + ListGroupResponse struct { + Groups []Group `json:"groups"` + } + + // ListGroupRequest is a request struct + ListGroupRequest struct { + GroupId string + } + + // Group contain the information of the particular group + Group struct { + GroupID int `json:"groupId"` + GroupName string `json:"groupName"` + ContractIDs []string `json:"contractIds"` + Permissions []string `json:"permissions"` + } +) + +var ( + // ErrListGroups is returned in case an error occurs on ListGroups operation + ErrListGroups = errors.New("list groups") +) + +func (p *dns) ListGroups(ctx context.Context, params ListGroupRequest) (*ListGroupResponse, error) { + logger := p.Log(ctx) + logger.Debug("ListGroups") + + uri, err := url.Parse("/config-dns/v2/data/groups/") + if err != nil { + return nil, fmt.Errorf("%w: failed to parse url: %s", ErrListGroups, err) + } + + q := uri.Query() + if params.GroupId != "" { + q.Add("gid", params.GroupId) + } + uri.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to list listZoneGroups request: %w", err) + } + + var groups ListGroupResponse + resp, err := p.Exec(req, &groups) + if err != nil { + return nil, fmt.Errorf("ListZoneGroups request failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, p.Error(resp) + } + + return &groups, nil +} diff --git a/pkg/dns/data_test.go b/pkg/dns/data_test.go new file mode 100644 index 00000000..5793e063 --- /dev/null +++ b/pkg/dns/data_test.go @@ -0,0 +1,168 @@ +package dns + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDns_ListGroups(t *testing.T) { + tests := map[string]struct { + request ListGroupRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *ListGroupResponse + withError error + }{ + "200 OK, when optional query parameter provided": { + request: ListGroupRequest{ + GroupId: "9012", + }, + responseStatus: http.StatusOK, + responseBody: ` + { + "groups": [ + { + "groupId": 9012, + "groupName": "example-name", + "contractIds": [ + "1-2ABCDE" + ], + "permissions": [ + "READ", + "WRITE", + "ADD", + "DELETE" + ] + } + ] + }`, + expectedPath: "/config-dns/v2/data/groups/?gid=9012", + expectedResponse: &ListGroupResponse{ + Groups: []Group{ + { + GroupID: 9012, + GroupName: "example-name", + ContractIDs: []string{ + "1-2ABCDE", + }, + Permissions: []string{ + "READ", + "WRITE", + "ADD", + "DELETE", + }, + }, + }, + }, + }, + "200 OK, when optional query parameter not provided": { + responseStatus: http.StatusOK, + responseBody: ` + { + "groups": [ + { + "groupId": 9012, + "groupName": "example-name1", + "contractIds": [ + "1-2ABCDE" + ], + "permissions": [ + "READ", + "WRITE", + "ADD", + "DELETE" + ] + }, +{ + "groupId": 9013, + "groupName": "example-name2", + "contractIds": [ + "1-2ABCDE" + ], + "permissions": [ + "READ", + "WRITE", + "ADD", + "DELETE" + ] + } + ] + }`, + expectedPath: "/config-dns/v2/data/groups/", + expectedResponse: &ListGroupResponse{ + Groups: []Group{ + { + GroupID: 9012, + GroupName: "example-name1", + ContractIDs: []string{ + "1-2ABCDE", + }, + Permissions: []string{ + "READ", + "WRITE", + "ADD", + "DELETE", + }, + }, + { + GroupID: 9013, + GroupName: "example-name2", + ContractIDs: []string{ + "1-2ABCDE", + }, + Permissions: []string{ + "READ", + "WRITE", + "ADD", + "DELETE", + }, + }, + }, + }, + }, + "500 internal server error, when optional query parameter not provided ": { + responseStatus: http.StatusInternalServerError, + responseBody: ` + { + "type": "internal_error", + "title": "Internal Server Error", + "detail": "Error fetching authorities", + "status": 500 + }`, + expectedPath: "/config-dns/v2/data/groups/", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Detail: "Error fetching authorities", + StatusCode: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.ListGroups(context.Background(), test.request) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/dns/dns.go b/pkg/dns/dns.go index 51b2804e..47202ab7 100644 --- a/pkg/dns/dns.go +++ b/pkg/dns/dns.go @@ -18,11 +18,12 @@ var ( type ( // DNS is the dns api interface DNS interface { - Zones - TSIGKeys Authorities - Records + Data RecordSets + Records + TSIGKeys + Zones } dns struct { diff --git a/pkg/dns/errors.go b/pkg/dns/errors.go index c088e615..042d19b4 100644 --- a/pkg/dns/errors.go +++ b/pkg/dns/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) var ( @@ -43,8 +45,8 @@ func (p *dns) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { p.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = fmt.Sprintf("Failed to unmarshal error body") - e.Detail = err.Error() + e.Title = fmt.Sprintf("Failed to unmarshal error body. DNS API failed. Check details for more information.") + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/dns/errors_test.go b/pkg/dns/errors_test.go new file mode 100644 index 00000000..34fd599c --- /dev/null +++ b/pkg/dns/errors_test.go @@ -0,0 +1,77 @@ +package dns + +import ( + "context" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" + "github.com/stretchr/testify/require" + + "github.com/tj/assert" +) + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. DNS API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. DNS API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. DNS API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + d := dns{ + Session: sess, + } + assert.Equal(t, test.expected, d.Error(test.input)) + }) + } +} diff --git a/pkg/dns/mocks.go b/pkg/dns/mocks.go index dd71490f..1c32338c 100644 --- a/pkg/dns/mocks.go +++ b/pkg/dns/mocks.go @@ -480,3 +480,13 @@ func (d *Mock) GetBulkZoneDeleteResult(ctx context.Context, param string) (*Bulk return args.Get(0).(*BulkDeleteResultResponse), args.Error(1) } + +func (d *Mock) ListGroups(ctx context.Context, request ListGroupRequest) (*ListGroupResponse, error) { + args := d.Called(ctx, request) + + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*ListGroupResponse), args.Error(1) +} diff --git a/pkg/edgeworkers/activations.go b/pkg/edgeworkers/activations.go index 4d27de5f..3917a2f1 100644 --- a/pkg/edgeworkers/activations.go +++ b/pkg/edgeworkers/activations.go @@ -50,6 +50,7 @@ type ( ActivateVersion struct { Network ActivationNetwork `json:"network"` Version string `json:"version"` + Note string `json:"note"` } // ActivationNetwork represents available activation network types @@ -83,6 +84,7 @@ type ( Network string `json:"network"` Status string `json:"status"` Version string `json:"version"` + Note string `json:"note"` } ) diff --git a/pkg/edgeworkers/activations_test.go b/pkg/edgeworkers/activations_test.go index 2c4b0c05..c2dcf2bd 100644 --- a/pkg/edgeworkers/activations_test.go +++ b/pkg/edgeworkers/activations_test.go @@ -38,7 +38,8 @@ func TestListActivations(t *testing.T) { "network": "PRODUCTION", "createdBy": "jdoe", "createdTime": "2018-07-09T09:03:28Z", - "lastModifiedTime": "2018-07-09T09:04:42Z" + "lastModifiedTime": "2018-07-09T09:04:42Z", + "note": "activation note3" }, { "edgeWorkerId": 42, @@ -49,7 +50,8 @@ func TestListActivations(t *testing.T) { "network": "STAGING", "createdBy": "jsmith", "createdTime": "2018-07-09T08:13:54Z", - "lastModifiedTime": "2018-07-09T08:35:02Z" + "lastModifiedTime": "2018-07-09T08:35:02Z", + "note": "activation note1" } ] }`, @@ -66,6 +68,7 @@ func TestListActivations(t *testing.T) { Network: "PRODUCTION", Status: "PENDING", Version: "2", + Note: "activation note3", }, { AccountID: "B-M-1KQK3WU", @@ -77,6 +80,7 @@ func TestListActivations(t *testing.T) { Network: "STAGING", Status: "IN_PROGRESS", Version: "1", + Note: "activation note1", }, }, }, @@ -99,7 +103,8 @@ func TestListActivations(t *testing.T) { "network": "STAGING", "createdBy": "jsmith", "createdTime": "2018-07-09T08:13:54Z", - "lastModifiedTime": "2018-07-09T08:35:02Z" + "lastModifiedTime": "2018-07-09T08:35:02Z", + "note": "activation note1" } ] }`, @@ -116,6 +121,7 @@ func TestListActivations(t *testing.T) { Network: "STAGING", Status: "IN_PROGRESS", Version: "1", + Note: "activation note1", }, }, }, @@ -196,7 +202,8 @@ func TestGetActivation(t *testing.T) { "network": "STAGING", "createdBy": "jsmith", "createdTime": "2018-07-09T08:13:54Z", - "lastModifiedTime": "2018-07-09T08:35:02Z" + "lastModifiedTime": "2018-07-09T08:35:02Z", + "note": "activation note1" }`, expectedPath: "/edgeworkers/v1/ids/42/activations/1", expectedResponse: &Activation{ @@ -209,6 +216,7 @@ func TestGetActivation(t *testing.T) { Network: "STAGING", Status: "IN_PROGRESS", Version: "1", + Note: "activation note1", }, }, "500 internal server error": { @@ -293,7 +301,8 @@ func TestActivateVersion(t *testing.T) { "network": "STAGING", "createdBy": "jsmith", "createdTime": "2018-07-09T08:13:54Z", - "lastModifiedTime": "2018-07-09T08:35:02Z" + "lastModifiedTime": "2018-07-09T08:35:02Z", + "note": "activation note1" }`, expectedPath: "/edgeworkers/v1/ids/42/activations", expectedResponse: &Activation{ @@ -306,6 +315,7 @@ func TestActivateVersion(t *testing.T) { Network: "STAGING", Status: "PRESUBMIT", Version: "1", + Note: "activation note1", }, }, "500 internal server error": { @@ -467,7 +477,8 @@ func TestCancelActivation(t *testing.T) { "network": "STAGING", "createdBy": "jsmith", "createdTime": "2018-07-09T08:13:54Z", - "lastModifiedTime": "2018-07-09T08:35:02Z" + "lastModifiedTime": "2018-07-09T08:35:02Z", + "note": "activation note1" }`, expectedPath: "/edgeworkers/v1/ids/42/activations/1", expectedResponse: &Activation{ @@ -480,6 +491,7 @@ func TestCancelActivation(t *testing.T) { Network: "STAGING", Status: "CANCELED", Version: "1", + Note: "activation note1", }, }, "500 internal server error": { diff --git a/pkg/edgeworkers/errors.go b/pkg/edgeworkers/errors.go index 986fb572..70aa883a 100644 --- a/pkg/edgeworkers/errors.go +++ b/pkg/edgeworkers/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -63,7 +65,8 @@ func (e *edgeworkers) Error(r *http.Response) error { if err := json.Unmarshal(body, &result); err != nil { e.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - result.Title = string(body) + result.Title = fmt.Sprintf("Failed to unmarshal error body. Edgeworkers API failed. Check details for more information.") + result.Detail = errs.UnescapeContent(string(body)) result.Status = r.StatusCode } return &result diff --git a/pkg/edgeworkers/errors_test.go b/pkg/edgeworkers/errors_test.go index 9bd85da5..ff8910cb 100644 --- a/pkg/edgeworkers/errors_test.go +++ b/pkg/edgeworkers/errors_test.go @@ -1,6 +1,7 @@ package edgeworkers import ( + "context" "io/ioutil" "net/http" "strings" @@ -51,8 +52,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "test", - Detail: "", + Title: "Failed to unmarshal error body. Edgeworkers API failed. Check details for more information.", + Detail: "test", Status: http.StatusInternalServerError, }, }, @@ -99,3 +100,60 @@ func TestIs(t *testing.T) { }) } } + +func TestValidationErrorsParsing(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Edgeworkers API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Edgeworkers API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Edgeworkers API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + e := edgeworkers{ + Session: sess, + } + assert.Equal(t, test.expected, e.Error(test.input)) + }) + } +} diff --git a/pkg/errs/error.go b/pkg/errs/error.go new file mode 100644 index 00000000..c01c40d7 --- /dev/null +++ b/pkg/errs/error.go @@ -0,0 +1,23 @@ +// Package errs provides utilities for working with errors during JSON data unmarshalling. +// It includes functions for unescaping HTML content and checking if a string contains HTML or XML data. +package errs + +import ( + "strings" + + "golang.org/x/net/html" +) + +// UnescapeContent unescapes HTML content. +func UnescapeContent(content string) string { + //check if the content is HTML or XML + if isHTML(content) { + return html.UnescapeString(content) + } + return content +} + +func isHTML(data string) bool { + _, err := html.Parse(strings.NewReader(data)) + return err == nil +} diff --git a/pkg/gtm/domain.go b/pkg/gtm/domain.go index c5331f9e..8ef30417 100644 --- a/pkg/gtm/domain.go +++ b/pkg/gtm/domain.go @@ -99,11 +99,18 @@ type DomainsList struct { // DomainItem is a DomainsList item type DomainItem struct { - AcgId string `json:"acgId"` - LastModified string `json:"lastModified"` - Links []*Link `json:"links"` - Name string `json:"name"` - Status string `json:"status"` + AcgId string `json:"acgId"` + LastModified string `json:"lastModified"` + Links []*Link `json:"links"` + Name string `json:"name"` + Status string `json:"status"` + LastModifiedBy string `json:"lastModifiedBy"` + ChangeID string `json:"changeId"` + ActivationState string `json:"activationState"` + ModificationComments string `json:"modificationComments"` + SignAndServe bool `json:"signAndServe"` + SignAndServeAlgorithm string `json:"signAndServeAlgorithm"` + DeleteRequestID string `json:"deleteRequestId"` } // Validate validates Domain diff --git a/pkg/gtm/domain_test.go b/pkg/gtm/domain_test.go index e29cbffc..98fcd220 100644 --- a/pkg/gtm/domain_test.go +++ b/pkg/gtm/domain_test.go @@ -25,17 +25,6 @@ func TestGtm_NewDomain(t *testing.T) { } func TestGtm_ListDomains(t *testing.T) { - var result DomainsList - - respData, err := loadTestData("TestGtm_ListDomains.resp.json") - if err != nil { - t.Fatal(err) - } - - if err := json.NewDecoder(bytes.NewBuffer(respData)).Decode(&result); err != nil { - t.Fatal(err) - } - tests := map[string]struct { responseStatus int responseBody string @@ -48,21 +37,87 @@ func TestGtm_ListDomains(t *testing.T) { headers: http.Header{ "Content-Type": []string{"application/vnd.config-gtm.v1.4+json;charset=UTF-8"}, }, - responseStatus: http.StatusOK, - responseBody: string(respData), - expectedPath: "/config-gtm/v1/domains", - expectedResponse: result.DomainItems, + responseStatus: http.StatusOK, + responseBody: `{ + "items":[{ + "acgId": "1-2345", + "lastModified": "2014-03-03T16:02:45.000+0000", + "name": "example.akadns.net", + "status": "2014-02-20 22:56 GMT: Current configuration has been propagated to all GTM name servers", + "lastModifiedBy": "test-user", + "changeId": "abf5b76f-f9de-4404-bb2c-9d15e7b9ff5d", + "activationState": "COMPLETE", + "modificationComments": "terraform test gtm domain", + "signAndServe": false, + "signAndServeAlgorithm": null, + "deleteRequestId": null, + "links": [{ + "href": "/config-gtm/v1/domains/example.akadns.net", + "rel": "self" + }] + }, + { + "acgId": "1-2345", + "lastModified": "2013-11-09T12:04:45.000+0000", + "name": "demo.akadns.net", + "status": "2014-02-20 22:56 GMT: Current configuration has been propagated to all GTM name servers", + "lastModifiedBy": "test-user", + "changeId": "abf5b76f-f9de-4404-bb2c-9d15e7b9ff5d", + "activationState": "COMPLETE", + "modificationComments": "terraform test gtm domain", + "signAndServe": false, + "signAndServeAlgorithm": null, + "deleteRequestId": null, + "links": [{ + "href": "/config-gtm/v1/domains/example.akadns.net", + "rel": "self" + }] + }]}`, + expectedPath: "/config-gtm/v1/domains", + expectedResponse: []*DomainItem{{ + AcgId: "1-2345", + LastModified: "2014-03-03T16:02:45.000+0000", + Name: "example.akadns.net", + Status: "2014-02-20 22:56 GMT: Current configuration has been propagated to all GTM name servers", + LastModifiedBy: "test-user", + ChangeID: "abf5b76f-f9de-4404-bb2c-9d15e7b9ff5d", + ActivationState: "COMPLETE", + ModificationComments: "terraform test gtm domain", + SignAndServe: false, + SignAndServeAlgorithm: "", + DeleteRequestID: "", + Links: []*Link{{ + Rel: "self", + Href: "/config-gtm/v1/domains/example.akadns.net", + }}, + }, + { + AcgId: "1-2345", + LastModified: "2013-11-09T12:04:45.000+0000", + Name: "demo.akadns.net", + Status: "2014-02-20 22:56 GMT: Current configuration has been propagated to all GTM name servers", + LastModifiedBy: "test-user", + ChangeID: "abf5b76f-f9de-4404-bb2c-9d15e7b9ff5d", + ActivationState: "COMPLETE", + ModificationComments: "terraform test gtm domain", + SignAndServe: false, + SignAndServeAlgorithm: "", + DeleteRequestID: "", + Links: []*Link{{ + Rel: "self", + Href: "/config-gtm/v1/domains/example.akadns.net", + }}, + }}, }, "500 internal server error": { headers: http.Header{}, responseStatus: http.StatusInternalServerError, - responseBody: ` -{ - "type": "internal_error", - "title": "Internal Server Error", - "detail": "Error fetching domains", - "status": 500 -}`, + responseBody: `{ + "type": "internal_error", + "title": "Internal Server Error", + "detail": "Error fetching domains", + "status": 500 + }`, expectedPath: "/config-gtm/v1/domains", withError: &Error{ Type: "internal_error", @@ -71,6 +126,42 @@ func TestGtm_ListDomains(t *testing.T) { StatusCode: http.StatusInternalServerError, }, }, + "Service Unavailable plain text response": { + headers: http.Header{}, + responseStatus: http.StatusServiceUnavailable, + responseBody: `Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z`, + expectedPath: "/config-gtm/v1/domains", + withError: &Error{ + Type: "", + Title: "Failed to unmarshal error body. GTM API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "Service Unavailable html response": { + headers: http.Header{}, + responseStatus: http.StatusServiceUnavailable, + responseBody: `......`, + expectedPath: "/config-gtm/v1/domains", + withError: &Error{ + Type: "", + Title: "Failed to unmarshal error body. GTM API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "Service Unavailable xml response": { + headers: http.Header{}, + responseStatus: http.StatusServiceUnavailable, + responseBody: `1Item 12Item 2`, + expectedPath: "/config-gtm/v1/domains", + withError: &Error{ + Type: "", + Title: "Failed to unmarshal error body. GTM API failed. Check details for more information.", + Detail: "1Item 12Item 2", + StatusCode: http.StatusServiceUnavailable, + }, + }, } for name, test := range tests { diff --git a/pkg/gtm/errors.go b/pkg/gtm/errors.go index a5b3a5b2..cf46564c 100644 --- a/pkg/gtm/errors.go +++ b/pkg/gtm/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) var ( @@ -45,8 +47,8 @@ func (p *gtm) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { p.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = fmt.Sprintf("Failed to unmarshal error body") - e.Detail = err.Error() + e.Title = fmt.Sprintf("Failed to unmarshal error body. GTM API failed. Check details for more information.") + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/gtm/errors_test.go b/pkg/gtm/errors_test.go new file mode 100644 index 00000000..eccc91c3 --- /dev/null +++ b/pkg/gtm/errors_test.go @@ -0,0 +1,77 @@ +package gtm + +import ( + "context" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/session" + "github.com/stretchr/testify/require" + + "github.com/tj/assert" +) + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. GTM API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. GTM API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. GTM API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + g := gtm{ + Session: sess, + } + assert.Equal(t, test.expected, g.Error(test.input)) + }) + } +} diff --git a/pkg/gtm/testdata/TestGtm_ListDomains.resp.json b/pkg/gtm/testdata/TestGtm_ListDomains.resp.json deleted file mode 100644 index d64a8d79..00000000 --- a/pkg/gtm/testdata/TestGtm_ListDomains.resp.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "items": [ - { - "acgId": "1-2345", - "lastModified": "2014-03-03T16:02:45.000+0000", - "name": "example.akadns.net", - "status": "2014-02-20 22:56 GMT: Current configuration has been propagated to all GTM name servers", - "links": [ - { - "href": "/config-gtm/v1/domains/example.akadns.net", - "rel": "self" - } - ] - }, - { - "acgId": "1-2345", - "lastModified": "2013-11-09T12:04:45.000+0000", - "name": "demo.akadns.net", - "status": "2014-02-20 22:56 GMT: Current configuration has been propagated to all GTM name servers", - "links": [ - { - "href": "/config-gtm/v1/domains/demo.akadns.net", - "rel": "self" - } - ] - } - ] -} \ No newline at end of file diff --git a/pkg/hapi/errors.go b/pkg/hapi/errors.go index 4c21e996..41c1077b 100644 --- a/pkg/hapi/errors.go +++ b/pkg/hapi/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -50,8 +52,8 @@ func (h *hapi) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { h.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = fmt.Sprintf("Failed to unmarshal error body") - e.Detail = err.Error() + e.Title = fmt.Sprintf("Failed to unmarshal error body. HAPI API failed. Check details for more information.") + e.Detail = errs.UnescapeContent(string(body)) } e.Status = r.StatusCode diff --git a/pkg/hapi/errors_test.go b/pkg/hapi/errors_test.go index 1ed5375a..df779f4e 100644 --- a/pkg/hapi/errors_test.go +++ b/pkg/hapi/errors_test.go @@ -53,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "Failed to unmarshal error body", - Detail: "invalid character 'e' in literal true (expecting 'r')", + Title: "Failed to unmarshal error body. HAPI API failed. Check details for more information.", + Detail: "test", Status: http.StatusInternalServerError, }, }, @@ -66,3 +66,60 @@ func TestNewError(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. HAPI API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. HAPI API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. HAPI API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + g := hapi{ + Session: sess, + } + assert.Equal(t, test.expected, g.Error(test.input)) + }) + } +} diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index c598a89f..b14bb58d 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -46,8 +48,8 @@ func (i *iam) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { i.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = "Failed to unmarshal error body" - e.Detail = err.Error() + e.Title = "Failed to unmarshal error body. IAM API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/iam/errors_test.go b/pkg/iam/errors_test.go index 505f304e..48233bb4 100644 --- a/pkg/iam/errors_test.go +++ b/pkg/iam/errors_test.go @@ -53,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "Failed to unmarshal error body", - Detail: "invalid character 'e' in literal true (expecting 'r')", + Title: "Failed to unmarshal error body. IAM API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -66,3 +66,66 @@ func TestNewError(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + StatusCode: http.StatusServiceUnavailable, + Title: "Failed to unmarshal error body. IAM API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + StatusCode: http.StatusServiceUnavailable, + Title: "Failed to unmarshal error body. IAM API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. IAM API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + i := iam{ + Session: sess, + } + assert.Equal(t, test.expected, i.Error(test.input)) + }) + } +} diff --git a/pkg/imaging/errors.go b/pkg/imaging/errors.go index c127e3f6..be7b8e5b 100644 --- a/pkg/imaging/errors.go +++ b/pkg/imaging/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -44,7 +46,8 @@ func (i *imaging) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { i.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = string(body) + e.Title = "Failed to unmarshal error body. Image & Video Manager API failed. Check details for more information." + e.Detail = errs.UnescapeContent(string(body)) e.Status = r.StatusCode } return &e diff --git a/pkg/imaging/errors_test.go b/pkg/imaging/errors_test.go index 3b31df8e..cd516b43 100644 --- a/pkg/imaging/errors_test.go +++ b/pkg/imaging/errors_test.go @@ -1,6 +1,7 @@ package imaging import ( + "context" "io/ioutil" "net/http" "strings" @@ -81,8 +82,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "test", - Detail: "", + Title: "Failed to unmarshal error body. Image & Video Manager API failed. Check details for more information.", + Detail: "test", Status: http.StatusInternalServerError, }, }, @@ -129,3 +130,60 @@ func TestAs(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Image & Video Manager API failed. Check details for more information.", + Detail: "......", + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Image & Video Manager API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Image & Video Manager API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + g := imaging{ + Session: sess, + } + assert.Equal(t, test.expected, g.Error(test.input)) + }) + } +} diff --git a/pkg/imaging/policy.gen.go b/pkg/imaging/policy.gen.go index 5df548eb..4675e52b 100644 --- a/pkg/imaging/policy.gen.go +++ b/pkg/imaging/policy.gen.go @@ -708,6 +708,8 @@ type ( OutputImage struct { // AdaptiveQuality Override the quality of image to serve when Image & Video Manager detects a slow connection. Specifying lower values lets users with slow connections browse your site with reduced load times without impacting the quality of images for users with faster connections. AdaptiveQuality *int `json:"adaptiveQuality,omitempty"` + // AllowPristineOnDownsize Whether a pristine image wider than the requested breakpoint is allowed as a derivative image if it has the fewest bytes. This will not have an affect if transformations are present. + AllowPristineOnDownsize *bool `json:"allowPristineOnDownsize,omitempty"` // AllowedFormats The graphics file formats allowed for browser specific results. AllowedFormats []OutputImageAllowedFormats `json:"allowedFormats,omitempty"` // ForcedFormats The forced extra formats for the `imFormat` query parameter, which requests a specific browser type. By default, Image and Video Manager detects the browser and returns the appropriate image. @@ -716,6 +718,8 @@ type ( PerceptualQuality *OutputImagePerceptualQualityVariableInline `json:"perceptualQuality,omitempty"` // PerceptualQualityFloor Only applies with perceptualQuality set. Sets a minimum image quality to respect when using perceptual quality. Perceptual quality will not reduce the quality below this value even if it determines the compressed image to be acceptably visually similar. PerceptualQualityFloor *int `json:"perceptualQualityFloor,omitempty"` + // PreferModernFormats Whether derivative image formats should be selected with a preference for modern formats (such as WebP and Avif) instead the format that results in the fewest bytes. + PreferModernFormats *bool `json:"preferModernFormats,omitempty"` // Quality Mutually exclusive with perceptualQuality, used by default if neither is specified. The chosen quality of the output images. Using a quality value from 1-100 resembles JPEG quality across output formats. Quality *IntegerVariableInline `json:"quality,omitempty"` } @@ -896,6 +900,8 @@ type ( EndTime int `json:"endTime"` // RolloutDuration The amount of time in seconds that the policy takes to rollout. During the rollout an increasing proportion of images/videos will begin to use the new policy instead of the cached images/videos from the previous version. Policies on the staging network deploy as quickly as possible without rollout. For staging policies this value will always be 1. RolloutDuration int `json:"rolloutDuration"` + // ServeStaleEndTime The estimated time that serving stale for this policy will end. Value is a unix timestamp. + ServeStaleEndTime *int `json:"serveStaleEndTime,omitempty"` // StartTime The estimated time that rollout for this policy will begin. Value is a unix timestamp. StartTime int `json:"startTime"` } @@ -2886,6 +2892,7 @@ func (o OutputImage) Validate() error { validation.Min(1), validation.Max(100), ), + "AllowPristineOnDownsize": validation.Validate(o.AllowPristineOnDownsize), "AllowedFormats": validation.Validate(o.AllowedFormats, validation.Each( validation.In(OutputImageAllowedFormatsGif, OutputImageAllowedFormatsJpeg, @@ -2909,7 +2916,8 @@ func (o OutputImage) Validate() error { validation.Min(1), validation.Max(100), ), - "Quality": validation.Validate(o.Quality), + "PreferModernFormats": validation.Validate(o.PreferModernFormats), + "Quality": validation.Validate(o.Quality), }.Filter() } @@ -3110,6 +3118,7 @@ func (r RolloutInfo) Validate() error { validation.Min(3600), validation.Max(604800), ), + "ServeStaleEndTime": validation.Validate(r.ServeStaleEndTime), "StartTime": validation.Validate(r.StartTime, validation.Required, ), @@ -4388,7 +4397,7 @@ func (r *RegionOfInterestCrop) UnmarshalJSON(in []byte) error { } /*-----------------------------------------------*/ -////////// Transformation unmarshallers /////////// +////////// Transformation unmarshalers /////////// /*-----------------------------------------------*/ var ( diff --git a/pkg/imaging/policy.go b/pkg/imaging/policy.go index e004b757..8f9b4f34 100644 --- a/pkg/imaging/policy.go +++ b/pkg/imaging/policy.go @@ -134,6 +134,8 @@ type ( PostBreakpointTransformations PostBreakpointTransformations `json:"postBreakpointTransformations,omitempty"` // RolloutDuration The amount of time in seconds that the policy takes to rollout. During the rollout an increasing proportion of images/videos will begin to use the new policy instead of the cached images/videos from the previous version RolloutDuration *int `json:"rolloutDuration,omitempty"` + // ServeStaleDuration The amount of time in seconds that the policy will serve stale images. During the serve stale period realtime images will attempt to use the offline image from the previous policy version first if possible. + ServeStaleDuration *int `json:"serveStaleDuration,omitempty"` // Transformations Set of image transformations to apply to the source image. If unspecified, no operations are performed Transformations Transformations `json:"transformations,omitempty"` // Variables Declares variables for use within the policy. Any variable declared here can be invoked throughout transformations as a [Variable](#variable) object, so that you don't have to specify values separately You can also pass in these variable names and values dynamically as query parameters in the image's request URL @@ -212,6 +214,10 @@ func (p *PolicyInputImage) Validate() error { validation.Min(3600), validation.Max(604800), ), + "ServeStaleDuration": validation.Validate(p.ServeStaleDuration, + validation.Min(0), + validation.Max(2592000), + ), "Transformations": validation.Validate(p.Transformations), "Variables": validation.Validate(p.Variables, validation.Each()), }.Filter() diff --git a/pkg/networklists/errors.go b/pkg/networklists/errors.go index 235cc2a4..3e439796 100644 --- a/pkg/networklists/errors.go +++ b/pkg/networklists/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) var ( @@ -43,8 +45,8 @@ func (p *networklists) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { p.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = fmt.Sprintf("Failed to unmarshal error body") - e.Detail = err.Error() + e.Title = fmt.Sprintf("Failed to unmarshal error body. Network Lists API failed. Check details for more information.") + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/networklists/errors_test.go b/pkg/networklists/errors_test.go index 33b5c50d..b6a4f28c 100644 --- a/pkg/networklists/errors_test.go +++ b/pkg/networklists/errors_test.go @@ -53,8 +53,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "Failed to unmarshal error body", - Detail: "invalid character 'e' in literal true (expecting 'r')", + Title: "Failed to unmarshal error body. Network Lists API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -66,3 +66,66 @@ func TestNewError(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Network Lists API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Network Lists API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. Network Lists API failed. Check details for more information.", + Detail: "", + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + n := networklists{ + Session: sess, + } + assert.Equal(t, test.expected, n.Error(test.input)) + }) + } +} diff --git a/pkg/papi/errors.go b/pkg/papi/errors.go index 39098023..c3c73023 100644 --- a/pkg/papi/errors.go +++ b/pkg/papi/errors.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v7/pkg/errs" ) type ( @@ -61,8 +63,8 @@ func (p *papi) Error(r *http.Response) error { if err := json.Unmarshal(body, &e); err != nil { p.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) - e.Title = fmt.Sprintf("Failed to unmarshal error body") - e.Detail = err.Error() + e.Title = fmt.Sprintf("Failed to unmarshal error body. PAPI API failed. Check details for more information.") + e.Detail = errs.UnescapeContent(string(body)) } e.StatusCode = r.StatusCode diff --git a/pkg/papi/errors_test.go b/pkg/papi/errors_test.go index 61b1b427..0fa936d8 100644 --- a/pkg/papi/errors_test.go +++ b/pkg/papi/errors_test.go @@ -55,8 +55,8 @@ func TestNewError(t *testing.T) { Request: req, }, expected: &Error{ - Title: "Failed to unmarshal error body", - Detail: "invalid character 'e' in literal true (expecting 'r')", + Title: "Failed to unmarshal error body. PAPI API failed. Check details for more information.", + Detail: "test", StatusCode: http.StatusInternalServerError, }, }, @@ -126,3 +126,66 @@ func TestErrorIs(t *testing.T) { }) } } + +func TestJsonErrorUnmarshalling(t *testing.T) { + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + tests := map[string]struct { + input *http.Response + expected *Error + }{ + "API failure with HTML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(`......`))}, + expected: &Error{ + Type: "", + Title: "Failed to unmarshal error body. PAPI API failed. Check details for more information.", + Detail: "......", + StatusCode: http.StatusServiceUnavailable, + }, + }, + "API failure with plain text response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader("Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z"))}, + expected: &Error{ + Type: "", + StatusCode: http.StatusServiceUnavailable, + Title: "Failed to unmarshal error body. PAPI API failed. Check details for more information.", + Detail: "Your request did not succeed as this operation has reached the limit for your account. Please try after 2024-01-16T15:20:55.945Z", + }, + }, + "API failure with XML response": { + input: &http.Response{ + Request: req, + Status: "OK", + StatusCode: http.StatusServiceUnavailable, + Body: ioutil.NopCloser(strings.NewReader(``))}, + expected: &Error{ + Type: "", + StatusCode: http.StatusServiceUnavailable, + Title: "Failed to unmarshal error body. PAPI API failed. Check details for more information.", + Detail: "", + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sess, _ := session.New() + p := papi{ + Session: sess, + } + assert.Equal(t, test.expected, p.Error(test.input)) + }) + } +}