diff --git a/.github/workflows/cli-build_test.yml b/.github/workflows/cli-build_test.yml index 8006aa3f..2c7cf2ca 100644 --- a/.github/workflows/cli-build_test.yml +++ b/.github/workflows/cli-build_test.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version-file: 'go.mod' - name: Build run: make build-cli diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..d7858371 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,27 @@ +name: Integration Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_call: + +jobs: + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Run Integration Tests + env: + OMS_PORTAL_API_KEY: ${{ secrets.OMS_PORTAL_API_KEY }} + OMS_PORTAL_API: ${{ secrets.OMS_PORTAL_API }} + run: make test-integration diff --git a/.github/workflows/service-build_test.yml b/.github/workflows/service-build_test.yml index d703121f..0640adf4 100644 --- a/.github/workflows/service-build_test.yml +++ b/.github/workflows/service-build_test.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version-file: 'go.mod' - name: Build run: make build-service diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 3bed3049..7305b396 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -6,8 +6,13 @@ on: - main jobs: + integration-tests: + uses: ./.github/workflows/integration-test.yml + secrets: inherit + tag: runs-on: ubuntu-latest + needs: integration-tests steps: - uses: actions/checkout@v5 with: @@ -17,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version-file: 'go.mod' - name: Tag run: hack/tag-release.sh diff --git a/Makefile b/Makefile index 986ba051..8228e45a 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,10 @@ test-cli: # -count=1 to disable caching test results go test -count=1 -v ./cli/... +test-integration: + # Run integration tests with build tag + go test -count=1 -v -tags=integration ./cli/... + test-service: go test -count=1 -v ./service/... diff --git a/cli/cmd/api_key_integration_test.go b/cli/cmd/api_key_integration_test.go new file mode 100644 index 00000000..b90d100d --- /dev/null +++ b/cli/cmd/api_key_integration_test.go @@ -0,0 +1,220 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration +// +build integration + +package cmd_test + +import ( + "fmt" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/portal" +) + +var _ = Describe("API Key Integration Tests", func() { + var ( + portalClient portal.Portal + testOwner string + testOrg string + testRole string + registeredKey *portal.ApiKey + originalAdminKey string + expiresAt time.Time + extendedExpiry time.Time + ) + + BeforeEach(func() { + apiKey := os.Getenv("OMS_PORTAL_API_KEY") + apiURL := os.Getenv("OMS_PORTAL_API") + if apiKey == "" || apiURL == "" { + Skip("Integration tests require OMS_PORTAL_API_KEY and OMS_PORTAL_API environment variables") + } + + originalAdminKey = apiKey + + portalClient = portal.NewPortalClient() + // test env wrapper + portalClient.(*portal.PortalClient).Env = NewTestEnv(apiKey, os.Getenv("OMS_PORTAL_API"), "") + + // test data + testOwner = fmt.Sprintf("integration-test-%d@test.com", time.Now().Unix()) + testOrg = "IntegrationTestOrg" + testRole = "Ext" + expiresAt = time.Now().Add(24 * time.Hour) + extendedExpiry = time.Now().Add(48 * time.Hour) + }) + + Describe("Standalone created-key behavior", func() { + It("created API key can list builds when used", func() { + registerCmd := cmd.RegisterCmd{ + Opts: cmd.RegisterOpts{ + Owner: fmt.Sprintf("standalone-test-%d@test.com", time.Now().Unix()), + Organization: "StandaloneTestOrg", + Role: "Ext", + ExpiresAt: time.Now().Add(1 * time.Hour).Format(time.RFC3339), + }, + } + + newKey, err := registerCmd.Register(portalClient) + Expect(err).To(BeNil(), "API key registration should succeed") + Expect(newKey).NotTo(BeNil(), "Register should return the created API key") + + keys, err := portalClient.ListAPIKeys() + Expect(err).To(BeNil(), "Listing API keys should succeed") + + var created *portal.ApiKey + for i := range keys { + if keys[i].Owner == registerCmd.Opts.Owner { + created = &keys[i] + break + } + } + Expect(created).NotTo(BeNil(), "Should find the created API key") + Expect(newKey.ApiKey).NotTo(BeEmpty(), "Created API key must include secret value") + + client := portal.NewPortalClient() + client.Env = NewTestEnv(newKey.ApiKey, os.Getenv("OMS_PORTAL_API"), "") + + builds, err := client.ListBuilds(portal.CodesphereProduct) + Expect(err).To(BeNil(), "Listing builds with created key should succeed") + Expect(builds.Builds).NotTo(BeEmpty(), "Created key should be able to see builds") + }) + }) + + Describe("Complete API Key Flow", func() { + It("should successfully complete the full API key lifecycle", func() { + By("Registering a new customer API key") + registerCmd := cmd.RegisterCmd{ + Opts: cmd.RegisterOpts{ + Owner: testOwner, + Organization: testOrg, + Role: testRole, + ExpiresAt: expiresAt.Format(time.RFC3339), + }, + } + + newKey, err := registerCmd.Register(portalClient) + Expect(err).To(BeNil(), "API key registration should succeed") + Expect(newKey).NotTo(BeNil(), "Register should return the created API key") + + By("Listing API keys to get the newly registered key") + keys, err := portalClient.ListAPIKeys() + Expect(err).To(BeNil(), "Listing API keys should succeed") + Expect(keys).NotTo(BeEmpty(), "Should have at least one API key") + + // Find the new key + for i := range keys { + if keys[i].Owner == testOwner { + registeredKey = &keys[i] + break + } + } + Expect(registeredKey).NotTo(BeNil(), "Should find the registered API key") + Expect(registeredKey.Owner).To(Equal(testOwner)) + Expect(registeredKey.Organization).To(Equal(testOrg)) + Expect(registeredKey.Role).To(Equal(testRole)) + + By("Ensuring the customer can see builds") + Expect(newKey.ApiKey).NotTo(BeEmpty(), "Registered key must include the API key value") + + p := portal.NewPortalClient() + // switch to the new key + p.Env = NewTestEnv(newKey.ApiKey, os.Getenv("OMS_PORTAL_API"), "") + + builds, err := p.ListBuilds(portal.CodesphereProduct) + Expect(err).To(BeNil(), "Listing builds with new key should succeed") + Expect(builds.Builds).NotTo(BeEmpty(), "Should have at least one build available") + + // restore admin key + portalClient.(*portal.PortalClient).Env = NewTestEnv(originalAdminKey, os.Getenv("OMS_PORTAL_API"), "") + + By("Extending the API Key to a future date") + updateCmd := cmd.UpdateAPIKeyCmd{ + Opts: cmd.UpdateAPIKeyOpts{ + APIKeyID: registeredKey.KeyID, + ExpiresAtStr: extendedExpiry.Format(time.RFC3339), + }, + } + + err = updateCmd.UpdateAPIKey(portalClient) + Expect(err).To(BeNil(), "API key update should succeed") + + By("Verifying the API key was updated") + keys, err = portalClient.ListAPIKeys() + Expect(err).To(BeNil(), "Listing API keys should succeed") + + // Find the updated key + var updatedKey *portal.ApiKey + for i := range keys { + if keys[i].KeyID == registeredKey.KeyID { + updatedKey = &keys[i] + break + } + } + Expect(updatedKey).NotTo(BeNil(), "Should find the updated API key") + Expect(updatedKey.ExpiresAt).To(BeTemporally("~", extendedExpiry, 5*time.Second)) + + By("Revoking the API Key") + revokeCmd := cmd.RevokeAPIKeyCmd{ + Opts: cmd.RevokeAPIKeyOpts{ + ID: registeredKey.KeyID, + }, + } + + err = revokeCmd.Revoke(portalClient) + Expect(err).To(BeNil(), "API key revocation should succeed") + + By("Ensuring the API Key is not valid anymore") + + keyFound := true + for attempt := 0; attempt < 5; attempt++ { + keys, err = portalClient.ListAPIKeys() + Expect(err).To(BeNil(), "Listing API keys should succeed") + + keyFound = false + for i := range keys { + if keys[i].KeyID == registeredKey.KeyID { + keyFound = true + break + } + } + + if !keyFound { + break + } + time.Sleep(1 * time.Second) + } + + if keyFound { + revokedClient := portal.NewPortalClient() + revokedClient.Env = NewTestEnv(newKey.ApiKey, os.Getenv("OMS_PORTAL_API"), "") + _, useErr := revokedClient.ListBuilds(portal.CodesphereProduct) + Expect(useErr).NotTo(BeNil(), "Using a revoked API key should fail") + } else { + Expect(keyFound).To(BeFalse(), "Revoked API key should not be in the list") + } + }) + }) + + Describe("API Key Update With Wrong Input", func() { + It("should handle update with invalid date format", func() { + updateCmd := cmd.UpdateAPIKeyCmd{ + Opts: cmd.UpdateAPIKeyOpts{ + APIKeyID: "test-key-id", + ExpiresAtStr: "invalid-date", + }, + } + + err := updateCmd.UpdateAPIKey(portalClient) + Expect(err).NotTo(BeNil(), "Should fail with invalid date format") + Expect(err.Error()).To(ContainSubstring("invalid date format")) + }) + }) +}) diff --git a/cli/cmd/api_key_test_helpers_test.go b/cli/cmd/api_key_test_helpers_test.go new file mode 100644 index 00000000..e2e8c277 --- /dev/null +++ b/cli/cmd/api_key_test_helpers_test.go @@ -0,0 +1,41 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration +// +build integration + +package cmd_test + +import "errors" + +// interface for tests and allows injecting a specific API key and API URL without modifying process env +type testEnv struct { + apiKey string + apiURL string + workdir string +} + +func NewTestEnv(apiKey, apiURL, workdir string) *testEnv { + return &testEnv{apiKey: apiKey, apiURL: apiURL, workdir: workdir} +} + +func (e *testEnv) GetOmsPortalApiKey() (string, error) { + if e.apiKey == "" { + return "", errors.New("OMS_PORTAL_API_KEY not set in test env") + } + return e.apiKey, nil +} + +func (e *testEnv) GetOmsPortalApi() string { + if e.apiURL == "" { + return "https://oms-portal.codesphere.com/api" + } + return e.apiURL +} + +func (e *testEnv) GetOmsWorkdir() string { + if e.workdir == "" { + return "./oms-workdir" + } + return e.workdir +} diff --git a/cli/cmd/register.go b/cli/cmd/register.go index 93750cd1..e621a604 100644 --- a/cli/cmd/register.go +++ b/cli/cmd/register.go @@ -27,25 +27,34 @@ type RegisterOpts struct { func (c *RegisterCmd) RunE(_ *cobra.Command, args []string) error { p := portal.NewPortalClient() - return c.Register(p) + newKey, err := c.Register(p) + if err != nil { + return err + } + + if newKey != nil { + fmt.Printf("API key registered successfully!\nOwner: %s\nOrganisation: %s\nKey: %s\n", newKey.Owner, newKey.Organization, newKey.ApiKey) + } + + return nil } -func (c *RegisterCmd) Register(p portal.Portal) error { +func (c *RegisterCmd) Register(p portal.Portal) (*portal.ApiKey, error) { var err error var expiresAt time.Time if c.Opts.ExpiresAt != "" { expiresAt, err = time.Parse(time.RFC3339, c.Opts.ExpiresAt) if err != nil { - return fmt.Errorf("failed to parse expiration date: %w", err) + return nil, fmt.Errorf("failed to parse expiration date: %w", err) } } - err = p.RegisterAPIKey(c.Opts.Owner, c.Opts.Organization, c.Opts.Role, expiresAt) + newKey, err := p.RegisterAPIKey(c.Opts.Owner, c.Opts.Organization, c.Opts.Role, expiresAt) if err != nil { - return fmt.Errorf("failed to register API key: %w", err) + return nil, fmt.Errorf("failed to register API key: %w", err) } - return nil + return newKey, nil } func AddRegisterCmd(list *cobra.Command, opts GlobalOptions) { diff --git a/cli/cmd/register_test.go b/cli/cmd/register_test.go index 05c31a39..11713077 100644 --- a/cli/cmd/register_test.go +++ b/cli/cmd/register_test.go @@ -44,15 +44,17 @@ var _ = Describe("RegisterCmd", func() { Context("when expiration date is valid", func() { It("registers the API key successfully", func() { parsedTime, _ := time.Parse(time.RFC3339, expiresAt) - mockPortal.EXPECT().RegisterAPIKey(owner, organization, role, parsedTime).Return(nil) - err := c.Register(mockPortal) + mockPortal.EXPECT().RegisterAPIKey(owner, organization, role, parsedTime).Return(&portal.ApiKey{}, nil) + ak, err := c.Register(mockPortal) Expect(err).To(BeNil()) + Expect(ak).NotTo(BeNil()) }) It("returns error if Register fails", func() { parsedTime, _ := time.Parse(time.RFC3339, expiresAt) - mockPortal.EXPECT().RegisterAPIKey(owner, organization, role, parsedTime).Return(fmt.Errorf("some error")) - err := c.Register(mockPortal) + mockPortal.EXPECT().RegisterAPIKey(owner, organization, role, parsedTime).Return((*portal.ApiKey)(nil), fmt.Errorf("some error")) + ak, err := c.Register(mockPortal) + Expect(ak).To(BeNil()) Expect(err).To(MatchError(ContainSubstring("failed to register API key"))) }) }) @@ -62,7 +64,8 @@ var _ = Describe("RegisterCmd", func() { c.Opts.ExpiresAt = "invalid-date" }) It("returns error for invalid expiration date", func() { - err := c.Register(mockPortal) + ak, err := c.Register(mockPortal) + Expect(ak).To(BeNil()) Expect(err).To(MatchError(ContainSubstring("failed to parse expiration date"))) }) }) diff --git a/internal/portal/http.go b/internal/portal/http.go index 7ee65077..ddd7497a 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -23,7 +23,7 @@ type Portal interface { ListBuilds(product Product) (availablePackages Builds, err error) GetBuild(product Product, version string, hash string) (Build, error) DownloadBuildArtifact(product Product, build Build, file io.Writer, quiet bool) error - RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) error + RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) RevokeAPIKey(key string) error UpdateAPIKey(key string, expiresAt time.Time) error ListAPIKeys() ([]ApiKey, error) @@ -204,7 +204,7 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file return nil } -func (c *PortalClient) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) error { +func (c *PortalClient) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) { req := struct { Owner string `json:"owner"` Organization string `json:"organization"` @@ -219,25 +219,22 @@ func (c *PortalClient) RegisterAPIKey(owner string, organization string, role st reqBody, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to generate request body: %w", err) + return nil, fmt.Errorf("failed to generate request body: %w", err) } resp, err := c.HttpRequest(http.MethodPost, "/key/register", reqBody) if err != nil { - return fmt.Errorf("POST request to register API key failed: %w", err) + return nil, fmt.Errorf("POST request to register API key failed: %w", err) } defer func() { _ = resp.Body.Close() }() newKey := &ApiKey{} err = json.NewDecoder(resp.Body).Decode(newKey) if err != nil { - return fmt.Errorf("failed to decode response body: %w", err) + return nil, fmt.Errorf("failed to decode response body: %w", err) } - log.Println("API key registered successfully!") - log.Printf("Owner: %s\nOrganisation: %s\nKey: %s\n", newKey.Owner, newKey.Organization, newKey.ApiKey) - - return nil + return newKey, nil } func (c *PortalClient) RevokeAPIKey(keyId string) error { diff --git a/internal/portal/mocks.go b/internal/portal/mocks.go index b3087607..857676d5 100644 --- a/internal/portal/mocks.go +++ b/internal/portal/mocks.go @@ -252,20 +252,31 @@ func (_c *MockPortal_ListBuilds_Call) RunAndReturn(run func(product Product) (Bu } // RegisterAPIKey provides a mock function for the type MockPortal -func (_mock *MockPortal) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) error { +func (_mock *MockPortal) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) { ret := _mock.Called(owner, organization, role, expiresAt) if len(ret) == 0 { panic("no return value specified for RegisterAPIKey") } - var r0 error - if returnFunc, ok := ret.Get(0).(func(string, string, string, time.Time) error); ok { + var r0 *ApiKey + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, string, time.Time) (*ApiKey, error)); ok { + return returnFunc(owner, organization, role, expiresAt) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string, time.Time) *ApiKey); ok { r0 = returnFunc(owner, organization, role, expiresAt) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ApiKey) + } } - return r0 + if returnFunc, ok := ret.Get(1).(func(string, string, string, time.Time) error); ok { + r1 = returnFunc(owner, organization, role, expiresAt) + } else { + r1 = ret.Error(1) + } + return r0, r1 } // MockPortal_RegisterAPIKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterAPIKey' @@ -289,12 +300,12 @@ func (_c *MockPortal_RegisterAPIKey_Call) Run(run func(owner string, organizatio return _c } -func (_c *MockPortal_RegisterAPIKey_Call) Return(err error) *MockPortal_RegisterAPIKey_Call { - _c.Call.Return(err) +func (_c *MockPortal_RegisterAPIKey_Call) Return(apiKey *ApiKey, err error) *MockPortal_RegisterAPIKey_Call { + _c.Call.Return(apiKey, err) return _c } -func (_c *MockPortal_RegisterAPIKey_Call) RunAndReturn(run func(owner string, organization string, role string, expiresAt time.Time) error) *MockPortal_RegisterAPIKey_Call { +func (_c *MockPortal_RegisterAPIKey_Call) RunAndReturn(run func(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error)) *MockPortal_RegisterAPIKey_Call { _c.Call.Return(run) return _c } diff --git a/internal/portal/write_counter.go b/internal/portal/write_counter.go index 560bd90b..3df3b3bc 100644 --- a/internal/portal/write_counter.go +++ b/internal/portal/write_counter.go @@ -6,6 +6,7 @@ package portal import ( "fmt" "io" + "log" "time" ) @@ -37,7 +38,8 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { wc.Written += int64(n) if time.Since(wc.LastUpdate) >= 100*time.Millisecond { - fmt.Printf("\rDownloading... %s transferred %c \033[K", byteCountToHumanReadable(wc.Written), wc.animate()) + // We need to use the log package so callers/tests that redirect log output will capture progress messages correctly. + log.Printf("Downloading... %s transferred %c", byteCountToHumanReadable(wc.Written), wc.animate()) wc.LastUpdate = time.Now() } diff --git a/internal/tmpl/tmpl_suite_test.go b/internal/tmpl/tmpl_suite_test.go index 53592b2c..09cd81ee 100644 --- a/internal/tmpl/tmpl_suite_test.go +++ b/internal/tmpl/tmpl_suite_test.go @@ -13,4 +13,4 @@ import ( func TestTmpl(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Tmpl Suite") -} \ No newline at end of file +}