diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e2e4a..93825ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,48 @@ jobs: - name: Run E2E tests run: make e2e + e2e-enterprise-test: + name: E2E Test (Enterprise Edition) + runs-on: ubuntu-latest + # The Enterprise Edition image exposes enterprise-only API surface without + # a license (most endpoints work unlicensed; analysis and a few + # license-gated operations do not). This job never blocks the pipeline: + # if the image can't be pulled in this environment, or the suite finds + # itself pointed at a non-Enterprise instance, it skips cleanly instead + # of failing. Set the SONAR_ENTERPRISE_LICENSE repo secret to also + # exercise license-dependent behavior. + continue-on-error: true + services: + sonarqube-enterprise: + image: docker.io/library/sonarqube:2026.3.1-enterprise + ports: + - 9001:9000 + env: + SONAR_ES_BOOTSTRAP_CHECKS_DISABLE: "true" + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26.5" + cache: true + + - name: Cache Go tools + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + /home/runner/go/bin + key: ${{ runner.os }}-go-tools-${{ hashFiles('Makefile') }}-e2e-enterprise + restore-keys: | + ${{ runner.os }}-go-tools- + + - name: Run E2E tests (Enterprise Edition) + run: make e2e.enterprise + env: + SONAR_LICENSE: ${{ secrets.SONAR_ENTERPRISE_LICENSE }} + build: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index 2a4194f..39e1706 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,10 @@ setup.sonar: fi # Setup SonarQube Enterprise Edition instance for integration testing. -# Requires a valid SonarQube Enterprise Edition license. +# Runs the Enterprise Edition Docker image, which exposes enterprise-only API +# surface without a license (most endpoints work unlicensed; analysis and a +# few license-gated operations do not). If SONAR_LICENSE is set, it is +# applied on top so license-dependent tests can run too; this is optional. # Override the endpoint with: make setup.sonar.enterprise enterprise_endpoint=http://my-sonarqube:9000 setup.sonar.enterprise: @command -v curl >/dev/null 2>&1 || { echo "curl is required but not installed. Please install curl."; exit 1; } @@ -163,6 +166,13 @@ setup.sonar.enterprise: done; \ echo "\nSonarQube Enterprise is ready at ${enterprise_endpoint}."; \ fi + @if [ -n "$$SONAR_LICENSE" ]; then \ + echo "SONAR_LICENSE is set; activating license on ${enterprise_endpoint}..."; \ + curl -s -u ${username}:${password} -X POST --data-urlencode "license=$$SONAR_LICENSE" \ + "${enterprise_endpoint}/api/editions/set_license" -o /dev/null -w "set_license responded with HTTP %{http_code}\n"; \ + else \ + echo "SONAR_LICENSE not set; running unlicensed. License-gated tests will be skipped."; \ + fi # Teardown SonarQube instance teardown.sonar: diff --git a/integration_testing/enterprise/applications_test.go b/integration_testing/enterprise/applications_test.go new file mode 100644 index 0000000..74d01db --- /dev/null +++ b/integration_testing/enterprise/applications_test.go @@ -0,0 +1,217 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Applications Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + projectKey string + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + projectKey = helpers.UniqueResourceName("app-project") + _, resp, err := client.Projects.Create(context.Background(), &sonar.ProjectsCreateOptions{ + Name: projectKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + cleanup.RegisterCleanup("project", projectKey, func() error { + _, err := client.Projects.Delete(context.Background(), &sonar.ProjectsDeleteOptions{Project: projectKey}) + return err + }) + }) + + AfterAll(func() { + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // Full lifecycle: Create -> Show -> AddProject -> SearchProjects -> + // SetTags -> Update -> RemoveProject -> Delete + // ========================================================================= + Describe("Lifecycle", func() { + It("should create a new application", func() { + appKey := helpers.UniqueResourceName("application") + + result, resp, err := client.Applications.Create(context.Background(), &sonar.ApplicationsCreateOptions{ + Name: appKey, + Key: appKey, + Description: "created by e2e enterprise suite", + Visibility: sonar.ProjectVisibilityPrivate, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + Expect(result.Application.Key).To(Equal(appKey)) + Expect(result.Application.Name).To(Equal(appKey)) + Expect(result.Application.Visibility).To(Equal(sonar.ProjectVisibilityPrivate)) + + cleanup.RegisterCleanup("application", appKey, func() error { + _, err := client.Applications.Delete(context.Background(), &sonar.ApplicationsDeleteOptions{Application: appKey}) + return err + }) + + By("showing the created application") + showResult, showResp, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(showResp.StatusCode).To(Equal(http.StatusOK)) + Expect(showResult).NotTo(BeNil()) + Expect(showResult.Application.Key).To(Equal(appKey)) + Expect(showResult.Application.Projects).To(BeEmpty()) + + By("adding the real project to the application") + addResp, err := client.Applications.AddProject(context.Background(), &sonar.ApplicationsAddProjectOptions{ + Application: appKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(addResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("finding the project via SearchProjects") + searchResult, searchResp, err := client.Applications.SearchProjects(context.Background(), &sonar.ApplicationsSearchProjectsOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(searchResp.StatusCode).To(Equal(http.StatusOK)) + Expect(searchResult).NotTo(BeNil()) + + var found bool + for _, p := range searchResult.Projects { + if p.Key == projectKey { + found = true + + break + } + } + Expect(found).To(BeTrue(), "expected project %q to be listed in application %q", projectKey, appKey) + + By("confirming the project now appears on Show") + showAfterAdd, _, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + + var projectListed bool + for _, p := range showAfterAdd.Application.Projects { + if p.Key == projectKey { + projectListed = true + + break + } + } + Expect(projectListed).To(BeTrue()) + + By("setting tags on the application") + tagsResp, err := client.Applications.SetTags(context.Background(), &sonar.ApplicationsSetTagsOptions{ + Application: appKey, + Tags: []string{"e2e", "enterprise"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(tagsResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("verifying tags were applied") + showAfterTags, _, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(showAfterTags.Application.Tags).To(ConsistOf("e2e", "enterprise")) + + By("updating the application name and description") + newName := appKey + "-updated" + updateResp, err := client.Applications.Update(context.Background(), &sonar.ApplicationsUpdateOptions{ + Application: appKey, + Name: newName, + Description: "updated by e2e enterprise suite", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(updateResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + showAfterUpdate, _, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(showAfterUpdate.Application.Name).To(Equal(newName)) + Expect(showAfterUpdate.Application.Description).To(Equal("updated by e2e enterprise suite")) + + By("removing the project from the application") + removeResp, err := client.Applications.RemoveProject(context.Background(), &sonar.ApplicationsRemoveProjectOptions{ + Application: appKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(removeResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + showAfterRemove, _, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(showAfterRemove.Application.Projects).To(BeEmpty()) + + By("deleting the application") + deleteResp, err := client.Applications.Delete(context.Background(), &sonar.ApplicationsDeleteOptions{ + Application: appKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(deleteResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + _, showAfterDelete, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: appKey, + }) + Expect(err).To(HaveOccurred()) + Expect(showAfterDelete.StatusCode).To(Equal(http.StatusNotFound)) + }) + }) + + // ========================================================================= + // Show / Delete: not-found behavior must be a real 404, never a + // license-gate error, once the suite is running against Enterprise. + // ========================================================================= + Describe("Show", func() { + Context("Functional Tests", func() { + It("should return 404 for a nonexistent application", func() { + result, resp, err := client.Applications.Show(context.Background(), &sonar.ApplicationsShowOptions{ + Application: "nonexistent-application-xyz", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + Expect(result).To(BeNil()) + }) + }) + }) + + Describe("Delete", func() { + Context("Functional Tests", func() { + It("should return 404 for a nonexistent application", func() { + resp, err := client.Applications.Delete(context.Background(), &sonar.ApplicationsDeleteOptions{ + Application: "nonexistent-application-xyz", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/architecture_v2_test.go b/integration_testing/enterprise/architecture_v2_test.go new file mode 100644 index 0000000..60a73bc --- /dev/null +++ b/integration_testing/enterprise/architecture_v2_test.go @@ -0,0 +1,85 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Architecture V2 Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + projectKey string + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + projectKey = helpers.UniqueResourceName("architecture-project") + _, resp, err := client.Projects.Create(context.Background(), &sonar.ProjectsCreateOptions{ + Name: projectKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + cleanup.RegisterCleanup("project", projectKey, func() error { + _, err := client.Projects.Delete(context.Background(), &sonar.ProjectsDeleteOptions{Project: projectKey}) + return err + }) + }) + + AfterAll(func() { + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // FileGraph + // + // Architecture graphs are only populated once a scanner analysis of the + // project has run. This suite has no scanner-analysis helper available, so + // the real project created above is guaranteed to have no analysis data. + // That means a "no graph data" style response (an empty/opaque payload, or + // a not-found-ish error) is a legitimate, expected outcome here. Per + // product decision, the Architecture service does NOT require a license to + // respond meaningfully, so the one thing this test must never accept is a + // license-gate response: not 402 Payment Required, and — since this + // suite's BeforeSuite has already confirmed the server is Enterprise+ and + // architecture analysis itself doesn't require a license — not 403 + // Forbidden either. + // ========================================================================= + Describe("FileGraph", func() { + Context("Functional Tests", func() { + It("should return file graph data or a data-availability error, never a license gate", func() { + result, resp, err := client.V2.Architecture.FileGraph(context.Background(), &sonar.ArchitectureFileGraphOptions{ + ProjectKey: projectKey, + BranchKey: "main", + Source: "java", + }) + if err != nil { + Expect(resp).NotTo(BeNil()) + // Data-availability condition (no analysis has ever run against this + // project), not an enterprise/license gate: explicitly rule out the + // license-gate status codes. + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + } else { + Expect(resp.StatusCode).To(BeNumerically("<", 400)) + Expect(result).NotTo(BeNil()) + } + }) + }) + }) +}) diff --git a/integration_testing/enterprise/audit_logs_test.go b/integration_testing/enterprise/audit_logs_test.go new file mode 100644 index 0000000..2007782 --- /dev/null +++ b/integration_testing/enterprise/audit_logs_test.go @@ -0,0 +1,45 @@ +package enterprise_test + +import ( + "context" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Audit Logs Service", Ordered, func() { + var client *sonar.Client + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + }) + + // ========================================================================= + // Download + // ========================================================================= + Describe("Download", func() { + Context("Functional Tests", func() { + It("should download audit logs for a recent time range", func() { + to := time.Now().UTC() + from := to.Add(-24 * time.Hour) + + result, resp, err := client.AuditLogs.Download(context.Background(), &sonar.AuditLogsDownloadOptions{ + From: from.Format(time.RFC3339), + To: to.Format(time.RFC3339), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/entitlements_v2_test.go b/integration_testing/enterprise/entitlements_v2_test.go new file mode 100644 index 0000000..677d53f --- /dev/null +++ b/integration_testing/enterprise/entitlements_v2_test.go @@ -0,0 +1,170 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +// This suite exercises the license-management API (client.V2.Entitlements) +// itself. Unlike the rest of the enterprise suite, a real license is +// *optional* here too, but this specific file is inherently about whether +// and how a license is provisioned. There is no way to know in this +// environment whether a real, valid license (e.g. via SONAR_LICENSE used by +// `make setup.sonar.enterprise`) will actually be installed when this suite +// runs, so every test below is written to be safe both with and without a +// license present. +// +// DeleteLicense, UpdateLicense and DeactivateOffline are intentionally never +// called anywhere in this file, not even for parameter-validation checks: +// DeactivateOffline and UpdateLicense take no options at all (nothing to +// validate client-side), and invoking any of the three for real would mutate +// license state on a possibly-shared server that may already carry a real, +// valid license. Only read-only calls (GetLicense, GetPurchasableFeatures) +// and client-side-only parameter validation (which never reaches the +// network) are exercised here. +var _ = Describe("Entitlements Service", func() { + var client *sonar.Client + + BeforeEach(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + }) + + // ========================================================================= + // GetLicense: live-verified against an unlicensed instance - unlike its + // name suggests, this is not an unconditionally-succeeding read. It fails + // with 404 {"message":"License not found"} when no license is installed. + // ========================================================================= + Describe("GetLicense", func() { + It("should return license information when licensed, or a not-found error otherwise", func() { + result, resp, err := client.V2.Entitlements.GetLicense(context.Background()) + + if helpers.HasActiveLicense(client) { + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + + GinkgoWriter.Printf("GetLicense: active license detected (edition=%q), asserting license-field values\n", result.Edition) + Expect(result.Edition).NotTo(BeEmpty()) + Expect(result.ValidEdition).To(BeTrue()) + } else { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + Expect(result).To(BeNil()) + + GinkgoWriter.Printf("GetLicense: no active license detected, confirmed 404 as expected\n") + } + }) + }) + + // ========================================================================= + // GetPurchasableFeatures: read-only, content depends on the specific + // license/edition so only structural assertions are made. + // ========================================================================= + Describe("GetPurchasableFeatures", func() { + It("should return the list of purchasable features", func() { + result, resp, err := client.V2.Entitlements.GetPurchasableFeatures(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + }) + }) + + // ========================================================================= + // Mutating operations: parameter validation only. These never reach the + // network (validation happens client-side before the request is built), + // so they are safe to run against a real, possibly-already-licensed + // server. No functional/live calls are made for any of these methods. + // ========================================================================= + Describe("Mutating operations", func() { + Context("Parameter Validation", func() { + Describe("ActivateOnline", func() { + It("should fail with nil options", func() { + resp, err := client.V2.Entitlements.ActivateOnline(context.Background(), nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required")) + Expect(resp).To(BeNil()) + }) + + It("should fail without required license key", func() { + resp, err := client.V2.Entitlements.ActivateOnline(context.Background(), &sonar.EntitlementsActivateOnlineOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("LicenseKey")) + Expect(resp).To(BeNil()) + }) + }) + + Describe("ActivateLegacy", func() { + It("should fail with nil options", func() { + resp, err := client.V2.Entitlements.ActivateLegacy(context.Background(), nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required")) + Expect(resp).To(BeNil()) + }) + + It("should fail without required license key", func() { + resp, err := client.V2.Entitlements.ActivateLegacy(context.Background(), &sonar.EntitlementsActivateLegacyOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("LicenseKey")) + Expect(resp).To(BeNil()) + }) + }) + + Describe("ActivateOffline", func() { + It("should fail with nil options", func() { + resp, err := client.V2.Entitlements.ActivateOffline(context.Background(), nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required")) + Expect(resp).To(BeNil()) + }) + + It("should fail without required license content", func() { + resp, err := client.V2.Entitlements.ActivateOffline(context.Background(), &sonar.EntitlementsActivateOfflineOptions{ + LicenseKey: "ABCD-EFGH-IJKL-MNOP", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("License")) + Expect(resp).To(BeNil()) + }) + + It("should fail without required license key", func() { + resp, err := client.V2.Entitlements.ActivateOffline(context.Background(), &sonar.EntitlementsActivateOfflineOptions{ + License: "some-license-file-content", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("LicenseKey")) + Expect(resp).To(BeNil()) + }) + }) + + Describe("GetOfflineActivationRequest", func() { + It("should fail with nil options", func() { + result, resp, err := client.V2.Entitlements.GetOfflineActivationRequest(context.Background(), nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required")) + Expect(result).To(BeNil()) + Expect(resp).To(BeNil()) + }) + + It("should fail without required license key", func() { + result, resp, err := client.V2.Entitlements.GetOfflineActivationRequest(context.Background(), &sonar.EntitlementsGetOfflineActivationRequestOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("LicenseKey")) + Expect(result).To(BeNil()) + Expect(resp).To(BeNil()) + }) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/governance_reports_test.go b/integration_testing/enterprise/governance_reports_test.go new file mode 100644 index 0000000..bdb8ead --- /dev/null +++ b/integration_testing/enterprise/governance_reports_test.go @@ -0,0 +1,193 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Governance Reports Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + portfolioKey string + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + portfolioKey = helpers.UniqueResourceName("governance-portfolio") + _, err = client.Views.Create(context.Background(), &sonar.ViewsCreateOptions{ + Key: portfolioKey, + Name: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + cleanup.RegisterCleanup("portfolio", portfolioKey, func() error { + _, err := client.Views.Delete(context.Background(), &sonar.ViewsDeleteOptions{Key: portfolioKey}) + + return err + }) + + // Subscribe is live-verified to fail with 400 "User 'admin' has no + // email" on a fresh instance where the admin account has no email + // configured. Since a subscription is inherently tied to a real + // recipient, this is a genuine prerequisite (not a guess to work + // around), so it is satisfied here rather than tolerated as an error. + _, _, err = client.Users.Update(context.Background(), &sonar.UsersUpdateOptions{ + Login: "admin", + Email: "e2e-governance-reports@example.com", + }) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterAll(func() { + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // Status + // ========================================================================= + Describe("Status", func() { + Context("Functional Tests", func() { + It("should return real status for a real portfolio", func() { + // Status reflects report generation rights/metadata, it does not + // require the portfolio to have been analyzed, so this must + // succeed for real on a licensed Enterprise instance. + result, resp, err := client.GovernanceReports.Status(context.Background(), &sonar.GovernanceReportsStatusOptions{ + ComponentKey: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + }) + }) + }) + + // ========================================================================= + // Subscribe / Unsubscribe + // ========================================================================= + Describe("Subscribe", func() { + Context("Functional Tests", func() { + It("should subscribe the current user to reports for the portfolio", func() { + resp, err := client.GovernanceReports.Subscribe(context.Background(), &sonar.GovernanceReportsSubscribeOptions{ + ComponentKey: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + }) + }) + }) + + Describe("Unsubscribe", func() { + Context("Functional Tests", func() { + It("should unsubscribe the current user from reports for the portfolio", func() { + // Subscribe first so there is something real to unsubscribe from. + _, err := client.GovernanceReports.Subscribe(context.Background(), &sonar.GovernanceReportsSubscribeOptions{ + ComponentKey: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + + resp, err := client.GovernanceReports.Unsubscribe(context.Background(), &sonar.GovernanceReportsUnsubscribeOptions{ + ComponentKey: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + }) + }) + }) + + // ========================================================================= + // UpdateFrequency + // ========================================================================= + Describe("UpdateFrequency", func() { + Context("Functional Tests", func() { + It("should update the report frequency for the portfolio", func() { + // Live-verified: the API rejects uppercase frequency values + // (e.g. "WEEKLY") with 400 "must be one of: [daily, weekly, + // monthly]" and only accepts lowercase. + resp, err := client.GovernanceReports.UpdateFrequency(context.Background(), &sonar.GovernanceReportsUpdateFrequencyOptions{ + ComponentKey: portfolioKey, + Frequency: "weekly", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + }) + }) + }) + + // ========================================================================= + // UpdateRecipients + // ========================================================================= + Describe("UpdateRecipients", func() { + Context("Functional Tests", func() { + It("should update the report recipients for the portfolio", func() { + resp, err := client.GovernanceReports.UpdateRecipients(context.Background(), &sonar.GovernanceReportsUpdateRecipientsOptions{ + ComponentKey: portfolioKey, + Recipients: "e2e-governance-reports@example.com", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + }) + }) + }) + + // ========================================================================= + // Download + // ========================================================================= + Describe("Download", func() { + Context("Functional Tests", func() { + It("should fail with a specific data-availability error, never a license-gate error", func() { + // Live-verified against an unanalyzed portfolio: unlike + // regulatory/security reports (which succeed unconditionally + // even with zero analysis data), governance report generation + // genuinely requires analysis and fails with exactly + // 400 {"errors":[{"msg":"No analysis has been done on this + // component"}]} - never a license-gate status. + result, resp, err := client.GovernanceReports.Download(context.Background(), &sonar.GovernanceReportsDownloadOptions{ + ComponentKey: portfolioKey, + }) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + Expect(err.Error()).To(ContainSubstring("No analysis has been done")) + }) + }) + }) + + // ========================================================================= + // Nonexistent component + // ========================================================================= + Describe("Nonexistent portfolio", func() { + Context("Functional Tests", func() { + It("should return a not-found style error, never a license-gate error", func() { + // Even on a confirmed Enterprise server, a nonexistent component + // key must be reported as "not found", not "you need a license". + _, resp, err := client.GovernanceReports.Status(context.Background(), &sonar.GovernanceReportsStatusOptions{ + ComponentKey: "e2e-nonexistent-governance-portfolio", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/regulatory_reports_test.go b/integration_testing/enterprise/regulatory_reports_test.go new file mode 100644 index 0000000..6ff045e --- /dev/null +++ b/integration_testing/enterprise/regulatory_reports_test.go @@ -0,0 +1,88 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Regulatory Reports Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + projectKey string + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + projectKey = helpers.UniqueResourceName("regulatory-project") + _, resp, err := client.Projects.Create(context.Background(), &sonar.ProjectsCreateOptions{ + Name: projectKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + cleanup.RegisterCleanup("project", projectKey, func() error { + _, err := client.Projects.Delete(context.Background(), &sonar.ProjectsDeleteOptions{Project: projectKey}) + + return err + }) + }) + + AfterAll(func() { + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // Download + // ========================================================================= + Describe("Download", func() { + Context("Functional Tests", func() { + It("should download a real report even for an unanalyzed project", func() { + // Live-verified: unlike governance reports, this endpoint + // succeeds unconditionally (200, real ~MB-sized ZIP) even + // against a project with zero analysis data - there is no + // data-availability failure mode to tolerate here. + result, resp, err := client.RegulatoryReports.Download(context.Background(), &sonar.RegulatoryReportsDownloadOptions{ + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeEmpty()) + }) + }) + }) + + // ========================================================================= + // Nonexistent project + // ========================================================================= + Describe("Nonexistent project", func() { + Context("Functional Tests", func() { + It("should return a not-found style error, never a license-gate error", func() { + // Even on a confirmed Enterprise server, a nonexistent project + // key must be reported as "not found", not "you need a license". + _, resp, err := client.RegulatoryReports.Download(context.Background(), &sonar.RegulatoryReportsDownloadOptions{ + Project: "e2e-nonexistent-regulatory-project", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/saml_test.go b/integration_testing/enterprise/saml_test.go new file mode 100644 index 0000000..4f0a1c9 --- /dev/null +++ b/integration_testing/enterprise/saml_test.go @@ -0,0 +1,81 @@ +package enterprise_test + +import ( + "context" + "encoding/base64" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("SAML Service", Ordered, func() { + var client *sonar.Client + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + }) + + // ========================================================================= + // Validation + // ========================================================================= + Describe("Validation", func() { + Context("Functional Tests", func() { + It("should return a genuine validation outcome for a well-formed but bogus SAML response", func() { + // Valid base64, but not a real IdP-signed assertion. This is + // well-formed enough to reach the server's actual SAML validation + // logic instead of being rejected purely on transport/encoding + // malformation, so the server should give a real, definitive answer. + fakeAssertion := base64.StdEncoding.EncodeToString([]byte("not-a-real-assertion")) + + result, resp, err := client.Saml.Validation(context.Background(), &sonar.SamlValidationOptions{ + SAMLResponse: fakeAssertion, + }) + if err != nil { + Expect(resp).NotTo(BeNil()) + // SAML is not itself a license-gated feature: a 402/403 here + // would indicate an (incorrect) license/edition gate rather than + // a legitimate validation-failure response. It may, however, + // legitimately be unconfigured on the test server, which surfaces + // as some other 4xx - a distinct condition from a license gate, + // so it is tolerated narrowly here (4xx only, license codes excluded). + Expect(resp.StatusCode).NotTo(BeElementOf(http.StatusPaymentRequired, http.StatusForbidden)) + Expect(resp.StatusCode).To(BeNumerically(">=", http.StatusBadRequest)) + Expect(resp.StatusCode).To(BeNumerically("<", http.StatusInternalServerError)) + } else { + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + Expect(result).NotTo(BeEmpty()) + } + }) + }) + }) + + // ========================================================================= + // ValidationInit + // ========================================================================= + Describe("ValidationInit", func() { + Context("Functional Tests", func() { + It("should initiate the SAML validation flow or report a non-license-related condition", func() { + resp, err := client.Saml.ValidationInit(context.Background()) + if err != nil { + Expect(resp).NotTo(BeNil()) + // Same reasoning as Validation above: SAML is not license-gated, + // so a license/edition-gate status here would be a genuine defect, + // while an "unconfigured" style 4xx is a legitimate, distinct + // condition on a test server that has no SAML identity provider set up. + Expect(resp.StatusCode).NotTo(BeElementOf(http.StatusPaymentRequired, http.StatusForbidden)) + } else { + Expect(resp.StatusCode).To(BeNumerically(">=", http.StatusOK)) + Expect(resp.StatusCode).To(BeNumerically("<", http.StatusMultipleChoices)) + } + }) + }) + }) +}) diff --git a/integration_testing/enterprise/sca_v2_test.go b/integration_testing/enterprise/sca_v2_test.go new file mode 100644 index 0000000..16ced31 --- /dev/null +++ b/integration_testing/enterprise/sca_v2_test.go @@ -0,0 +1,226 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("SCA V2 Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + projectKey string + originalEnablement bool + originalEnablementCaptured bool + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + projectKey = helpers.UniqueResourceName("sca-project") + _, resp, err := client.Projects.Create(context.Background(), &sonar.ProjectsCreateOptions{ + Name: projectKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + cleanup.RegisterCleanup("project", projectKey, func() error { + _, err := client.Projects.Delete(context.Background(), &sonar.ProjectsDeleteOptions{Project: projectKey}) + return err + }) + }) + + AfterAll(func() { + // Restore SCA's original enablement state so this suite doesn't leave + // the server's SCA feature flag permanently flipped for other tests / + // other suites run against the same instance. Only meaningful when a + // license was active, since SetEnablement itself is license-gated. + if originalEnablementCaptured { + _, resp, err := client.V2.Sca.SetEnablement(context.Background(), &sonar.ScaSetEnablementOptions{ + Enablement: originalEnablement, + }) + if err != nil { + GinkgoWriter.Printf("Failed to restore original SCA enablement (%v): %v\n", originalEnablement, err) + } else { + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + } + } + + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // Enablement toggle. Live-verified against an unlicensed instance: unlike + // what the endpoint's shape suggests, both GetEnablement and SetEnablement + // require an active license and fail with 403 {"message":"Not available"} + // without one - this is a genuine license gate, not a plain feature flag. + // ========================================================================= + Describe("Enablement", func() { + Context("Functional Tests", func() { + It("should get the current enablement state, or fail with 403 when unlicensed", func() { + result, resp, err := client.V2.Sca.GetEnablement(context.Background()) + + if helpers.HasActiveLicense(client) { + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + + originalEnablement = result.Enablement + originalEnablementCaptured = true + } else { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(result).To(BeNil()) + } + }) + + It("should enable SCA, or fail with 403 when unlicensed", func() { + result, resp, err := client.V2.Sca.SetEnablement(context.Background(), &sonar.ScaSetEnablementOptions{ + Enablement: true, + }) + + if helpers.HasActiveLicense(client) { + Expect(originalEnablementCaptured).To(BeTrue(), "GetEnablement must run before SetEnablement") + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + Expect(result).NotTo(BeNil()) + } else { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(result).To(BeNil()) + } + }) + + It("should reflect the enabled state on a subsequent read, or fail with 403 when unlicensed", func() { + result, resp, err := client.V2.Sca.GetEnablement(context.Background()) + + if helpers.HasActiveLicense(client) { + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + Expect(result.Enablement).To(BeTrue()) + } else { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(result).To(BeNil()) + } + }) + }) + }) + + // ========================================================================= + // ListClis: a static CLI listing, independent of any project's analysis + // state or a license. + // ========================================================================= + Describe("ListClis", func() { + Context("Functional Tests", func() { + It("should list available SCA CLI downloads", func() { + result, resp, err := client.V2.Sca.ListClis(context.Background(), nil) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + }) + }) + }) + + // ========================================================================= + // SearchDependencyRisks / SearchReleases / GetSbomReport against the real + // (but unanalyzed) project. Live-verified against an unlicensed instance: + // since SCA can never be successfully enabled without a license (per the + // Enablement block above), these downstream endpoints consistently fail + // with 403 {"message":"SCA feature is not enabled for ''"}. On a + // licensed instance where SCA was actually enabled, no scan has run + // against this project (this suite has no analysis helper), so an empty + // result set is the expected data-availability outcome, and a license + // gate (402/403) must never occur. + // ========================================================================= + Describe("SearchDependencyRisks", func() { + Context("Functional Tests", func() { + It("should search dependency risks for the real project, or fail with 403 when SCA isn't enabled", func() { + result, resp, err := client.V2.Sca.SearchDependencyRisks(context.Background(), &sonar.ScaDependencyRisksSearchOptions{ + ProjectKey: projectKey, + }) + + if !helpers.HasActiveLicense(client) { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(result).To(BeNil()) + } else if err != nil { + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + } else { + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + } + }) + }) + }) + + Describe("SearchReleases", func() { + Context("Functional Tests", func() { + It("should search releases for the real project, or fail with 403 when SCA isn't enabled", func() { + result, resp, err := client.V2.Sca.SearchReleases(context.Background(), &sonar.ScaReleasesSearchOptions{ + ProjectKey: projectKey, + }) + + if !helpers.HasActiveLicense(client) { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(result).To(BeNil()) + } else if err != nil { + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + } else { + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + } + }) + }) + }) + + Describe("GetSbomReport", func() { + Context("Functional Tests", func() { + It("should return an SBOM report for the real project, or fail with 403 when SCA isn't enabled", func() { + result, resp, err := client.V2.Sca.GetSbomReport(context.Background(), &sonar.ScaSbomReportOptions{ + Component: projectKey, + Type: sonar.ScaSbomReportTypeCycloneDX, + Format: sonar.ScaSbomReportFormatJSON, + }) + + if !helpers.HasActiveLicense(client) { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + Expect(result).To(BeNil()) + } else if err != nil { + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + } else { + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + } + }) + }) + }) +}) diff --git a/integration_testing/enterprise/scim_management_test.go b/integration_testing/enterprise/scim_management_test.go new file mode 100644 index 0000000..aa5847e --- /dev/null +++ b/integration_testing/enterprise/scim_management_test.go @@ -0,0 +1,81 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("SCIM Management Service", Ordered, func() { + var client *sonar.Client + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + }) + + // ========================================================================= + // Status / Disable: live-verified to work unconditionally, regardless of + // whether SAML is configured or SCIM was ever enabled. + // ========================================================================= + Describe("Status", func() { + Context("Functional Tests", func() { + It("should report the current SCIM provisioning status", func() { + result, resp, err := client.ScimManagement.Status(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + }) + }) + }) + + Describe("Disable", func() { + Context("Functional Tests", func() { + It("should succeed even when SCIM was never enabled", func() { + // Live-verified: Disable returns 204 unconditionally, even + // against a server where SCIM has never been configured. + resp, err := client.ScimManagement.Disable(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + result, statusResp, err := client.ScimManagement.Status(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(statusResp.StatusCode).To(Equal(http.StatusOK)) + Expect(result.Enabled).To(BeFalse()) + }) + }) + }) + + // ========================================================================= + // Enable: live-verified via the SonarQube container logs to fail with 500 + // and java.lang.IllegalStateException: "SAML must be enabled to enable + // SCIM." - a genuine server-side precondition (SCIM provisioning rides on + // top of SAML SSO), not a license gate or an SDK bug. This suite has no + // SAML IdP configured, so Enable is expected to fail with exactly that + // error rather than a guessed-at success. + // ========================================================================= + Describe("Enable", func() { + Context("Functional Tests", func() { + It("should fail without a configured SAML identity provider", func() { + resp, err := client.ScimManagement.Enable(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusInternalServerError)) + + result, statusResp, statusErr := client.ScimManagement.Status(context.Background()) + Expect(statusErr).NotTo(HaveOccurred()) + Expect(statusResp.StatusCode).To(Equal(http.StatusOK)) + Expect(result.Enabled).To(BeFalse()) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/security_reports_test.go b/integration_testing/enterprise/security_reports_test.go new file mode 100644 index 0000000..467f766 --- /dev/null +++ b/integration_testing/enterprise/security_reports_test.go @@ -0,0 +1,115 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Security Reports Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + projectKey string + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + projectKey = helpers.UniqueResourceName("security-project") + _, resp, err := client.Projects.Create(context.Background(), &sonar.ProjectsCreateOptions{ + Name: projectKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + cleanup.RegisterCleanup("project", projectKey, func() error { + _, err := client.Projects.Delete(context.Background(), &sonar.ProjectsDeleteOptions{Project: projectKey}) + + return err + }) + }) + + AfterAll(func() { + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // Show + // ========================================================================= + Describe("Show", func() { + Context("Functional Tests", func() { + It("should return a real (zero-valued) report even for an unanalyzed project", func() { + // Live-verified: this endpoint succeeds unconditionally (200, + // full OWASP Top 10 category structure, zero-valued since no + // analysis has run) - there is no data-availability failure + // mode to tolerate here. + result, resp, err := client.SecurityReports.Show(context.Background(), &sonar.SecurityReportsShowOptions{ + Project: projectKey, + Standard: "owaspTop10", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + }) + }) + }) + + // ========================================================================= + // Download + // ========================================================================= + Describe("Download", func() { + Context("Functional Tests", func() { + It("should download a real report even for an unanalyzed project", func() { + // Live-verified: like Show, this endpoint succeeds + // unconditionally (200, real ~17MB PDF) even without any + // scanner analysis - there is no data-availability failure + // mode to tolerate here. Unlike Show, Download only accepts a + // restricted subset of standards ("sonarsourceSecurity", + // "casa", "owaspMasvs-v2"); "owaspTop10" - valid for Show - + // is rejected here with 400 "Standard is not supported". + result, resp, err := client.SecurityReports.Download(context.Background(), &sonar.SecurityReportsDownloadOptions{ + Project: projectKey, + Standards: []string{"sonarsourceSecurity"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeEmpty()) + }) + }) + }) + + // ========================================================================= + // Nonexistent project + // ========================================================================= + Describe("Nonexistent project", func() { + Context("Functional Tests", func() { + It("should return a not-found style error, never a license-gate error", func() { + // Even on a confirmed Enterprise server, a nonexistent project + // key must be reported as "not found", not "you need a license". + _, resp, err := client.SecurityReports.Show(context.Background(), &sonar.SecurityReportsShowOptions{ + Project: "e2e-nonexistent-security-project", + Standard: "owaspTop10", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).NotTo(Equal(http.StatusPaymentRequired)) + Expect(resp.StatusCode).NotTo(Equal(http.StatusForbidden)) + }) + }) + }) +}) diff --git a/integration_testing/enterprise/suite_test.go b/integration_testing/enterprise/suite_test.go new file mode 100644 index 0000000..b8e1c4f --- /dev/null +++ b/integration_testing/enterprise/suite_test.go @@ -0,0 +1,42 @@ +// Package enterprise_test contains integration tests that only make sense +// against a real SonarQube Enterprise Edition (or above) instance. +// +// Unlike the specs in integration_testing/, these specs do not tolerate an +// "enterprise-only error" as a passing outcome: they assert the actual +// enterprise behavior. The whole suite is skipped up front (not failed) if +// the connected server is not running Enterprise Edition or above, so it is +// safe to run in any environment via `make e2e.enterprise` — see the +// project Makefile and README for how to point it at a licensed instance. +package enterprise_test + +import ( + "fmt" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" +) + +var _ = BeforeSuite(func() { + client, err := helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + + edition, err := helpers.GetEdition(client) + Expect(err).NotTo(HaveOccurred()) + + if !helpers.IsEnterpriseOrAbove(edition) { + Skip(fmt.Sprintf("this suite requires a SonarQube Enterprise Edition (or above) instance, got edition %q", edition)) + } + + err = helpers.CleanupOrphanedResources(client, 0*time.Second) + Expect(err).NotTo(HaveOccurred()) +}) + +func TestEnterpriseIntegrationTesting(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "SonarQube SDK Enterprise Edition E2E Test Suite") +} diff --git a/integration_testing/enterprise/support_test.go b/integration_testing/enterprise/support_test.go new file mode 100644 index 0000000..8b184b2 --- /dev/null +++ b/integration_testing/enterprise/support_test.go @@ -0,0 +1,64 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Support Service", Ordered, func() { + var client *sonar.Client + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + }) + + // ========================================================================= + // Info + // ========================================================================= + Describe("Info", func() { + Context("Functional Tests", func() { + It("should return system support information, or a license-not-found error when unlicensed", func() { + // Unlike most endpoints covered by this suite, Support.Info is + // genuinely license-gated: it has been live-verified (see the + // WARNING in sonar/support_service.go) to require an actual + // installed commercial license independent of Enterprise Edition + + // 'Administer System' permission, failing with HTTP 400 and body + // {"errors":[{"msg":"License not found"}]} otherwise. This is one + // of the "some other cases" the license-optional design + // acknowledges, so branch on whether a license is actually active. + result, resp, err := client.Support.Info(context.Background()) + + if helpers.HasActiveLicense(client) { + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(result).NotTo(BeNil()) + + // The System and SonarQube sections are always populated on a + // real server (server ID, version, uptime, etc.), regardless of + // which optional sections (e.g. Statistics) are included. + Expect(result.System).NotTo(BeEmpty()) + Expect(result.SonarQube).NotTo(BeEmpty()) + + GinkgoWriter.Printf("Support.Info succeeded with an active license\n") + } else { + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + Expect(result).To(BeNil()) + + GinkgoWriter.Printf("Support.Info failed with %q as expected: no active license\n", err.Error()) + } + }) + }) + }) +}) diff --git a/integration_testing/enterprise/views_test.go b/integration_testing/enterprise/views_test.go new file mode 100644 index 0000000..7aabed2 --- /dev/null +++ b/integration_testing/enterprise/views_test.go @@ -0,0 +1,280 @@ +package enterprise_test + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/boxboxjason/sonarqube-client-go/v2/integration_testing/helpers" + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +var _ = Describe("Views Service", Ordered, func() { + var ( + client *sonar.Client + cleanup *helpers.CleanupManager + projectKey string + ) + + BeforeAll(func() { + var err error + client, err = helpers.NewDefaultClient() + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + cleanup = helpers.NewCleanupManager(client) + + projectKey = helpers.UniqueResourceName("view-project") + _, resp, err := client.Projects.Create(context.Background(), &sonar.ProjectsCreateOptions{ + Name: projectKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + cleanup.RegisterCleanup("project", projectKey, func() error { + _, err := client.Projects.Delete(context.Background(), &sonar.ProjectsDeleteOptions{Project: projectKey}) + return err + }) + }) + + AfterAll(func() { + errors := cleanup.Cleanup() + for _, err := range errors { + GinkgoWriter.Printf("Cleanup error: %v\n", err) + } + }) + + // ========================================================================= + // Full lifecycle: Create -> Show -> AddProject -> Projects -> Search -> + // Update -> Create (sub) -> AddPortfolio -> Show -> RemovePortfolio -> + // Delete (sub) -> RemoveProject -> Projects -> Delete -> Show (404) + // ========================================================================= + Describe("Lifecycle", func() { + It("should run a full portfolio lifecycle", func() { + portfolioKey := helpers.UniqueResourceName("portfolio") + + By("creating a new root portfolio") + resp, err := client.Views.Create(context.Background(), &sonar.ViewsCreateOptions{ + Name: portfolioKey, + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + cleanup.RegisterCleanup("portfolio", portfolioKey, func() error { + _, delErr := client.Views.Delete(context.Background(), &sonar.ViewsDeleteOptions{Key: portfolioKey}) + return helpers.IgnoreNotFoundError(delErr) + }) + + By("showing the freshly created portfolio") + show, _, err := client.Views.Show(context.Background(), &sonar.ViewsShowOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(show.Key).To(Equal(portfolioKey)) + Expect(show.Name).To(Equal(portfolioKey)) + Expect(show.SubViews).To(BeEmpty()) + + By("adding the real project to the portfolio") + addProjectResp, err := client.Views.AddProject(context.Background(), &sonar.ViewsAddProjectOptions{ + Key: portfolioKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(addProjectResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("confirming the project is now listed in the portfolio") + projects, _, err := client.Views.Projects(context.Background(), &sonar.ViewsProjectsOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + + var projectListed bool + for _, p := range projects.Projects { + if p.Key == projectKey { + projectListed = true + + break + } + } + Expect(projectListed).To(BeTrue(), "expected project %q to be listed in portfolio %q", projectKey, portfolioKey) + + By("searching for the portfolio by key") + search, _, err := client.Views.Search(context.Background(), &sonar.ViewsSearchOptions{ + Query: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + + var portfolioFound bool + for _, c := range search.Components { + if c.Key == portfolioKey { + portfolioFound = true + + break + } + } + Expect(portfolioFound).To(BeTrue(), "expected portfolio %q to be listed in search results", portfolioKey) + + By("updating the portfolio name and description") + newName := portfolioKey + "-updated" + updateResp, err := client.Views.Update(context.Background(), &sonar.ViewsUpdateOptions{ + Key: portfolioKey, + Name: newName, + Description: "updated by e2e enterprise suite", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(updateResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("verifying the update took effect") + showAfterUpdate, _, err := client.Views.Show(context.Background(), &sonar.ViewsShowOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(showAfterUpdate.Name).To(Equal(newName)) + Expect(showAfterUpdate.Description).To(Equal("updated by e2e enterprise suite")) + + By("creating a second portfolio to use as a sub-portfolio") + subPortfolioKey := helpers.UniqueResourceName("sub-portfolio") + subResp, err := client.Views.Create(context.Background(), &sonar.ViewsCreateOptions{ + Name: subPortfolioKey, + Key: subPortfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(subResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + cleanup.RegisterCleanup("portfolio", subPortfolioKey, func() error { + _, delErr := client.Views.Delete(context.Background(), &sonar.ViewsDeleteOptions{Key: subPortfolioKey}) + return helpers.IgnoreNotFoundError(delErr) + }) + + By("adding the second portfolio as a sub-portfolio of the first") + addPortfolioResp, err := client.Views.AddPortfolio(context.Background(), &sonar.ViewsAddPortfolioOptions{ + Portfolio: portfolioKey, + Reference: subPortfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(addPortfolioResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("confirming the sub-portfolio is nested under the parent") + showWithSub, _, err := client.Views.Show(context.Background(), &sonar.ViewsShowOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + + // Live-verified: a nested sub-portfolio's Key is a composite + // ":" key, not its own key - OriginalKey carries + // the sub-portfolio's own key. + var subFound bool + for _, sv := range showWithSub.SubViews { + if sv.OriginalKey == subPortfolioKey { + subFound = true + + break + } + } + Expect(subFound).To(BeTrue(), "expected sub-portfolio %q to be nested under %q", subPortfolioKey, portfolioKey) + + By("removing the sub-portfolio from the parent") + removePortfolioResp, err := client.Views.RemovePortfolio(context.Background(), &sonar.ViewsRemovePortfolioOptions{ + Portfolio: portfolioKey, + Reference: subPortfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(removePortfolioResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("confirming the sub-portfolio is no longer nested under the parent") + showAfterRemoveSub, _, err := client.Views.Show(context.Background(), &sonar.ViewsShowOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + + subFound = false + for _, sv := range showAfterRemoveSub.SubViews { + if sv.OriginalKey == subPortfolioKey { + subFound = true + + break + } + } + Expect(subFound).To(BeFalse()) + + By("deleting the now-standalone sub-portfolio") + delSubResp, err := client.Views.Delete(context.Background(), &sonar.ViewsDeleteOptions{ + Key: subPortfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(delSubResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("removing the project from the portfolio") + removeProjectResp, err := client.Views.RemoveProject(context.Background(), &sonar.ViewsRemoveProjectOptions{ + Key: portfolioKey, + Project: projectKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(removeProjectResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("confirming the project no longer appears in the portfolio") + projectsAfterRemove, _, err := client.Views.Projects(context.Background(), &sonar.ViewsProjectsOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + + projectListed = false + for _, p := range projectsAfterRemove.Projects { + if p.Key == projectKey { + projectListed = true + + break + } + } + Expect(projectListed).To(BeFalse()) + + By("deleting the portfolio") + delResp, err := client.Views.Delete(context.Background(), &sonar.ViewsDeleteOptions{ + Key: portfolioKey, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(delResp.StatusCode).To(BeElementOf(http.StatusOK, http.StatusNoContent)) + + By("confirming the portfolio no longer exists") + _, showResp, err := client.Views.Show(context.Background(), &sonar.ViewsShowOptions{ + Key: portfolioKey, + }) + Expect(err).To(HaveOccurred()) + Expect(showResp).NotTo(BeNil()) + Expect(showResp.StatusCode).To(Equal(http.StatusNotFound)) + }) + }) + + // ========================================================================= + // Show + // ========================================================================= + Describe("Show", func() { + Context("Functional Tests", func() { + It("should return 404 nonexistent portfolio", func() { + result, resp, err := client.Views.Show(context.Background(), &sonar.ViewsShowOptions{ + Key: "nonexistent-portfolio-xyz", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + Expect(result).To(BeNil()) + }) + }) + }) + + // ========================================================================= + // Delete + // ========================================================================= + Describe("Delete", func() { + Context("Functional Tests", func() { + It("should return 404 nonexistent portfolio", func() { + resp, err := client.Views.Delete(context.Background(), &sonar.ViewsDeleteOptions{ + Key: "nonexistent-portfolio-xyz", + }) + Expect(err).To(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + }) + }) + }) +}) diff --git a/integration_testing/helpers/edition.go b/integration_testing/helpers/edition.go new file mode 100644 index 0000000..e492c2d --- /dev/null +++ b/integration_testing/helpers/edition.go @@ -0,0 +1,42 @@ +package helpers + +import ( + "context" + "strings" + + "github.com/boxboxjason/sonarqube-client-go/v2/sonar" +) + +// GetEdition returns the SonarQube edition reported by the connected server +// (e.g. "community", "developer", "enterprise", "datacenter"). It relies on +// the System Info endpoint, which does not require an active license. +func GetEdition(client *sonar.Client) (string, error) { + info, _, err := client.System.Info(context.Background()) + if err != nil { + return "", err + } + + return info.System.Edition, nil +} + +// IsEnterpriseOrAbove reports whether the given edition string is Enterprise +// or Data Center edition (Data Center being a superset of Enterprise). +func IsEnterpriseOrAbove(edition string) bool { + normalized := strings.ToLower(strings.ReplaceAll(edition, " ", "")) + + return strings.Contains(normalized, "enterprise") || strings.Contains(normalized, "datacenter") +} + +// HasActiveLicense reports whether the connected server currently has a +// valid, supported Enterprise Edition license installed. It never returns +// an error: any failure to determine license state is treated as "no +// license", since license activation is optional for most enterprise-only +// endpoints covered by this suite. +func HasActiveLicense(client *sonar.Client) bool { + result, _, err := client.V2.Entitlements.GetLicense(context.Background()) + if err != nil || result == nil { + return false + } + + return result.Supported && !result.Expired +} diff --git a/integration_testing/settings_test.go b/integration_testing/settings_test.go index 6045f19..864f5ce 100644 --- a/integration_testing/settings_test.go +++ b/integration_testing/settings_test.go @@ -167,7 +167,17 @@ var _ = Describe("Settings Service", Ordered, func() { }) Expect(err).NotTo(HaveOccurred()) Expect(values.Settings).NotTo(BeEmpty()) - Expect(values.Settings[0].Value).To(Equal("E2E Test Login Message")) + // Live-verified: sonar.login.message is reported back via the + // singular "value" field on Community Edition (26.7.0) but via + // the plural "values" array on Enterprise Edition + // (2026.3.1-enterprise), even though it was set via the same + // singular Value option in both cases - check both shapes. + setting := values.Settings[0] + reportedValues := setting.Values + if setting.Value != "" { + reportedValues = append(reportedValues, setting.Value) + } + Expect(reportedValues).To(ContainElement("E2E Test Login Message")) // Clean up - reset the setting _, _ = client.Settings.Reset(context.Background(), &sonar.SettingsResetOptions{ @@ -410,7 +420,17 @@ var _ = Describe("Settings Service", Ordered, func() { }) Expect(err).NotTo(HaveOccurred()) Expect(values.Settings).NotTo(BeEmpty()) - Expect(values.Settings[0].Value).To(Equal("Welcome to E2E Testing!")) + // Live-verified: sonar.login.message is reported back via the + // singular "value" field on Community Edition (26.7.0) but via + // the plural "values" array on Enterprise Edition + // (2026.3.1-enterprise), even though it was set via the same + // singular Value option in both cases - check both shapes. + setting := values.Settings[0] + reportedValues := setting.Values + if setting.Value != "" { + reportedValues = append(reportedValues, setting.Value) + } + Expect(reportedValues).To(ContainElement("Welcome to E2E Testing!")) // The LoginMessage endpoint may return empty due to SonarQube behavior // but the setting value is correctly stored diff --git a/sonar/client.go b/sonar/client.go index c64f04f..6d29524 100644 --- a/sonar/client.go +++ b/sonar/client.go @@ -617,6 +617,12 @@ type SonarAPIRequestParameters struct { // Body is the request body to include in the API request. It will be // JSON-encoded if not nil. Body any + // RootPath resolves Path against the server root instead of the client's + // API base path (which always ends in "api/"). A handful of legacy + // endpoints (e.g. the SAML assertion consumer service) are mounted + // directly on the server root rather than under "api/"; this flag lets + // those requests reach the correct URL. + RootPath bool } // NewSonarQubeAPIRequest creates a new API request based on the provided @@ -728,6 +734,10 @@ func (c *Client) buildRequestURL(params SonarAPIRequestParameters) string { baseURLCopy := *c.baseURL c.mu.RUnlock() + if params.RootPath { + baseURLCopy.Path = strings.TrimSuffix(baseURLCopy.Path, "api/") + } + baseURLCopy.Path += params.Path if params.RawQuery != nil { diff --git a/sonar/governance_reports_service.go b/sonar/governance_reports_service.go index cc225be..2fd9861 100644 --- a/sonar/governance_reports_service.go +++ b/sonar/governance_reports_service.go @@ -99,7 +99,7 @@ type GovernanceReportsUpdateFrequencyOptions struct { ComponentID string `url:"componentId,omitempty"` // ComponentKey is the component key. Optional. ComponentKey string `url:"componentKey,omitempty"` - // Frequency is the report frequency. Optional. Valid values: DAILY, WEEKLY, MONTHLY. + // Frequency is the report frequency. Optional. Valid values: daily, weekly, monthly. Frequency string `url:"frequency,omitempty"` } diff --git a/sonar/saml_service.go b/sonar/saml_service.go index 5d93c86..7b08e5b 100644 --- a/sonar/saml_service.go +++ b/sonar/saml_service.go @@ -56,6 +56,12 @@ func (s *SamlService) ValidateValidationOpt(opt *SamlValidationOptions) error { // URL query parameter, since real SAML assertions are too large to fit // reliably within URL length limits. // +// Unlike the rest of the SonarQube API, this endpoint is mounted on the +// server root rather than under "api/" (live-verified against a SonarQube +// Enterprise instance: "api/saml/validation" 404s with "Unknown url", while +// "saml/validation" reaches the real handler), so the request bypasses the +// client's API base path. +// // API endpoint: POST saml/validation. // Since: 9.7. // Internal endpoint. @@ -71,9 +77,10 @@ func (s *SamlService) Validation(ctx context.Context, opt *SamlValidationOptions //nolint:exhaustruct // RawQuery intentionally unset: SAMLResponse is sent in the body, not the query string req, err := s.client.NewSonarQubeAPIRequest(ctx, SonarAPIRequestParameters{ - Method: http.MethodPost, - Path: "saml/validation", - Body: formBody, + Method: http.MethodPost, + Path: "saml/validation", + Body: formBody, + RootPath: true, Headers: map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }, @@ -97,11 +104,19 @@ func (s *SamlService) Validation(ctx context.Context, opt *SamlValidationOptions // provider so that an administrator can validate the SAML configuration // before it is enforced. // +// Like Validation, this endpoint is mounted on the server root rather than +// under "api/", so the request bypasses the client's API base path. +// // API endpoint: GET saml/validation_init. // Since: 9.7. // Internal endpoint. func (s *SamlService) ValidationInit(ctx context.Context) (*http.Response, error) { - req, err := s.client.NewSonarQubeV1APIRequest(ctx, http.MethodGet, "saml/validation_init", nil) + //nolint:exhaustruct // Body and Headers intentionally unset for this GET request + req, err := s.client.NewSonarQubeAPIRequest(ctx, SonarAPIRequestParameters{ + Method: http.MethodGet, + Path: "saml/validation_init", + RootPath: true, + }) if err != nil { return nil, err } diff --git a/sonar/security_reports_service.go b/sonar/security_reports_service.go index c86a1b2..19b967e 100644 --- a/sonar/security_reports_service.go +++ b/sonar/security_reports_service.go @@ -83,8 +83,14 @@ type SecurityReportsDownloadOptions struct { // Project is the project key. This field is required. Project string `url:"project"` - // Standards is the list of standards to include in the report. - // If omitted, all standards are included. Optional. + // Standards is the list of standards to include in the report. If + // omitted, all standards are included. Optional. + // + // Unlike Show, this endpoint only supports a subset of the standards + // accepted by allowedSecurityStandards: live-verified, only + // "sonarsourceSecurity", "casa", and "owaspMasvs-v2" are accepted here - + // every other standard (including "owaspTop10") fails with 400 + // {"errors":[{"msg":"Standard '' is not supported"}]}. Standards []string `url:"standards,omitempty,comma"` } @@ -118,6 +124,19 @@ var allowedSecurityStandards = map[string]struct{}{ "owaspMobileTop10": {}, } +// allowedSecurityReportDownloadStandards is the subset of +// allowedSecurityStandards actually accepted by the download endpoint. +// Live-verified: every other standard, including "owaspTop10" (the most +// commonly expected one), fails with 400 "Standard '' is not +// supported" on download, despite being valid for Show. +// +//nolint:gochecknoglobals // constant set of allowed values +var allowedSecurityReportDownloadStandards = map[string]struct{}{ + "sonarsourceSecurity": {}, + "casa": {}, + "owaspMasvs-v2": {}, +} + // ----------------------------------------------------------------------------- // Validation Functions // ----------------------------------------------------------------------------- @@ -134,7 +153,7 @@ func (s *SecurityReportsService) ValidateDownloadOpt(opt *SecurityReportsDownloa } if len(opt.Standards) > 0 { - return AreValuesAuthorized(opt.Standards, allowedSecurityStandards, "Standards") + return AreValuesAuthorized(opt.Standards, allowedSecurityReportDownloadStandards, "Standards") } return nil diff --git a/sonar/views_service.go b/sonar/views_service.go index aa16048..9b39406 100644 --- a/sonar/views_service.go +++ b/sonar/views_service.go @@ -19,9 +19,18 @@ type ViewsService struct { // View represents a SonarQube portfolio/view. type View struct { // Description is the portfolio description. - Description string `json:"description,omitempty"` - // Key is the portfolio key. + // + // Live-verified: the real API field is "desc", not "description". + Description string `json:"desc,omitempty"` + // Key is the portfolio key. When this View represents a sub-portfolio + // nested under a parent (e.g. within ViewDetails.SubViews), this is a + // composite ":" key, not the sub-portfolio's own key - + // use OriginalKey for that. Key string `json:"key,omitempty"` + // OriginalKey is the sub-portfolio's own key, without the parent-key + // prefix that Key carries in a nested context. Only populated when this + // View represents a sub-portfolio. + OriginalKey string `json:"originalKey,omitempty"` // Name is the portfolio name. Name string `json:"name,omitempty"` // Qualifier is the qualifier of the component (VW for portfolio, SVW for sub-portfolio). @@ -33,7 +42,9 @@ type View struct { // ViewDetails is an extended portfolio with sub-portfolios and selection modes. type ViewDetails struct { // Description is the portfolio description. - Description string `json:"description,omitempty"` + // + // Live-verified: the real API field is "desc", not "description". + Description string `json:"desc,omitempty"` // Key is the portfolio key. Key string `json:"key,omitempty"` // Name is the portfolio name. @@ -54,8 +65,14 @@ type ViewProject struct { Key string `json:"key,omitempty"` // Name is the project name. Name string `json:"name,omitempty"` + // BranchKey lists the branch keys selected for the project, if any. + BranchKey []string `json:"branchKey,omitempty"` // Selected indicates whether the project is selected in the portfolio. Selected bool `json:"selected,omitempty"` + // Enabled indicates whether the project is enabled in the portfolio. + Enabled bool `json:"enabled,omitempty"` + // Accessible indicates whether the current user can access the project. + Accessible bool `json:"accessible,omitempty"` } // ViewProjectStatus represents a project with its quality gate status in a portfolio. @@ -100,16 +117,13 @@ type ViewsSearch struct { Paging Paging `json:"paging,omitzero"` } -// ViewsShow represents the response from the show endpoint. -type ViewsShow struct { - // Portfolio contains the portfolio details. - Portfolio ViewDetails `json:"portfolio,omitzero"` -} - // ViewsProjects represents the response from the projects endpoint. type ViewsProjects struct { // Projects is the list of projects in the portfolio. - Projects []ViewProject `json:"projects,omitempty"` + // + // Live-verified: unlike most list endpoints in this SDK, the real + // response wraps this list in a "results" field, not "projects". + Projects []ViewProject `json:"results,omitempty"` // Paging contains pagination information. Paging Paging `json:"paging,omitzero"` } @@ -1310,10 +1324,14 @@ func (s *ViewsService) SetTagsMode(ctx context.Context, opt *ViewsSetTagsModeOpt // Show returns the details of a portfolio, including sub-portfolios. // Requires 'Browse' permission on the portfolio. // +// The response body is the portfolio object itself (live-verified: it is +// not wrapped in a "portfolio" field, despite what the endpoint's name +// might suggest). +// // API endpoint: GET /api/views/show. // Since: 6.6. // Enterprise Edition only. -func (s *ViewsService) Show(ctx context.Context, opt *ViewsShowOptions) (*ViewsShow, *http.Response, error) { +func (s *ViewsService) Show(ctx context.Context, opt *ViewsShowOptions) (*ViewDetails, *http.Response, error) { err := s.ValidateShowOpt(opt) if err != nil { return nil, nil, err @@ -1324,7 +1342,7 @@ func (s *ViewsService) Show(ctx context.Context, opt *ViewsShowOptions) (*ViewsS return nil, nil, err } - result := new(ViewsShow) + result := new(ViewDetails) resp, err := s.client.Do(req, result) if err != nil { diff --git a/sonar/views_service_test.go b/sonar/views_service_test.go index 18274ce..9ea3095 100644 --- a/sonar/views_service_test.go +++ b/sonar/views_service_test.go @@ -792,15 +792,13 @@ func TestViewsService_SetTagsMode_ValidationError(t *testing.T) { // ----------------------------------------------------------------------------- func TestViewsService_Show(t *testing.T) { - response := ViewsShow{ - Portfolio: ViewDetails{ - Key: "pf-1", - Name: "Portfolio 1", - Qualifier: "VW", - SelectionMode: "MANUAL", - SubViews: []View{ - {Key: "sub-pf-1", Name: "Sub Portfolio 1", Qualifier: "SVW"}, - }, + response := ViewDetails{ + Key: "pf-1", + Name: "Portfolio 1", + Qualifier: "VW", + SelectionMode: "MANUAL", + SubViews: []View{ + {Key: "sub-pf-1", Name: "Sub Portfolio 1", Qualifier: "SVW"}, }, } server := newTestServer(t, mockHandler(t, http.MethodGet, "/views/show", http.StatusOK, response)) @@ -810,9 +808,9 @@ func TestViewsService_Show(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) require.NotNil(t, result) - assert.Equal(t, "pf-1", result.Portfolio.Key) - assert.Equal(t, "Portfolio 1", result.Portfolio.Name) - assert.Len(t, result.Portfolio.SubViews, 1) + assert.Equal(t, "pf-1", result.Key) + assert.Equal(t, "Portfolio 1", result.Name) + assert.Len(t, result.SubViews, 1) } func TestViewsService_Show_ValidationError(t *testing.T) {