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) {