diff --git a/cli/cmd/api_key_integration_test.go b/cli/cmd/api_key_integration_test.go index b90d100d..eb3979ce 100644 --- a/cli/cmd/api_key_integration_test.go +++ b/cli/cmd/api_key_integration_test.go @@ -9,6 +9,8 @@ package cmd_test import ( "fmt" "os" + "os/exec" + "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -217,4 +219,116 @@ var _ = Describe("API Key Integration Tests", func() { Expect(err.Error()).To(ContainSubstring("invalid date format")) }) }) + + Describe("Old API Key Detection and Warning", func() { + var ( + cliPath string + ) + + BeforeEach(func() { + cliPath = "./oms-cli" + + _, err := os.Stat(cliPath) + if err != nil { + Skip("OMS CLI not found at " + cliPath + ", please build it first with 'make build-cli'") + } + }) + + Context("when using a 22-character old API key format", func() { + It("should detect the old format and attempt to upgrade", func() { + cmd := exec.Command(cliPath, "version") + cmd.Env = append(os.Environ(), + "OMS_PORTAL_API_KEY=fakeapikeywith22charsa", // 22 characters + "OMS_PORTAL_API=http://localhost:3000/api", + ) + + output, _ := cmd.CombinedOutput() + outputStr := string(output) + + Expect(outputStr).To(ContainSubstring("OMS CLI version")) + }) + }) + + Context("when using a new long-format API key", func() { + It("should not show any warning", func() { + cmd := exec.Command(cliPath, "version") + cmd.Env = append(os.Environ(), + "OMS_PORTAL_API_KEY=fake-api-key", + "OMS_PORTAL_API=http://localhost:3000/api", + ) + + output, _ := cmd.CombinedOutput() + outputStr := string(output) + + Expect(outputStr).To(ContainSubstring("OMS CLI version")) + Expect(outputStr).NotTo(ContainSubstring("old API key")) + Expect(outputStr).NotTo(ContainSubstring("Failed to upgrade")) + }) + }) + + Context("when using a 22-character key with list api-keys command", func() { + It("should attempt the upgrade and handle the error gracefully", func() { + cmd := exec.Command(cliPath, "list", "api-keys") + cmd.Env = append(os.Environ(), + "OMS_PORTAL_API_KEY=fakeapikeywith22charsa", // 22 characters (old format) + "OMS_PORTAL_API=http://localhost:3000/api", + ) + + output, err := cmd.CombinedOutput() + outputStr := string(output) + + Expect(err).To(HaveOccurred()) + + hasWarning := strings.Contains(outputStr, "old API key") || + strings.Contains(outputStr, "Failed to upgrade") || + strings.Contains(outputStr, "Unauthorized") + + Expect(hasWarning).To(BeTrue(), + "Should contain warning about old key or auth failure. Got: "+outputStr) + }) + }) + + Context("when checking key length detection", func() { + It("should correctly identify 22-character old format", func() { + oldKey := "fakeapikeywith22charsa" + Expect(len(oldKey)).To(Equal(22)) + }) + + It("should correctly identify new long format", func() { + newKey := "4hBieJRj2pWeB9qKJ9wQGE3CrcldLnLwP8fz6qutMjkf1n1" + Expect(len(newKey)).NotTo(Equal(22)) + Expect(len(newKey)).To(BeNumerically(">", 22)) + }) + }) + }) + + Describe("PreRun Hook Execution", func() { + var ( + cliPath string + ) + + BeforeEach(func() { + cliPath = "./oms-cli" + + _, err := os.Stat(cliPath) + if err != nil { + Skip("OMS CLI not found at " + cliPath + ", please build it first with 'make build-cli'") + } + }) + + Context("when running any OMS command", func() { + It("should execute the PreRun hook", func() { + cmd := exec.Command(cliPath, "version") + cmd.Env = append(os.Environ(), + "OMS_PORTAL_API_KEY=valid-key-format-short", + "OMS_PORTAL_API=http://localhost:3000/api", + ) + + output, _ := cmd.CombinedOutput() + outputStr := string(output) + + Expect(outputStr).To(ContainSubstring("OMS CLI version")) + }) + }) + }) }) diff --git a/cli/cmd/cmd_suite_test.go b/cli/cmd/cmd_suite_test.go index bff03295..6c525373 100644 --- a/cli/cmd/cmd_suite_test.go +++ b/cli/cmd/cmd_suite_test.go @@ -4,8 +4,18 @@ package cmd_test import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" "testing" + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/stretchr/testify/mock" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -14,3 +24,101 @@ func TestCmd(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Cmd Suite") } + +var _ = Describe("RootCmd", func() { + var ( + mockEnv *env.MockEnv + mockHttpClient *portal.MockHttpClient + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockHttpClient = portal.NewMockHttpClient(GinkgoT()) + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockHttpClient.AssertExpectations(GinkgoT()) + }) + + Describe("PreRun hook with old API key", func() { + Context("when API key is 22 characters (old format)", func() { + It("attempts to upgrade the key via GetApiKeyId", func() { + oldKey := "fakeapikeywith22charsa" // 22 characters + keyId := "test-key-id-12345" + expectedNewKey := keyId + oldKey + + Expect(os.Setenv("OMS_PORTAL_API_KEY", oldKey)).NotTo(HaveOccurred()) + Expect(os.Setenv("OMS_PORTAL_API", "http://test-portal.com/api")).NotTo(HaveOccurred()) + + mockEnv.EXPECT().GetOmsPortalApi().Return("http://test-portal.com/api") + + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + Expect(req.Header.Get("X-API-Key")).To(Equal(oldKey)) + Expect(req.URL.Path).To(ContainSubstring("/key")) + + response := map[string]string{ + "keyId": keyId, + } + body, _ := json.Marshal(response) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil + }) + + portalClient := &portal.PortalClient{ + Env: mockEnv, + HttpClient: mockHttpClient, + } + + result, err := portalClient.GetApiKeyId(oldKey) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(keyId)) + + // Concatenate on client side + newApiKey := result + oldKey + Expect(newApiKey).To(Equal(expectedNewKey)) + + Expect(os.Unsetenv("OMS_PORTAL_API_KEY")).NotTo(HaveOccurred()) + Expect(os.Unsetenv("OMS_PORTAL_API")).NotTo(HaveOccurred()) + }) + }) + + Context("when API key is not 22 characters (new format)", func() { + It("does not attempt to upgrade the key", func() { + newKey := "new-long-api-key-format-very-long-string" + + Expect(os.Setenv("OMS_PORTAL_API_KEY", newKey)).NotTo(HaveOccurred()) + Expect(os.Setenv("OMS_PORTAL_API", "http://test-portal.com/api")).NotTo(HaveOccurred()) + + Expect(len(newKey)).NotTo(Equal(22)) + + Expect(os.Unsetenv("OMS_PORTAL_API_KEY")).NotTo(HaveOccurred()) + Expect(os.Unsetenv("OMS_PORTAL_API")).NotTo(HaveOccurred()) + }) + }) + + Context("when API key is empty", func() { + It("does not attempt to upgrade", func() { + Expect(os.Setenv("OMS_PORTAL_API_KEY", "")).NotTo(HaveOccurred()) + Expect(os.Setenv("OMS_PORTAL_API", "http://test-portal.com/api")).NotTo(HaveOccurred()) + + Expect(len(os.Getenv("OMS_PORTAL_API_KEY"))).To(Equal(0)) + + Expect(os.Unsetenv("OMS_PORTAL_API_KEY")).NotTo(HaveOccurred()) + Expect(os.Unsetenv("OMS_PORTAL_API")).NotTo(HaveOccurred()) + }) + }) + }) + + Describe("GetRootCmd", func() { + It("returns a valid root command", func() { + rootCmd := cmd.GetRootCmd() + Expect(rootCmd).NotTo(BeNil()) + Expect(rootCmd.Use).To(Equal("oms")) + Expect(rootCmd.Short).To(Equal("Codesphere Operations Management System (OMS)")) + }) + }) +}) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index b3a1108b..131d7e57 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -4,10 +4,12 @@ package cmd import ( + "fmt" "log" "os" "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/portal" "github.com/spf13/cobra" ) @@ -25,6 +27,33 @@ func GetRootCmd() *cobra.Command { This command can be used to run common tasks related to managing codesphere installations, like downloading new versions.`), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + apiKey := os.Getenv("OMS_PORTAL_API_KEY") + + if len(apiKey) == 22 { + fmt.Fprintf(os.Stderr, "Warning: You used an old API key format.\n") + fmt.Fprintf(os.Stderr, "Attempting to upgrade to the new format...\n\n") + + portalClient := portal.NewPortalClient() + keyId, err := portalClient.GetApiKeyId(apiKey) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Failed to upgrade old API key: %v\n", err) + return + } + + newApiKey := keyId + apiKey + + if err := os.Setenv("OMS_PORTAL_API_KEY", newApiKey); err != nil { + fmt.Fprintf(os.Stderr, "Error: Failed to set environment variable: %v\n", err) + return + } + opts.OmsPortalApiKey = newApiKey + + fmt.Fprintf(os.Stderr, "Please update your environment variable:\n\n") + fmt.Fprintf(os.Stderr, " export OMS_PORTAL_API_KEY='%s'\n\n", newApiKey) + } + }, } // General commands AddVersionCmd(rootCmd) diff --git a/internal/portal/http_test.go b/internal/portal/http_test.go index a060397e..2a1d5491 100644 --- a/internal/portal/http_test.go +++ b/internal/portal/http_test.go @@ -352,6 +352,7 @@ var _ = Describe("HttpWrapper", func() { }) }) }) + }) // Helper types for testing diff --git a/internal/portal/mocks.go b/internal/portal/mocks.go index 7ec25e51..0165e738 100644 --- a/internal/portal/mocks.go +++ b/internal/portal/mocks.go @@ -275,6 +275,60 @@ func (_c *MockPortal_DownloadBuildArtifact_Call) RunAndReturn(run func(product P return _c } +// GetApiKeyId provides a mock function for the type MockPortal +func (_mock *MockPortal) GetApiKeyId(oldKey string) (string, error) { + ret := _mock.Called(oldKey) + + if len(ret) == 0 { + panic("no return value specified for GetApiKeyId") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { + return returnFunc(oldKey) + } + if returnFunc, ok := ret.Get(0).(func(string) string); ok { + r0 = returnFunc(oldKey) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(oldKey) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPortal_GetApiKeyId_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApiKeyId' +type MockPortal_GetApiKeyId_Call struct { + *mock.Call +} + +// GetApiKeyId is a helper method to define mock.On call +// - oldKey +func (_e *MockPortal_Expecter) GetApiKeyId(oldKey interface{}) *MockPortal_GetApiKeyId_Call { + return &MockPortal_GetApiKeyId_Call{Call: _e.mock.On("GetApiKeyId", oldKey)} +} + +func (_c *MockPortal_GetApiKeyId_Call) Run(run func(oldKey string)) *MockPortal_GetApiKeyId_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockPortal_GetApiKeyId_Call) Return(s string, err error) *MockPortal_GetApiKeyId_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockPortal_GetApiKeyId_Call) RunAndReturn(run func(oldKey string) (string, error)) *MockPortal_GetApiKeyId_Call { + _c.Call.Return(run) + return _c +} + // GetBuild provides a mock function for the type MockPortal func (_mock *MockPortal) GetBuild(product Product, version string, hash string) (Build, error) { ret := _mock.Called(product, version, hash) diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 13a86f53..02f2ccdc 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -27,6 +27,7 @@ type Portal interface { RevokeAPIKey(key string) error UpdateAPIKey(key string, expiresAt time.Time) error ListAPIKeys() ([]ApiKey, error) + GetApiKeyId(oldKey string) (string, error) } type PortalClient struct { @@ -326,3 +327,39 @@ func (c *PortalClient) ListAPIKeys() ([]ApiKey, error) { return keys, nil } + +// GetApiKeyId retrieves the key ID by sending the old key in the request header. +func (c *PortalClient) GetApiKeyId(oldKey string) (string, error) { + url, err := url.JoinPath(c.Env.GetOmsPortalApi(), "/key") + if err != nil { + return "", fmt.Errorf("failed to generate URL: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("X-API-Key", oldKey) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), string(respBody)) + } + + var result struct { + KeyID string `json:"keyId"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + return result.KeyID, nil +} diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index a3bf3604..809e4d1b 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "io" - "log" "net/http" "net/url" "time" @@ -36,11 +35,9 @@ var _ = Describe("PortalClient", func() { client portal.PortalClient mockEnv *env.MockEnv mockHttpClient *portal.MockHttpClient - status int apiUrl string getUrl url.URL headers http.Header - getResponse []byte product portal.Product apiKey string apiKeyErr error @@ -57,7 +54,6 @@ var _ = Describe("PortalClient", func() { Env: mockEnv, HttpClient: mockHttpClient, } - status = http.StatusOK apiUrl = "fake-portal.com" }) JustBeforeEach(func() { @@ -70,30 +66,39 @@ var _ = Describe("PortalClient", func() { }) Describe("GetBody", func() { - JustBeforeEach(func() { - mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( - func(req *http.Request) (*http.Response, error) { - getUrl = *req.URL - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(bytes.NewReader(getResponse)), - }, nil - }).Maybe() - }) - Context("when path starts with a /", func() { - It("Executes a request against the right URL", func() { - _, status, err := client.GetBody("/api/fake") - Expect(status).To(Equal(status)) + BeforeEach(func() { + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + getUrl = *req.URL + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte{})), + }, nil + }) + }) + + It("executes a request against the right URL", func() { + _, _, err := client.GetBody("/api/fake") Expect(err).NotTo(HaveOccurred()) Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) }) }) - Context("when path does not with a /", func() { - It("Executes a request against the right URL", func() { - _, status, err := client.GetBody("api/fake") - Expect(status).To(Equal(status)) + Context("when path does not start with a /", func() { + BeforeEach(func() { + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + getUrl = *req.URL + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte{})), + }, nil + }) + }) + + It("executes a request against the right URL", func() { + _, _, err := client.GetBody("api/fake") Expect(err).NotTo(HaveOccurred()) Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) }) @@ -105,92 +110,68 @@ var _ = Describe("PortalClient", func() { apiKeyErr = errors.New("fake-error") }) - It("Returns an error", func() { - _, status, err := client.GetBody("/api/fake") - Expect(status).To(Equal(status)) - Expect(err).NotTo(BeNil()) - Expect(err.Error()).To(MatchRegexp(".*fake-error")) - Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + It("returns an error", func() { + _, _, err := client.GetBody("/api/fake") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("fake-error")) }) }) }) Describe("ListCodespherePackages", func() { - JustBeforeEach(func() { - mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( - func(req *http.Request) (*http.Response, error) { - getUrl = *req.URL - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(bytes.NewReader(getResponse)), - }, nil - }) - }) - Context("when the request suceeds", func() { - var expectedResult portal.Builds + Context("when the request succeeds", func() { BeforeEach(func() { firstBuild, _ := time.Parse("2006-01-02", "2025-04-02") lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") - getPackagesResponse := portal.Builds{ + response := portal.Builds{ Builds: []portal.Build{ - { - Hash: "lastBuild", - Date: lastBuild, - }, - { - Hash: "firstBuild", - Date: firstBuild, - }, - }, - } - getResponse, _ = json.Marshal(getPackagesResponse) - - expectedResult = portal.Builds{ - Builds: []portal.Build{ - { - Hash: "firstBuild", - Date: firstBuild, - }, - { - Hash: "lastBuild", - Date: lastBuild, - }, + {Hash: "lastBuild", Date: lastBuild}, + {Hash: "firstBuild", Date: firstBuild}, }, } + responseBody, _ := json.Marshal(response) + + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + getUrl = *req.URL + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil + }) }) It("returns the builds ordered by date", func() { + firstBuild, _ := time.Parse("2006-01-02", "2025-04-02") + lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") + packages, err := client.ListBuilds(portal.CodesphereProduct) Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) + Expect(packages.Builds).To(HaveLen(2)) + Expect(packages.Builds[0].Hash).To(Equal("firstBuild")) + Expect(packages.Builds[0].Date).To(Equal(firstBuild)) + Expect(packages.Builds[1].Hash).To(Equal("lastBuild")) + Expect(packages.Builds[1].Date).To(Equal(lastBuild)) Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere")) }) }) }) Describe("DownloadBuildArtifact", func() { - var ( - build portal.Build - downloadResponse string - ) + var build portal.Build BeforeEach(func() { buildDate, _ := time.Parse("2006-01-02", "2025-05-01") - - downloadResponse = "fake-file-contents" - - build = portal.Build{ - Date: buildDate, - } + build = portal.Build{Date: buildDate} mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { getUrl = *req.URL headers = req.Header return &http.Response{ - StatusCode: status, - Body: io.NopCloser(bytes.NewReader([]byte(downloadResponse))), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte("fake-file-contents"))), }, nil }) }) @@ -199,7 +180,7 @@ var _ = Describe("PortalClient", func() { fakeWriter := NewFakeWriter() err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, false) Expect(err).NotTo(HaveOccurred()) - Expect(fakeWriter.String()).To(Equal(downloadResponse)) + Expect(fakeWriter.String()).To(Equal("fake-file-contents")) Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) }) @@ -208,166 +189,225 @@ var _ = Describe("PortalClient", func() { err := client.DownloadBuildArtifact(product, build, fakeWriter, 42, false) Expect(err).NotTo(HaveOccurred()) Expect(headers.Get("Range")).To(Equal("bytes=42-")) - Expect(fakeWriter.String()).To(Equal(downloadResponse)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) }) + }) - It("emits progress logs when not quiet", func() { - var logBuf bytes.Buffer - prev := log.Writer() - log.SetOutput(&logBuf) - defer log.SetOutput(prev) + Describe("GetLatestOmsBuild", func() { + Context("when builds are available", func() { + BeforeEach(func() { + firstBuild, _ := time.Parse("2006-01-02", "2025-04-02") + lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, false) - Expect(err).NotTo(HaveOccurred()) - Expect(logBuf.String()).To(ContainSubstring("Downloading...")) + response := portal.Builds{ + Builds: []portal.Build{ + {Hash: "firstBuild", Date: firstBuild, Version: "1.42.0"}, + {Hash: "lastBuild", Date: lastBuild, Version: "1.42.1"}, + }, + } + responseBody, _ := json.Marshal(response) + + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + getUrl = *req.URL + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil + }) + }) + + It("returns the latest build", func() { + lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") + build, err := client.GetBuild(portal.OmsProduct, "", "") + Expect(err).NotTo(HaveOccurred()) + Expect(build.Hash).To(Equal("lastBuild")) + Expect(build.Date).To(Equal(lastBuild)) + Expect(build.Version).To(Equal("1.42.1")) + }) + + It("returns the build matching version", func() { + lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") + build, err := client.GetBuild(portal.OmsProduct, "1.42.1", "") + Expect(err).NotTo(HaveOccurred()) + Expect(build.Hash).To(Equal("lastBuild")) + Expect(build.Date).To(Equal(lastBuild)) + Expect(build.Version).To(Equal("1.42.1")) + }) + + It("returns the build matching version and hash", func() { + lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") + build, err := client.GetBuild(portal.OmsProduct, "1.42.1", "lastBuild") + Expect(err).NotTo(HaveOccurred()) + Expect(build.Hash).To(Equal("lastBuild")) + Expect(build.Date).To(Equal(lastBuild)) + Expect(build.Version).To(Equal("1.42.1")) + }) }) - It("does not emit progress logs when quiet", func() { - var logBuf bytes.Buffer - prev := log.Writer() - log.SetOutput(&logBuf) - defer log.SetOutput(prev) + Context("when no builds are returned", func() { + BeforeEach(func() { + response := portal.Builds{Builds: []portal.Build{}} + responseBody, _ := json.Marshal(response) - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, true) - Expect(err).NotTo(HaveOccurred()) - Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil) + }) + + It("returns an error", func() { + _, err := client.GetBuild(portal.OmsProduct, "", "") + Expect(err).To(MatchError("no builds returned")) + }) }) }) - Describe("GetLatestOmsBuild", func() { + Describe("GetApiKeyId", func() { + Context("when the request succeeds", func() { + BeforeEach(func() { + response := map[string]string{"keyId": "test-key-id"} + responseBody, _ := json.Marshal(response) + + mockEnv.EXPECT().GetOmsPortalApi().Return(apiUrl) + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil) + }) + + It("returns the key ID", func() { + result, err := client.GetApiKeyId("old-key") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("test-key-id")) + }) + }) + + Context("when the HTTP request fails", func() { + BeforeEach(func() { + mockEnv.EXPECT().GetOmsPortalApi().Return(apiUrl) + mockHttpClient.EXPECT().Do(mock.Anything).Return(nil, errors.New("network error")) + }) + + It("returns an error", func() { + _, err := client.GetApiKeyId("old-key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + }) + + Context("when the server returns an error status", func() { + BeforeEach(func() { + mockEnv.EXPECT().GetOmsPortalApi().Return(apiUrl) + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewReader([]byte("Unauthorized"))), + }, nil) + }) + + It("returns an error", func() { + _, err := client.GetApiKeyId("old-key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected response status: 401")) + }) + }) + }) + + Describe("RegisterAPIKey", func() { var ( - lastBuild, firstBuild time.Time - getPackagesResponse portal.Builds + owner, organization, role string + expiresAt time.Time + responseBody []byte ) - JustBeforeEach(func() { - getResponse, _ = json.Marshal(getPackagesResponse) - mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( - func(req *http.Request) (*http.Response, error) { - getUrl = *req.URL - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(bytes.NewReader(getResponse)), - }, nil - }) + + BeforeEach(func() { + owner = "test-owner" + organization = "test-org" + role = "admin" + expiresAt, _ = time.Parse("2006-01-02", "2026-01-01") + + responseKey := portal.ApiKey{ + KeyID: "key-123", + Owner: owner, + Organization: organization, + Role: role, + ExpiresAt: expiresAt, + ApiKey: "secret-key-data", + } + responseBody, _ = json.Marshal(responseKey) }) - Context("When the build is included", func() { + Context("when registration succeeds", func() { BeforeEach(func() { - firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") - lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") - - getPackagesResponse = portal.Builds{ - Builds: []portal.Build{ - { - Hash: "firstBuild", - Date: firstBuild, - Version: "1.42.0", - }, - { - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - }, - }, - } + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil) }) - It("returns the build", func() { - expectedResult := portal.Build{ - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - } - packages, err := client.GetBuild(portal.OmsProduct, "", "") + + It("returns the new API key", func() { + key, err := client.RegisterAPIKey(owner, organization, role, expiresAt) Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + Expect(key.KeyID).To(Equal("key-123")) + Expect(key.ApiKey).To(Equal("secret-key-data")) }) }) + }) - Context("When the build with version is included", func() { + Describe("RevokeAPIKey", func() { + Context("when revocation succeeds", func() { BeforeEach(func() { - firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") - lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") - - getPackagesResponse = portal.Builds{ - Builds: []portal.Build{ - { - Hash: "firstBuild", - Date: firstBuild, - Version: "1.42.0", - }, - { - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - }, - }, - } + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte("{}"))), + }, nil) }) - It("returns the build", func() { - expectedResult := portal.Build{ - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - } - packages, err := client.GetBuild(portal.OmsProduct, "1.42.1", "") + + It("completes without error", func() { + err := client.RevokeAPIKey("key-to-revoke") Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) }) }) + }) - Context("When the build with version and hash is included", func() { + Describe("UpdateAPIKey", func() { + Context("when update succeeds", func() { BeforeEach(func() { - firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") - lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") - - getPackagesResponse = portal.Builds{ - Builds: []portal.Build{ - { - Hash: "firstBuild", - Date: firstBuild, - Version: "1.42.0", - }, - { - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - }, - }, - } + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte("{}"))), + }, nil) }) - It("returns the build", func() { - expectedResult := portal.Build{ - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - } - packages, err := client.GetBuild(portal.OmsProduct, "1.42.1", "lastBuild") + + It("completes without error", func() { + expiresAt, _ := time.Parse("2006-01-02", "2027-01-01") + err := client.UpdateAPIKey("key-to-update", expiresAt) Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) }) }) + }) - Context("When no builds are returned", func() { + Describe("ListAPIKeys", func() { + Context("when listing succeeds", func() { BeforeEach(func() { - firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") - lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") - - getPackagesResponse = portal.Builds{ - Builds: []portal.Build{}, + expiresAt, _ := time.Parse("2006-01-02", "2026-01-01") + keys := []portal.ApiKey{ + {KeyID: "key-1", Owner: "owner-1", Organization: "org-1", Role: "admin", ExpiresAt: expiresAt}, + {KeyID: "key-2", Owner: "owner-2", Organization: "org-2", Role: "viewer", ExpiresAt: expiresAt}, } + responseBody, _ := json.Marshal(keys) + + mockHttpClient.EXPECT().Do(mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil) }) - It("returns an error and an empty build", func() { - expectedResult := portal.Build{} - packages, err := client.GetBuild(portal.OmsProduct, "", "") - Expect(err).To(MatchError("no builds returned")) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + + It("returns the list of API keys", func() { + keys, err := client.ListAPIKeys() + Expect(err).NotTo(HaveOccurred()) + Expect(keys).To(HaveLen(2)) + Expect(keys[0].KeyID).To(Equal("key-1")) + Expect(keys[1].KeyID).To(Equal("key-2")) }) }) })