Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/scorecard/.changeset/six-seas-wear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-sonarqube': patch
---

Resolve issue of displaying 0 value for `sonarqube.openIssues` Scorecard SonarQube metric when the project is inaccessible.
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,19 @@
* limitations under the License.
*/

import { ConfigReader } from '@backstage/config';
import { SonarQubeClient } from './SonarQubeClient';
import { mockServices } from '@backstage/backend-test-utils';

const mockFetch = jest.fn();
globalThis.fetch = mockFetch;

describe('SonarQubeClient', () => {
const config = new ConfigReader({
sonarqube: {
baseUrl: 'https://sonarcloud.io',
apiKey: 'test-key',
const config = mockServices.rootConfig({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] naming-conventions

Migrating from ConfigReader to mockServices.rootConfig in this file creates a minor inconsistency with sibling test files in the same plugin that still use ConfigReader.

data: {
sonarqube: {
baseUrl: 'https://sonarcloud.io',
apiKey: 'test-key',
},
},
});
const logger = mockServices.logger.mock();
Expand Down Expand Up @@ -83,20 +84,107 @@ describe('SonarQubeClient', () => {
});

describe('getOpenIssuesCount', () => {
it('returns the total count of open issues', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ total: 42 }),
});
it('returns the total count of open issues after verifying project access', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ component: { key: 'my-project' } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 42,
paging: { pageIndex: 1, pageSize: 1, total: 42 },
}),
});

const result = await client.getOpenIssuesCount('my-project');

expect(result).toBe(42);
expect(mockFetch).toHaveBeenCalledWith(
expect(mockFetch).toHaveBeenNthCalledWith(
1,
'https://sonarcloud.io/api/components/show?component=my-project',
expect.any(Object),
);
expect(mockFetch).toHaveBeenNthCalledWith(
2,
'https://sonarcloud.io/api/issues/search?componentKeys=my-project&statuses=OPEN,CONFIRMED,REOPENED&ps=1',
expect.any(Object),
);
});

it('throws when project access check fails and does not search issues', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});

await expect(client.getOpenIssuesCount('my-project')).rejects.toThrow(
/SonarQube API error: 404 Not Found/,
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://sonarcloud.io/api/components/show?component=my-project',
expect.any(Object),
);
});

it('propagates API errors from issues search after access check succeeds', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ component: { key: 'my-project' } }),
})
.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
});

await expect(client.getOpenIssuesCount('my-project')).rejects.toThrow(
/SonarQube API error: 503 Service Unavailable/,
);
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('returns 0 when the project is accessible and has no open issues', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ component: { key: 'my-project' } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 0,
paging: { pageIndex: 1, pageSize: 1, total: 0 },
}),
});

const result = await client.getOpenIssuesCount('my-project');

expect(result).toBe(0);
});

it('returns the top-level total field from the issues search response', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ component: { key: 'my-project' } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
total: 99,
paging: { pageIndex: 1, pageSize: 1, total: 7 },
}),
});

const result = await client.getOpenIssuesCount('my-project');

expect(result).toBe(99);
});
});

describe('getMeasures', () => {
Expand Down Expand Up @@ -141,10 +229,12 @@ describe('SonarQubeClient', () => {
});

it('strips trailing slash from baseUrl', () => {
const configWithSlash = new ConfigReader({
sonarqube: {
baseUrl: 'https://sonarcloud.io/',
apiKey: 'test-key',
const configWithSlash = mockServices.rootConfig({
data: {
sonarqube: {
baseUrl: 'https://sonarcloud.io/',
apiKey: 'test-key',
},
},
});
const clientWithSlash = new SonarQubeClient(configWithSlash, logger);
Expand All @@ -163,7 +253,7 @@ describe('SonarQubeClient', () => {
});

it('defaults baseUrl to https://sonarcloud.io when not configured', async () => {
const emptyConfig = new ConfigReader({});
const emptyConfig = mockServices.rootConfig({ data: {} });
const defaultClient = new SonarQubeClient(emptyConfig, logger);

mockFetch.mockResolvedValueOnce({
Expand All @@ -180,8 +270,10 @@ describe('SonarQubeClient', () => {
});

it('sends no Authorization header when apiKey is not configured', async () => {
const noKeyConfig = new ConfigReader({
sonarqube: { baseUrl: 'https://sonarcloud.io' },
const noKeyConfig = mockServices.rootConfig({
data: {
sonarqube: { baseUrl: 'https://sonarcloud.io' },
},
});
const noKeyClient = new SonarQubeClient(noKeyConfig, logger);

Expand All @@ -199,22 +291,24 @@ describe('SonarQubeClient', () => {
});

describe('named instances', () => {
const multiConfig = new ConfigReader({
sonarqube: {
baseUrl: 'https://sonarcloud.io',
apiKey: 'default-key',
instances: [
{
name: 'internal',
baseUrl: 'https://sonar.internal.com',
apiKey: 'internal-key',
authType: 'Bearer',
},
{
name: 'public',
baseUrl: 'https://sonarcloud.io',
},
],
const multiConfig = mockServices.rootConfig({
data: {
sonarqube: {
baseUrl: 'https://sonarcloud.io',
apiKey: 'default-key',
instances: [
{
name: 'internal',
baseUrl: 'https://sonar.internal.com',
apiKey: 'internal-key',
authType: 'Bearer',
},
{
name: 'public',
baseUrl: 'https://sonarcloud.io',
},
],
},
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,23 @@ export class SonarQubeClient {
instanceName?: string,
): Promise<number> {
this.logger.debug(`Fetching open issues count for project ${projectKey}`);

// Pre-flight: /api/issues/search returns 200 with total: 0 for inaccessible
// projects, so verify the component exists and is reachable first.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

The pre-flight check adds an extra HTTP round-trip per getOpenIssuesCount call. The sequential approach is the correct trade-off for scorecard metric polling.

await this.fetchApi(
Comment thread
imykhno marked this conversation as resolved.
`/api/components/show?component=${encodeURIComponent(projectKey)}`,
instanceName,
Comment thread
imykhno marked this conversation as resolved.
);

// For a private / invisible / unauthorized project it often does not return 403.
// It returns HTTP 200 with an empty result set.
const data = await this.fetchApi(
`/api/issues/search?componentKeys=${encodeURIComponent(
projectKey,
)}&statuses=OPEN,CONFIRMED,REOPENED&ps=1`,
instanceName,
);

return data.total;
}

Expand Down
Loading