diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ebfce13e..5977916d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -38,6 +38,7 @@ jobs: OCTOPUS_SERVER_BASE64_LICENSE: ${{ secrets.OCTOPUS_SERVER_BASE64_LICENSE }} OCTOPUS__FeatureToggles__EphemeralEnvironmentsManualDeploymentsFeatureToggle: 'true' OCTOPUS__FeatureToggles__EphemeralEnvironmentsFeatureToggle: 'true' + OCTOPUS__FeatureToggles__RateLimitingV2FeatureToggle: 'true' ports: - 8080:8080 # https://github.com/dorny/test-reporter/issues/168 diff --git a/pkg/ratelimitingpolicies/rate_limiting_policy.go b/pkg/ratelimitingpolicies/rate_limiting_policy.go new file mode 100644 index 00000000..b629ac0d --- /dev/null +++ b/pkg/ratelimitingpolicies/rate_limiting_policy.go @@ -0,0 +1,40 @@ +package ratelimitingpolicies + +type RateLimitingPolicy struct { + ID string `json:"Id"` + IsBuiltIn bool `json:"IsBuiltIn"` + Name string `json:"Name"` + IsEnabled bool `json:"IsEnabled"` + ScopeType RateLimitingPolicyScopeType `json:"ScopeType"` + RequestsPerHour int `json:"RequestsPerHour"` + BurstLimit int `json:"BurstLimit"` + AuditMode bool `json:"AuditMode"` +} + +type GetRateLimitingPolicyByIdRequest struct { + ID string `uri:"id"` +} + +type ListRateLimitingPoliciesRequest struct { + Skip int `uri:"skip,omitempty"` + Take int `uri:"take,omitempty"` +} + +type ListRateLimitingPoliciesResponse struct { + ItemType string `json:"ItemType"` + TotalResults int `json:"TotalResults"` + ItemsPerPage int `json:"ItemsPerPage"` + Items []RateLimitingPolicy `json:"Items"` + NumberOfPages int `json:"NumberOfPages"` + LastPageNumber int `json:"LastPageNumber"` +} + +type ModifyRateLimitingPolicyCommand struct { + ID string `uri:"id" json:"-"` + Name string `json:"Name"` + IsEnabled bool `json:"IsEnabled"` + ScopeType RateLimitingPolicyScopeType `json:"ScopeType"` + RequestsPerHour int `json:"RequestsPerHour"` + BurstLimit int `json:"BurstLimit"` + AuditMode bool `json:"AuditMode"` +} diff --git a/pkg/ratelimitingpolicies/rate_limiting_policy_scope_type.go b/pkg/ratelimitingpolicies/rate_limiting_policy_scope_type.go new file mode 100644 index 00000000..460ed8cc --- /dev/null +++ b/pkg/ratelimitingpolicies/rate_limiting_policy_scope_type.go @@ -0,0 +1,9 @@ +package ratelimitingpolicies + +type RateLimitingPolicyScopeType int + +const ( + Unauthenticated RateLimitingPolicyScopeType = iota + AuthenticatedHuman + AuthenticatedAgent +) diff --git a/pkg/ratelimitingpolicies/rate_limiting_policy_scope_type_string.go b/pkg/ratelimitingpolicies/rate_limiting_policy_scope_type_string.go new file mode 100644 index 00000000..d48ffea3 --- /dev/null +++ b/pkg/ratelimitingpolicies/rate_limiting_policy_scope_type_string.go @@ -0,0 +1,100 @@ +// Code generated by "enumer -type=RateLimitingPolicyScopeType -json -output rate_limiting_policy_scope_type_string.go"; DO NOT EDIT. + +package ratelimitingpolicies + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _RateLimitingPolicyScopeTypeName = "UnauthenticatedAuthenticatedHumanAuthenticatedAgent" + +var _RateLimitingPolicyScopeTypeIndex = [...]uint8{0, 15, 33, 51} + +const _RateLimitingPolicyScopeTypeLowerName = "unauthenticatedauthenticatedhumanauthenticatedagent" + +func (i RateLimitingPolicyScopeType) String() string { + if i < 0 || i >= RateLimitingPolicyScopeType(len(_RateLimitingPolicyScopeTypeIndex)-1) { + return fmt.Sprintf("RateLimitingPolicyScopeType(%d)", i) + } + return _RateLimitingPolicyScopeTypeName[_RateLimitingPolicyScopeTypeIndex[i]:_RateLimitingPolicyScopeTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _RateLimitingPolicyScopeTypeNoOp() { + var x [1]struct{} + _ = x[Unauthenticated-(0)] + _ = x[AuthenticatedHuman-(1)] + _ = x[AuthenticatedAgent-(2)] +} + +var _RateLimitingPolicyScopeTypeValues = []RateLimitingPolicyScopeType{Unauthenticated, AuthenticatedHuman, AuthenticatedAgent} + +var _RateLimitingPolicyScopeTypeNameToValueMap = map[string]RateLimitingPolicyScopeType{ + _RateLimitingPolicyScopeTypeName[0:15]: Unauthenticated, + _RateLimitingPolicyScopeTypeLowerName[0:15]: Unauthenticated, + _RateLimitingPolicyScopeTypeName[15:33]: AuthenticatedHuman, + _RateLimitingPolicyScopeTypeLowerName[15:33]: AuthenticatedHuman, + _RateLimitingPolicyScopeTypeName[33:51]: AuthenticatedAgent, + _RateLimitingPolicyScopeTypeLowerName[33:51]: AuthenticatedAgent, +} + +var _RateLimitingPolicyScopeTypeNames = []string{ + _RateLimitingPolicyScopeTypeName[0:15], + _RateLimitingPolicyScopeTypeName[15:33], + _RateLimitingPolicyScopeTypeName[33:51], +} + +// RateLimitingPolicyScopeTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func RateLimitingPolicyScopeTypeString(s string) (RateLimitingPolicyScopeType, error) { + if val, ok := _RateLimitingPolicyScopeTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _RateLimitingPolicyScopeTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to RateLimitingPolicyScopeType values", s) +} + +// RateLimitingPolicyScopeTypeValues returns all values of the enum +func RateLimitingPolicyScopeTypeValues() []RateLimitingPolicyScopeType { + return _RateLimitingPolicyScopeTypeValues +} + +// RateLimitingPolicyScopeTypeStrings returns a slice of all String values of the enum +func RateLimitingPolicyScopeTypeStrings() []string { + strs := make([]string, len(_RateLimitingPolicyScopeTypeNames)) + copy(strs, _RateLimitingPolicyScopeTypeNames) + return strs +} + +// IsARateLimitingPolicyScopeType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i RateLimitingPolicyScopeType) IsARateLimitingPolicyScopeType() bool { + for _, v := range _RateLimitingPolicyScopeTypeValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for RateLimitingPolicyScopeType +func (i RateLimitingPolicyScopeType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for RateLimitingPolicyScopeType +func (i *RateLimitingPolicyScopeType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("RateLimitingPolicyScopeType should be a string, got %s", data) + } + + var err error + *i, err = RateLimitingPolicyScopeTypeString(s) + return err +} diff --git a/pkg/ratelimitingpolicies/rate_limiting_policy_service.go b/pkg/ratelimitingpolicies/rate_limiting_policy_service.go new file mode 100644 index 00000000..716a4669 --- /dev/null +++ b/pkg/ratelimitingpolicies/rate_limiting_policy_service.go @@ -0,0 +1,61 @@ +package ratelimitingpolicies + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/internal" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" +) + +const rateLimitingPoliciesTemplate = "/api/ratelimitingpolicies{/id}{?skip,take}" + +// GetByID returns the rate limiting policy that matches the given ID. +func GetByID(client newclient.Client, request GetRateLimitingPolicyByIdRequest) (*RateLimitingPolicy, error) { + if request.ID == "" { + return nil, internal.CreateRequiredParameterIsEmptyError("ID") + } + + path, pathError := client.URITemplateCache().Expand(rateLimitingPoliciesTemplate, request) + if pathError != nil { + return nil, pathError + } + + result, resultError := newclient.Get[RateLimitingPolicy](client.HttpSession(), path) + if resultError != nil { + return nil, resultError + } + + return result, nil +} + +// List returns a paginated collection of rate limiting policies. +func List(client newclient.Client, request ListRateLimitingPoliciesRequest) (*ListRateLimitingPoliciesResponse, error) { + path, pathError := client.URITemplateCache().Expand(rateLimitingPoliciesTemplate, request) + if pathError != nil { + return nil, pathError + } + + result, resultError := newclient.Get[ListRateLimitingPoliciesResponse](client.HttpSession(), path) + if resultError != nil { + return nil, resultError + } + + return result, nil +} + +// Modify changes the rate limiting policy that matches the given ID. +func Modify(client newclient.Client, command ModifyRateLimitingPolicyCommand) (*RateLimitingPolicy, error) { + if command.ID == "" { + return nil, internal.CreateRequiredParameterIsEmptyError("ID") + } + + path, pathError := client.URITemplateCache().Expand(rateLimitingPoliciesTemplate, command) + if pathError != nil { + return nil, pathError + } + + result, resultError := newclient.Put[RateLimitingPolicy](client.HttpSession(), path, command) + if resultError != nil { + return nil, resultError + } + + return result, nil +} diff --git a/pkg/ratelimitingpolicies/rate_limiting_policy_test.go b/pkg/ratelimitingpolicies/rate_limiting_policy_test.go new file mode 100644 index 00000000..9071d3e1 --- /dev/null +++ b/pkg/ratelimitingpolicies/rate_limiting_policy_test.go @@ -0,0 +1,128 @@ +package ratelimitingpolicies + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRateLimitingPolicyScopeTypeJsonMarshal(t *testing.T) { + cases := map[RateLimitingPolicyScopeType]string{ + Unauthenticated: `"Unauthenticated"`, + AuthenticatedHuman: `"AuthenticatedHuman"`, + AuthenticatedAgent: `"AuthenticatedAgent"`, + } + for scope, expected := range cases { + jsonValue, err := json.Marshal(scope) + require.NoError(t, err) + require.JSONEq(t, expected, string(jsonValue)) + + var enumValue RateLimitingPolicyScopeType + require.NoError(t, json.Unmarshal(jsonValue, &enumValue)) + require.Equal(t, scope, enumValue) + } +} + +func TestRateLimitingPolicyScopeTypeJsonUnmarshalInvalid(t *testing.T) { + var scope RateLimitingPolicyScopeType + require.Error(t, json.Unmarshal([]byte(`"NotAScope"`), &scope)) +} + +func TestRateLimitingPolicyMarshalRoundTrip(t *testing.T) { + policy := RateLimitingPolicy{ + ID: "RateLimitingPolicies-2", + IsBuiltIn: true, + Name: "Authenticated requests", + IsEnabled: true, + ScopeType: AuthenticatedHuman, + RequestsPerHour: 10_000, + BurstLimit: 5_000, + AuditMode: true, + } + + data, err := json.Marshal(policy) + require.NoError(t, err) + + expected := `{ + "Id": "RateLimitingPolicies-2", + "IsBuiltIn": true, + "Name": "Authenticated requests", + "IsEnabled": true, + "ScopeType": "AuthenticatedHuman", + "RequestsPerHour": 10000, + "BurstLimit": 5000, + "AuditMode": true + }` + require.JSONEq(t, expected, string(data)) + + var received RateLimitingPolicy + require.NoError(t, json.Unmarshal(data, &received)) + require.Equal(t, policy, received) +} + +func TestModifyRateLimitingPolicyCommandMarshal(t *testing.T) { + command := ModifyRateLimitingPolicyCommand{ + ID: "RateLimitingPolicies-1", + Name: "Changed", + IsEnabled: true, + ScopeType: Unauthenticated, + RequestsPerHour: 123, + BurstLimit: 456, + AuditMode: true, + } + + data, err := json.Marshal(command) + require.NoError(t, err) + require.JSONEq(t, `{ + "Name": "Changed", + "IsEnabled": true, + "ScopeType": "Unauthenticated", + "RequestsPerHour": 123, + "BurstLimit": 456, + "AuditMode": true + }`, string(data)) +} + +func TestListRateLimitingPoliciesResponseUnmarshal(t *testing.T) { + payload := `{ + "ItemType": "RateLimitingPolicy", + "TotalResults": 2, + "ItemsPerPage": 30, + "NumberOfPages": 1, + "LastPageNumber": 0, + "Items": [ + { + "Id": "RateLimitingPolicies-1", + "Name": "Human", + "IsBuiltIn": true, + "ScopeType": "AuthenticatedHuman", + "IsEnabled": true, + "RequestsPerHour": 1000, + "BurstLimit": 50, + "AuditMode": true + }, + { + "Id": "RateLimitingPolicies-2", + "Name": "Agent", + "IsBuiltIn": true, + "ScopeType": "AuthenticatedAgent", + "IsEnabled": false, + "RequestsPerHour": 500, + "BurstLimit": 25, + "AuditMode": false + } + ] + }` + + var response ListRateLimitingPoliciesResponse + require.NoError(t, json.Unmarshal([]byte(payload), &response)) + + require.Equal(t, 2, response.TotalResults) + require.Len(t, response.Items, 2) + require.Equal(t, AuthenticatedHuman, response.Items[0].ScopeType) + require.Equal(t, AuthenticatedAgent, response.Items[1].ScopeType) + require.False(t, response.Items[1].IsEnabled) + require.True(t, response.Items[0].AuditMode) + require.False(t, response.Items[1].AuditMode) +} diff --git a/test/e2e/rate_limiting_policy_service_test.go b/test/e2e/rate_limiting_policy_service_test.go new file mode 100644 index 00000000..96c0cccd --- /dev/null +++ b/test/e2e/rate_limiting_policy_service_test.go @@ -0,0 +1,188 @@ +package e2e + +import ( + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/ratelimitingpolicies" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListRateLimitingPolicies(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + response, err := ratelimitingpolicies.List( + client, + ratelimitingpolicies.ListRateLimitingPoliciesRequest{}) + + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Len(t, response.Items, 3) + assert.Equal(t, 30, response.ItemsPerPage) + assert.Equal(t, 3, response.TotalResults) + assert.Equal(t, 1, response.NumberOfPages) + assert.Equal(t, 0, response.LastPageNumber) + assert.Equal(t, "RateLimitingPolicy", response.ItemType) +} + +func TestListRateLimitingPoliciesSkipTake(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + response1, err1 := ratelimitingpolicies.List( + client, + ratelimitingpolicies.ListRateLimitingPoliciesRequest{ + Skip: 0, + Take: 1, + }) + + assert.NoError(t, err1) + assert.NotNil(t, response1) + assert.Len(t, response1.Items, 1) + assert.Equal(t, 1, response1.ItemsPerPage) + assert.Equal(t, 3, response1.TotalResults) + assert.Equal(t, 3, response1.NumberOfPages) + assert.Equal(t, 2, response1.LastPageNumber) + assert.Equal(t, "RateLimitingPolicies-1", response1.Items[0].ID) + + response2, err2 := ratelimitingpolicies.List( + client, + ratelimitingpolicies.ListRateLimitingPoliciesRequest{ + Skip: 1, + Take: 1, + }) + + assert.NoError(t, err2) + assert.NotNil(t, response2) + assert.Len(t, response2.Items, 1) + assert.Equal(t, 1, response2.ItemsPerPage) + assert.Equal(t, 3, response2.TotalResults) + assert.Equal(t, 3, response2.NumberOfPages) + assert.Equal(t, 2, response2.LastPageNumber) + assert.Equal(t, "RateLimitingPolicies-2", response2.Items[0].ID) +} + +func TestGetRateLimitingPolicyByID(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + listResponse, listError := ratelimitingpolicies.List( + client, + ratelimitingpolicies.ListRateLimitingPoliciesRequest{}) + + assert.NoError(t, listError) + assert.NotNil(t, listResponse) + assert.Len(t, listResponse.Items, 3) + + for _, policy := range listResponse.Items { + getResponse, getError := ratelimitingpolicies.GetByID( + client, + ratelimitingpolicies.GetRateLimitingPolicyByIdRequest{ + ID: policy.ID, + }) + + assert.NoError(t, getError) + assert.NotNil(t, getResponse) + assert.Equal(t, policy.ID, getResponse.ID) + } +} + +func TestModifyRateLimitingPolicy(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + listResponse, listError := ratelimitingpolicies.List( + client, + ratelimitingpolicies.ListRateLimitingPoliciesRequest{ + Take: 1, + }) + + assert.NoError(t, listError) + assert.NotNil(t, listResponse) + assert.Len(t, listResponse.Items, 1) + policy := listResponse.Items[0] + + testModify := func(isEnabled bool, requestsPerHour int, burstLimit int, auditMode bool) { + modifyResponse, modifyError := ratelimitingpolicies.Modify( + client, + ratelimitingpolicies.ModifyRateLimitingPolicyCommand{ + ID: policy.ID, + Name: policy.Name, + ScopeType: policy.ScopeType, + + IsEnabled: isEnabled, + RequestsPerHour: requestsPerHour, + BurstLimit: burstLimit, + AuditMode: auditMode, + }) + assert.NoError(t, modifyError) + assert.NotNil(t, modifyResponse) + assert.Equal(t, policy.ID, modifyResponse.ID) + assert.Equal(t, policy.Name, modifyResponse.Name) + assert.Equal(t, policy.ScopeType, modifyResponse.ScopeType) + assert.Equal(t, isEnabled, modifyResponse.IsEnabled) + assert.Equal(t, requestsPerHour, modifyResponse.RequestsPerHour) + assert.Equal(t, burstLimit, modifyResponse.BurstLimit) + assert.Equal(t, auditMode, modifyResponse.AuditMode) + + // Ensure the modify actually set those properties with a follow-up GET + getResponse, getError := ratelimitingpolicies.GetByID( + client, + ratelimitingpolicies.GetRateLimitingPolicyByIdRequest{ + ID: policy.ID, + }) + + assert.NoError(t, getError) + assert.NotNil(t, getResponse) + assert.Equal(t, policy.ID, getResponse.ID) + assert.Equal(t, policy.Name, getResponse.Name) + assert.Equal(t, policy.ScopeType, getResponse.ScopeType) + assert.Equal(t, isEnabled, getResponse.IsEnabled) + assert.Equal(t, requestsPerHour, getResponse.RequestsPerHour) + assert.Equal(t, burstLimit, getResponse.BurstLimit) + assert.Equal(t, auditMode, getResponse.AuditMode) + } + + // Modify twice in case the properties were already set to what we modified them to + testModify(false, 12, 34, true) + testModify(true, 56, 78, false) +} + +func TestModifyRateLimitingPolicyError(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + listResponse, listError := ratelimitingpolicies.List( + client, + ratelimitingpolicies.ListRateLimitingPoliciesRequest{ + Take: 1, + }) + + assert.NoError(t, listError) + assert.NotNil(t, listResponse) + assert.Len(t, listResponse.Items, 1) + + policy := listResponse.Items[0] + assert.Equal(t, true, policy.IsBuiltIn) + + modifyResponse, modifyError := ratelimitingpolicies.Modify( + client, + ratelimitingpolicies.ModifyRateLimitingPolicyCommand{ + ID: policy.ID, + Name: "New name", // Not allowed to change built-in policy names + ScopeType: policy.ScopeType, + + IsEnabled: policy.IsEnabled, + RequestsPerHour: policy.RequestsPerHour, + BurstLimit: policy.BurstLimit, + AuditMode: policy.AuditMode, + }) + assert.Nil(t, modifyResponse) + assert.Error(t, modifyError) + assert.Equal( + t, + "The name of a built-in rate limiting policy cannot be changed.", + modifyError.(*core.APIError).Errors[0]) +}