diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f4f67fa..4e5a813 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -33,7 +33,7 @@ jobs: run: make build - name: Test - run: make unit-test + run: make unittest - name: Generate coverage report run: make cover diff --git a/go.mod b/go.mod index fc66148..d8b9c2a 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,15 @@ module github.com/szaydel/lyvecloud go 1.20 -require github.com/steinfletcher/apitest v1.5.15 +require ( + github.com/k0kubun/pp/v3 v3.2.0 + github.com/steinfletcher/apitest v1.5.15 +) -require github.com/davecgh/go-spew v1.1.1 // indirect +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect + golang.org/x/text v0.3.7 // indirect +) diff --git a/go.sum b/go.sum index 7d2044e..d6776ad 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/k0kubun/pp/v3 v3.2.0 h1:h33hNTZ9nVFNP3u2Fsgz8JXiF5JINoZfFq4SvKJwNcs= +github.com/k0kubun/pp/v3 v3.2.0/go.mod h1:ODtJQbQcIRfAD3N+theGCV1m/CBxweERz2dapdz1EwA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/steinfletcher/apitest v1.5.15 h1:AAdTN0yMbf0VMH/PMt9uB2I7jljepO6i+5uhm1PjH3c= github.com/steinfletcher/apitest v1.5.15/go.mod h1:mF+KnYaIkuHM0C4JgGzkIIOJAEjo+EA5tTjJ+bHXnQc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/lyveapi/account.go b/lyveapi/account.go index 2278987..4505192 100644 --- a/lyveapi/account.go +++ b/lyveapi/account.go @@ -99,8 +99,11 @@ func (client *Client) GetServiceAccount(svcAcctId string) (*ServiceAcct, error) return acctInfo, nil } +// UpdateServiceAccount updates an existing service account with changed +// settings in updatesReq and returns a nil and an error if decoding of the +// response fails, otherwise a decoded object and nil error is returned. func (client *Client) UpdateServiceAccount( - svcAcctId string, changes ServiceAcctUpdateReq) error { + svcAcctId string, updatesReq *ServiceAcct) error { client.mtx.RLock() endpoint := client.apiUrl + "/service-accounts" url := endpoint + "/" + svcAcctId @@ -111,8 +114,7 @@ func (client *Client) UpdateServiceAccount( var rdr io.ReadCloser var data []byte - if data, err = json.Marshal(changes); err != nil { - + if data, err = json.Marshal(updatesReq); err != nil { return err } diff --git a/lyveapi/common.go b/lyveapi/common.go index c6a45a3..0e74836 100644 --- a/lyveapi/common.go +++ b/lyveapi/common.go @@ -8,6 +8,10 @@ import ( "net/http" ) +// decodeFailedApiResponse takes a response object from the API and converts it +// into a more user-friendly native representation. It returns an exported +// error type, which has methods for accessing the status code from the API and +// the message. func decodeFailedApiResponse(resp *http.Response) error { body := &bytes.Buffer{} tRdr := io.TeeReader(resp.Body, body) @@ -50,6 +54,9 @@ func decodeFailedApiResponse(resp *http.Response) error { return errors.New("dubious response from the API: " + string(bodySlc)) } +// apiRequestAuthenticated packages up requests to the API without attempting +// to authenticate first. A valid token is required to complete requests +// successfully. func apiRequestAuthenticated( token, method, url string, payload []byte) (io.ReadCloser, error) { headers := map[string][]string{ @@ -93,6 +100,13 @@ func apiRequestAuthenticated( return nil, err } + // DEBUGGING: + // buf, _ := io.ReadAll(resp.Body) + // body := bytes.NewBuffer(buf) + // resp.Body = io.NopCloser(body) + + // log.Printf("DEBUG (response body): %s", body.String()) + // Check response from the API and if resp.StatusCode != http.StatusOK, we // are going to have access to the error object which we should return to // the caller. @@ -121,6 +135,10 @@ func apiRequestAuthenticated( return resp.Body, nil } +// Authenticate attempts to authenticate against the API and returns a token +// upon successful authentication. The API will expire this token after 24 +// hours. This expiration period appears to be fixed but Lyve Cloud may change +// it at any time. func Authenticate(credentials *Credentials, authEndpointUrl string) (*Token, error) { var data *bytes.Buffer diff --git a/lyveapi/common_test.go b/lyveapi/common_test.go index 07920ba..eaf2c01 100644 --- a/lyveapi/common_test.go +++ b/lyveapi/common_test.go @@ -9,6 +9,7 @@ import ( func Test_decodeFailedApiResponse(t *testing.T) { t.Parallel() + resp := &http.Response{} type testCase struct { diff --git a/lyveapi/errors.go b/lyveapi/errors.go index aa9960c..e46e9c9 100644 --- a/lyveapi/errors.go +++ b/lyveapi/errors.go @@ -6,6 +6,7 @@ import ( ) var ( + InvalidPermissionsErrMsg = "permission IDs are not known to the API; fetch a list of permissions to obtain correct IDs" InvalidTokenErrMsg = "token presented to the API is invalid" ExpiredTokenErrMsg = "token presented to the API is already expired" AuthenticationFailedErrMsg = "authentication was unsuccessful; check supplied credentials" @@ -22,6 +23,7 @@ var ( var errorCodesToErrors = map[string]string{ "ExpiredToken": ExpiredTokenErrMsg, "InvalidToken": InvalidTokenErrMsg, + "InvalidPermissions": InvalidPermissionsErrMsg, "AuthenticationFailed": AuthenticationFailedErrMsg, // We are seemingly getting a trailing space in auth failure responses. "AuthenticationFailed ": AuthenticationFailedErrMsg, diff --git a/lyveapi/errors_test.go b/lyveapi/errors_test.go index 7d3f023..78cd298 100644 --- a/lyveapi/errors_test.go +++ b/lyveapi/errors_test.go @@ -3,7 +3,6 @@ package lyveapi import "testing" func TestApiCallFailedErrorMsg(t *testing.T) { - t.Parallel() const msg1 = "This is a test message" diff --git a/lyveapi/monotime/time.go b/lyveapi/monotime/time.go new file mode 100644 index 0000000..4ef5a1f --- /dev/null +++ b/lyveapi/monotime/time.go @@ -0,0 +1,14 @@ +package monotime + +import ( + "time" + _ "unsafe" +) + +//go:noescape +//go:linkname nanotime runtime.nanotime +func nanotime() int64 + +func Monotonic() time.Duration { + return time.Duration(nanotime()) +} diff --git a/lyveapi/types.go b/lyveapi/types.go index 1df6a93..9e863d0 100644 --- a/lyveapi/types.go +++ b/lyveapi/types.go @@ -1,5 +1,12 @@ package lyveapi +import ( + "strconv" + "time" + + "github.com/szaydel/lyvecloud/lyveapi/monotime" +) + type Credentials struct { AccountId string `json:"accountId"` AccessKey string `json:"accessKey"` @@ -21,19 +28,20 @@ type CreateServiceAcctResp struct { // Depending on the API used, some of these fields may or may not be used. type ServiceAcct struct { - Id string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Enabled bool `json:"enabled,omitempty"` - ReadyState bool `json:"readyState,omitempty"` - Permissions []string `json:"permissions,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + ExpirationDate string `json:"expirationDate"` + ReadyState bool `json:"readyState"` + Permissions []string `json:"permissions,omitempty"` } -type ServiceAcctUpdateReq struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Permissions []string `json:"permissions,omitempty"` -} +// type ServiceAcctUpdateReq struct { +// Name string `json:"name"` +// Description string `json:"description,omitempty"` +// Permissions []string `json:"permissions,omitempty"` +// } type ServiceAcctList []ServiceAcct @@ -78,15 +86,27 @@ type Token struct { ExpirationSec string `json:"expirationSec,omitempty"` } +func (t Token) ExpiresMonoNanos() (time.Duration, error) { + var err error + var secs int64 + + if secs, err = strconv.ParseInt(t.ExpirationSec, 10, 64); err != nil { + return 0, err + } + + return time.Duration(secs*1e9) + monotime.Monotonic(), nil +} + // Bucket describes usage of a particular bucket. type Bucket struct { Name string `json:"name"` // Name is the name of the given bucket UsageGB float64 `json:"usageGB"` // UsageGB reports GBs used by bucket } -// UsageInBytes converts from the gigabytes reported by the API to bytes. A -// gigabyte (GB) is 1e9 bytes. -func (b Bucket) UsageInBytes() float64 { +// BytesUsed converts from the gigabytes reported by the API to bytes. The API +// uses base10 values for reporting usage, where a Gigabyte is 1000 Megabytes +// and 1 Megabyte is 1000 Kilobytes, etc. +func (b Bucket) BytesUsed() float64 { return 1e9 * b.UsageGB } @@ -111,32 +131,72 @@ type SubAccount struct { Trial int `json:"trial,omitempty"` } +type Buckets []Bucket + +// BytesUsedCombined returns a sum of usages across all buckets in the given +// list of buckets. +func (b Buckets) BytesUsedCombined() uint64 { + var tot float64 + for _, b := range b { + tot += b.BytesUsed() + } + + return uint64(tot) +} + +func (b Buckets) BytesUsedByName(name string) uint64 { + for _, b := range b { + if b.Name == name { + return uint64(b.UsageGB) + } + } + return 0 +} + +type Usages []Usage + +func (us Usages) MonthlyTotalUsageGB() map[MonthYearTuple]float64 { + m := make(map[MonthYearTuple]float64, len(us)) + for _, u := range us { + my := MonthYearTuple{u.Month, u.Year} + m[my] = u.TotalUsageGB + } + return m +} + // Usage reports various bucket usage details and included fields will vary // depending upon whether the query is for current usage or monthly usage. type Usage struct { // Year only used in monthly usage report - Year uint16 `json:"year,omitempty"` + Year Year `json:"year,omitempty"` // Month only used in monthly usage report Month Month `json:"month,omitempty"` // NumBuckets only used in current usage report NumBuckets int `json:"numBuckets,omitempty"` // TotalUsageGB is the amount of space consumed in Gigabytes (hopefully) TotalUsageGB float64 `json:"totalUsageGB"` - Buckets []Bucket `json:"buckets,omitempty"` + Buckets Buckets `json:"buckets,omitempty"` SubAccounts []SubAccount `json:"subAccounts,omitempty"` } +// BytesUsedCombined returns combined usage in bytes across all buckets in the +// given list of buckets. The API uses base10 values for reporting usage, where +// a Gigabyte is 1000 Megabytes and 1 Megabyte is 1000 Kilobytes, etc. +func (u Usage) BytesUsedCombined() uint64 { + return uint64(u.TotalUsageGB * 1e9) +} + // MonthlyUsageResp is the response object containing by month usage // information by bucket. If the account performing the query is a sub-account, // the SubAccounts field will not contain any data, because sub-accounts cannot // contain sub-accounts. type MonthlyUsageResp struct { // UsageByBucket contains a slice of Usage structs for all buckets under the master account or the sub-account for each month in the range. - UsageByBucket []Usage `json:"usageByBucket,omitempty"` + UsageByBucket Usages `json:"usageByBucket,omitempty"` // UsageBySubAccount will be an empty slice unless the data is requested // with credentials belonging to a "master" account. In most instances data // will be queried with credentials belonging to a sub-account. - UsageBySubAccount []Usage `json:"usageBySubAccount,omitempty"` + UsageBySubAccount Usages `json:"usageBySubAccount,omitempty"` } // CurrentUsageResp is the response object containing usage information by @@ -153,4 +213,16 @@ type CurrentUsageResp struct { TotalUsageGB float64 `json:"totalUsageGB"` SubAccounts []SubAccount `json:"subAccounts,omitempty"` } `json:"usageBySubAccount,omitempty"` + // UsageBySubAccount UsageBySubAccount `json:"usageBySubAccount,omitempty"` } + +// type UsageBySubAccount struct { +// TotalUsageGB float64 `json:"totalUsageGB"` +// SubAccounts []SubAccount `json:"subAccounts,omitempty"` +// } + +// // BytesUsedCombined returns combined usage across all buckets for the given +// // sub-account as bytes. +// func (usages UsageBySubAccount) BytesUsedCombined() uint64 { +// return uint64(usages.TotalUsageGB * 1e9) +// } diff --git a/lyveapi/types_test.go b/lyveapi/types_test.go index 19d4ea3..609efbc 100644 --- a/lyveapi/types_test.go +++ b/lyveapi/types_test.go @@ -3,9 +3,12 @@ package lyveapi import ( "math" "testing" + "time" + + "github.com/szaydel/lyvecloud/lyveapi/monotime" ) -func TestBucketUsageInBytes(t *testing.T) { +func TestBucketBytesUsed(t *testing.T) { t.Parallel() const expected = 1.45656e+12 @@ -14,9 +17,38 @@ func TestBucketUsageInBytes(t *testing.T) { UsageGB: 1456.56, } - t.Log(almostEqual(expected, bucket.UsageInBytes(), 0.01)) + if !almostEqual(expected, bucket.BytesUsed(), 0.01) { + t.Errorf("Usage expected to be ~ %v; got %v", + expected, bucket.BytesUsed()) + } } func almostEqual(expected, actual, epsilon float64) bool { return math.Abs(expected-actual) < epsilon } + +func TestTokenExpiresMonoNanos(t *testing.T) { + t.Parallel() + + const low time.Duration = 12000000000000 + const high time.Duration = 12000000100000 + + token := Token{ + Token: "mock-token-string", + ExpirationSec: "12000", + } + + nowMonotonic := monotime.Monotonic() + e, _ := token.ExpiresMonoNanos() + if !approx(e-nowMonotonic, low, high) { + t.Errorf("Value %v not in the range '%v - %v'", + e-nowMonotonic, low, high) + } +} + +func approx(n, low, high time.Duration) bool { + if n < low || n > high { + return false + } + return true +} diff --git a/lyveapi/usage.go b/lyveapi/usage.go index 3a0d146..ee98e65 100644 --- a/lyveapi/usage.go +++ b/lyveapi/usage.go @@ -12,8 +12,7 @@ import ( type Month uint8 const ( - INVALID Month = iota - JAN + JAN Month = iota FEB MAR APR @@ -29,21 +28,28 @@ const ( func (m Month) String() string { return map[Month]string{ - JAN: "1", - FEB: "2", - MAR: "3", - APR: "4", - MAY: "5", - JUN: "6", - JUL: "7", - AUG: "8", - SEP: "9", - OCT: "10", - NOV: "11", - DEC: "12", + JAN: "0", + FEB: "1", + MAR: "2", + APR: "3", + MAY: "4", + JUN: "5", + JUL: "6", + AUG: "7", + SEP: "8", + OCT: "9", + NOV: "10", + DEC: "11", }[m] } +type Year uint16 + +type MonthYearTuple struct { + Month Month + Year Year +} + // GetMonthlyUsage retrieves historical data, in monthly increments // within the provided range of time. This range is limited to a maximum of six // (6) months and a query spanning a larger range will fail with an diff --git a/lyveapi_test/account_test.go b/lyveapi_test/account_test.go new file mode 100644 index 0000000..8da22e1 --- /dev/null +++ b/lyveapi_test/account_test.go @@ -0,0 +1,421 @@ +package lyveapi_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/steinfletcher/apitest" + "github.com/szaydel/lyvecloud/lyveapi" +) + +const mockSvcAcctUpdateAcctId = "037c16bc-1409-4997-a8d7-523b985e32d9" +const mockOneSvcAccountUri = mockSvcAcctsUri + "/" + mockSvcAcctUpdateAcctId +const mockSvcAccountEnableDisableUri = mockOneSvcAccountUri + "/" + "enabled" + +func TestSvcAccounts(t *testing.T) { + const sGetSvcAcctRespBody = `{ + "id": "c79ab3e6-f3b4-4265-81bd-db3eee8ce213", + "name": "alphatest3", + "description": "alphatest3 service account", + "enabled": true, + "expirationDate": "", + "readyState": false, + "permissions": [ + "4440bab8-6525-4760-bb26-22bedfd5195a" + ] + }` + const sCreateSvcAcctRespBody = `{ + "id": "c79ab3e6-f3b4-4265-81bd-db3eee8ce213", + "accessKey": "EXHJ4WFC3UE3891B", + "secret": "KVKIBHEPUYKXVKYGS4I8C021DFZVNHL3", + "expirationDate": "" + }` + const sListSvcAcctsRespBody = `[ + { + "description": "alpha test service account", + "enabled": true, + "expirationDate": "", + "id": "b99ca0e6-d6ed-4c1c-9687-fa90fec287e1", + "name": "alpha", + "readyState": false + }, + { + "description": "beta test service account", + "enabled": true, + "expirationDate": "", + "id": "420573f9-953d-4e91-ae79-fd81a91dcbf7", + "name": "beta", + "readyState": true + } + ]` + + var svcAcctCreateSuccessMock = apitest.NewMock(). + Post(mockSvcAcctsUri). + RespondWith(). + Body(sCreateSvcAcctRespBody). + Status(http.StatusOK). + End() + + var svcAcctDeleteSuccessMock = apitest.NewMock(). + Delete(mockOneSvcAccountUri). + RespondWith(). + Status(http.StatusOK). + End() + + var svcAcctDisableSuccessMock = apitest.NewMock(). + Delete(mockSvcAccountEnableDisableUri). + RespondWith(). + Status(http.StatusOK). + End() + + var svcAcctEnableSuccessMock = apitest.NewMock(). + Put(mockSvcAccountEnableDisableUri). + RespondWith(). + Status(http.StatusOK). + End() + + var svcAcctUpdateSuccessMock = apitest.NewMock(). + Put(mockSvcAcctsUri). + RespondWith(). + Status(http.StatusOK). + End() + + var svcAcctsListMock = apitest.NewMock(). + Get(mockSvcAcctsUri). + RespondWith(). + Body(sListSvcAcctsRespBody). + Status(http.StatusOK). + End() + + var svcAcctGetOneMock = apitest.NewMock(). + Get(mockOneSvcAccountUri). + RespondWith(). + Body(sGetSvcAcctRespBody). + Status(http.StatusOK). + End() + + apitest.New("get service account"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctGetOneMock). + Handler(svcAccountsHandler()). + Get(mockOneSvcAccountUri). + Expect(t). + Body(sGetSvcAcctRespBody). + Status(http.StatusOK). + End() + + apitest.New("create service account"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctCreateSuccessMock). + Handler(svcAccountsHandler()). + Post(mockSvcAcctsUri). + Expect(t). + Body(sCreateSvcAcctRespBody). + Status(http.StatusOK). + End() + + apitest.New("delete service account"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctDeleteSuccessMock). + Handler(svcAccountsHandler()). + Delete(mockOneSvcAccountUri). + Expect(t). + Status(http.StatusOK). + End() + + apitest.New("disable service account"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctDisableSuccessMock). + Handler(svcAccountsHandler()). + Delete(mockSvcAccountEnableDisableUri). + Expect(t). + Status(http.StatusOK). + End() + + apitest.New("enable service account"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctEnableSuccessMock). + Handler(svcAccountsHandler()). + Put(mockSvcAccountEnableDisableUri). + Expect(t). + Status(http.StatusOK). + End() + + apitest.New("updates to service account"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctUpdateSuccessMock). + Handler(svcAccountsHandler()). + Put(mockOneSvcAccountUri). + Expect(t). + Status(http.StatusOK). + End() + + apitest.New("list service accounts"). + Report(apitest.SequenceDiagram()). + Mocks(svcAcctsListMock). + Handler(svcAccountsHandler()). + Get(mockSvcAcctsUri). + Expect(t). + Body(sListSvcAcctsRespBody). + Status(http.StatusOK). + End() +} + +func svcAccountsHandler() *http.ServeMux { + var handler = http.NewServeMux() + + handler.HandleFunc(mockSvcAccountEnableDisableUri, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodDelete: // Disable the service account + if err := invokeClientToDisableServiceAccount(mockSvcAccountEnableDisableUri); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + + case http.MethodPut: // Enable the service account + if err := invokeClientToEnableServiceAccount(mockSvcAccountEnableDisableUri); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + } + }) + + handler.HandleFunc("/service-accounts/", func(w http.ResponseWriter, r *http.Request) { + var svcAcct lyveapi.ServiceAcct + + switch r.Method { + case http.MethodDelete: // Delete the service account + if err := invokeClientToDeleteServiceAccount(mockOneSvcAccountUri); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + case http.MethodPut: + req := &lyveapi.ServiceAcct{ + Name: "new-test-name", + Description: "This is a test account", + } + + if err := invokeClientToUpdateServiceAccount( + mockOneSvcAccountUri, req, &svcAcct); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + + case http.MethodGet: + if err := invokeClientToGetServiceAccount( + mockOneSvcAccountUri, &svcAcct); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } else { + bytes, _ := json.Marshal(&svcAcct) + _, err := w.Write(bytes) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + } + } + }) + + handler.HandleFunc("/service-accounts", func(w http.ResponseWriter, r *http.Request) { + var svcAcctsList []lyveapi.ServiceAcct + var svcAcctCreateReq lyveapi.CreateServiceAcctReq + var svcAcctCreateResp lyveapi.CreateServiceAcctResp + + switch r.Method { + case http.MethodGet: + if err := invokeClientToListServiceAccounts(mockSvcAcctsUri, &svcAcctsList); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + bytes, _ := json.Marshal(svcAcctsList) + _, err := w.Write(bytes) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + + case http.MethodPost: + svcAcctCreateReq = lyveapi.CreateServiceAcctReq{ + Name: "alphatest", + Description: "alphatest description", + Permissions: []string{"fake-permission-id"}, + } + if err := invokeClientToCreateServiceAccount( + mockSvcAcctsUri, &svcAcctCreateReq, &svcAcctCreateResp); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } else { + bytes, _ := json.Marshal(&svcAcctCreateResp) + _, err := w.Write(bytes) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + } + + w.WriteHeader(http.StatusOK) + } + + }) + + return handler +} + +func invokeClientToDeleteServiceAccount(path string) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockOneSvcAccountUri: + tokens := strings.Split(path, "/") + svcAcctId := tokens[2] + if err := client.DeleteServiceAccount(svcAcctId); err != nil { + return err + } else { + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} + +func invokeClientToDisableServiceAccount(path string) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockSvcAccountEnableDisableUri: + tokens := strings.Split(path, "/") + svcAcctId := tokens[2] + if err := client.DisableServiceAccount(svcAcctId); err != nil { + return err + } else { + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} + +func invokeClientToEnableServiceAccount(path string) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockSvcAccountEnableDisableUri: + tokens := strings.Split(path, "/") + svcAcctId := tokens[2] + if err := client.EnableServiceAccount(svcAcctId); err != nil { + return err + } else { + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} + +func invokeClientToUpdateServiceAccount( + path string, updates *lyveapi.ServiceAcct, response interface{}) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockOneSvcAccountUri: + tokens := strings.Split(path, "/") + svcAcctId := tokens[2] + if err := client.UpdateServiceAccount(svcAcctId, updates); err != nil { + return err + } else { + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} + +func invokeClientToListServiceAccounts( + path string, + response interface{}, +) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockSvcAcctsUri: + if svcAccts, err := client.ListServiceAccounts(); err != nil { + return err + } else { + var respSvcAcctsList *[]lyveapi.ServiceAcct + respSvcAcctsList = response.(*[]lyveapi.ServiceAcct) + for _, v := range *svcAccts { + *respSvcAcctsList = append(*respSvcAcctsList, v) + } + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} + +func invokeClientToCreateServiceAccount( + path string, + createReq *lyveapi.CreateServiceAcctReq, + resp interface{}) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockSvcAcctsUri: + if createResp, err := client.CreateServiceAccount(createReq); err != nil { + return err + } else { + resp.(*lyveapi.CreateServiceAcctResp).Id = createResp.Id + resp.(*lyveapi.CreateServiceAcctResp).AccessKey = createResp.AccessKey + resp.(*lyveapi.CreateServiceAcctResp).Secret = createResp.Secret + resp.(*lyveapi.CreateServiceAcctResp).ExpirationDate = createResp.ExpirationDate + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} + +func invokeClientToGetServiceAccount(path string, resp interface{}) error { + var client = &lyveapi.Client{} + client.SetApiURL(mockApiEndpointUrl) + + switch path { + case mockOneSvcAccountUri: + tokens := strings.Split(path, "/") + svcAcctId := tokens[2] + if getResp, err := client.GetServiceAccount(svcAcctId); err != nil { + return err + } else { + resp.(*lyveapi.ServiceAcct).Id = getResp.Id + resp.(*lyveapi.ServiceAcct).Name = getResp.Name + resp.(*lyveapi.ServiceAcct).Description = getResp.Description + resp.(*lyveapi.ServiceAcct).Enabled = getResp.Enabled + resp.(*lyveapi.ServiceAcct).ReadyState = getResp.ReadyState + for _, p := range getResp.Permissions { + resp.(*lyveapi.ServiceAcct).Permissions = + append(resp.(*lyveapi.ServiceAcct).Permissions, p) + } + return nil + } + default: + return fmt.Errorf("no matches for given API path: %q", path) + } +} diff --git a/lyveapi_test/global_test.go b/lyveapi_test/global_test.go index 1b42a1a..d55b230 100644 --- a/lyveapi_test/global_test.go +++ b/lyveapi_test/global_test.go @@ -16,6 +16,7 @@ const ( mockPermissionDelUri2 = mockPermissionsUri + "/" + mockPermissionIdBeta mockCurrentUsageUri = "/usage/current" mockMonthlyUsageUri = "/usage/monthly" + mockSvcAcctsUri = "/service-accounts" createPermBadPolicyRespJSONObj = `{ "code": "InternalError", diff --git a/lyveapi_test/usage_test.go b/lyveapi_test/usage_test.go index 7905984..2e67df1 100644 --- a/lyveapi_test/usage_test.go +++ b/lyveapi_test/usage_test.go @@ -2,31 +2,17 @@ package lyveapi_test import ( "encoding/json" + "math" "net/http" "net/url" "strconv" + "strings" "testing" "github.com/steinfletcher/apitest" "github.com/szaydel/lyvecloud/lyveapi" ) -// var monthFromNumericString = map[string]lyveapi.Month{ -// "0": lyveapi.INVALID, -// "1": lyveapi.JAN, -// "2": lyveapi.FEB, -// "3": lyveapi.MAR, -// "4": lyveapi.APR, -// "5": lyveapi.MAY, -// "6": lyveapi.JUN, -// "7": lyveapi.JUL, -// "8": lyveapi.AUG, -// "9": lyveapi.SEP, -// "10": lyveapi.OCT, -// "11": lyveapi.NOV, -// "12": lyveapi.DEC, -// } - func TestUsage(t *testing.T) { const currentUsageRespBody = `{ "usageByBucket": { @@ -357,3 +343,177 @@ func usageGet(path string, url *url.URL, response interface{}) error { return pathUnmatchedErr } + +func Test_UsageBytesUsedCombined(t *testing.T) { + t.Parallel() + + const expectedUsageBytes = 77398902000000 + usage := lyveapi.Buckets{ + lyveapi.Bucket{ + Name: "a", + UsageGB: 493.52, + }, + lyveapi.Bucket{ + Name: "b", + UsageGB: 34.67, + }, + lyveapi.Bucket{ + Name: "c", + UsageGB: 67245.034, + }, + lyveapi.Bucket{ + Name: "d", + UsageGB: 9625.678, + }, + } + if usage.BytesUsedCombined() != expectedUsageBytes { + t.Errorf("Unexpected usage; expected: %d, actual: %d", expectedUsageBytes, usage.BytesUsedCombined()) + } +} + +func Test_UsageBytesUsedCombinedRangeLimit(t *testing.T) { + t.Parallel() + + // Upper limit of what we can support is 'math.MaxUint64'. + const upperLimit uint64 = math.MaxUint64 + const lowerAcceptableLimit uint64 = 1 << 63 + + const expectedUsageLimit = upperLimit + usage := lyveapi.Buckets{ + lyveapi.Bucket{ + Name: "a", + UsageGB: float64(upperLimit), + }, + lyveapi.Bucket{ + Name: "b", + UsageGB: float64(upperLimit), + }, + lyveapi.Bucket{ + Name: "c", + UsageGB: float64(upperLimit), + }, + } + + if usage.BytesUsedCombined() < lowerAcceptableLimit { + t.Errorf("Expected combined usage to support at least: %d bytes, actual: %d", + lowerAcceptableLimit, usage.BytesUsedCombined()) + } +} + +func Test_UsagesMonthlyTotalGB(t *testing.T) { + t.Parallel() + + var expectedUsagesByMonth = map[lyveapi.MonthYearTuple]float64{ + {Month: 8, Year: 2023}: 36466.3927, + {Month: 9, Year: 2023}: 36498.950999999994, + {Month: 10, Year: 2023}: 36676.30099999999, + {Month: 11, Year: 2023}: 34824.49, + } + const encodedUsageByBucket = `{ + "usageByBucket": [ + { + "buckets": [ + { + "name": "alpha", + "usageGB": 534.52 + }, + { + "name": "beta", + "usageGB": 34567.2827 + }, + { + "name": "gamma", + "usageGB": 516.24 + }, + { + "name": "delta", + "usageGB": 848.35 + } + ], + "month": 8, + "totalUsageGB": 36466.3927, + "year": 2023 + }, + { + "buckets": [ + { + "name": "alpha", + "usageGB": 534.52 + }, + { + "name": "beta", + "usageGB": 34575.261 + }, + { + "name": "gamma", + "usageGB": 516.24 + }, + { + "name": "delta", + "usageGB": 872.93 + } + ], + "month": 9, + "totalUsageGB": 36498.950999999994, + "year": 2023 + }, + { + "buckets": [ + { + "name": "alpha", + "usageGB": 534.52 + }, + { + "name": "beta", + "usageGB": 34575.261 + }, + { + "name": "gamma", + "usageGB": 693.59 + }, + { + "name": "delta", + "usageGB": 872.93 + } + ], + "month": 10, + "totalUsageGB": 36676.30099999999, + "year": 2023 + }, + { + "buckets": [ + { + "name": "alpha", + "usageGB": 534.52 + }, + { + "name": "beta", + "usageGB": 32723.45 + }, + { + "name": "gamma", + "usageGB": 693.59 + }, + { + "name": "delta", + "usageGB": 872.93 + } + ], + "month": 11, + "totalUsageGB": 34824.49, + "year": 2023 + } + ] + }` + + resp := &lyveapi.MonthlyUsageResp{} + if err := json.NewDecoder(strings.NewReader(encodedUsageByBucket)).Decode(resp); err != nil { + t.Fatal(err) + } + + for k, v := range resp.UsageByBucket.MonthlyTotalUsageGB() { + if expectedUsagesByMonth[k] != v { + t.Errorf("Unable to find expected usage %v => %v", k, v) + } + } +}