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
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@
const mockScorecardLocation =
'https://api.securityscorecards.dev/projects/github.com/owner/repo';

function createEntity(scorecardLocation: string): Entity {
function createEntity(scorecardLocation?: string): Entity {
const annotations = scorecardLocation
? { 'openssf/scorecard-location': scorecardLocation }
: {};

return {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'my-service',
annotations: {
'openssf/scorecard-location': scorecardLocation,
},
annotations,
},
spec: {},
} as Entity;
Expand Down Expand Up @@ -68,6 +70,25 @@
});

describe('getScorecard', () => {
it.each([
['missing annotation', createEntity(undefined)],

Check warning on line 74 in workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant "undefined".

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ3T5LbzqyblKzOsaU2C&open=AZ3T5LbzqyblKzOsaU2C&pullRequest=2940
['empty annotation', createEntity('')],
['whitespace annotation', createEntity(' ')],
['non-https annotation', createEntity('http://example.com/scorecard')],
])(
'throws when scorecard annotation is invalid (%s)',
async (_, testEntity) => {
const client = new OpenSSFClient();
const request = client.getScorecard(testEntity);

await expect(request).rejects.toBeInstanceOf(Error);
await expect(request).rejects.toThrow(
"Invalid annotation 'openssf/scorecard-location' value",
);
expect(fetch).not.toHaveBeenCalled();
},
);

it('fetches the scorecard from the entity scorecard URL', async () => {
(globalThis.fetch as jest.Mock).mockResolvedValue({
ok: true,
Expand All @@ -92,22 +113,12 @@
});

const client = new OpenSSFClient();
const request = client.getScorecard(entity);

await expect(client.getScorecard(entity)).rejects.toThrow(
await expect(request).rejects.toBeInstanceOf(Error);
await expect(request).rejects.toThrow(
'OpenSSF API request failed with status 404: Not Found',
);
});

it('throws when fetch rejects', async () => {
(globalThis.fetch as jest.Mock).mockRejectedValue(
new Error('Network error'),
);

const client = new OpenSSFClient();

await expect(client.getScorecard(entity)).rejects.toThrow(
'Network error',
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
import type { Entity } from '@backstage/catalog-model';

import { OpenSSFMetricProvider } from './OpenSSFMetricProvider';
import { OPENSSF_THRESHOLDS } from './OpenSSFConfig';
import {
createOpenSSFMetricProvider,
OpenSSFMetricProvider,
} from './OpenSSFMetricProvider';
import { OPENSSF_METRICS, OPENSSF_THRESHOLDS } from './OpenSSFConfig';

const scorecardLocation =
'https://api.securityscorecards.dev/projects/github.com/owner/repo';
Expand All @@ -41,6 +44,12 @@
description: 'Determines if the project is actively maintained.',
};

const hyphenatedCheckConfig = {
name: 'Code-Review',
displayTitle: 'OpenSSF Code Review',
description: 'Determines if the project requires code review.',
};

describe('OpenSSFMetricProvider', () => {
const entity = createEntity();

Expand Down Expand Up @@ -75,6 +84,14 @@
expect(provider.getProviderId()).toBe('openssf.maintained');
});

it('normalizes hyphenated check names for provider id', () => {
const provider = new OpenSSFMetricProvider(
hyphenatedCheckConfig,
OPENSSF_THRESHOLDS,
);
expect(provider.getProviderId()).toBe('openssf.code_review');
});

it('returns openssf as provider datasource id', () => {
const provider = new OpenSSFMetricProvider(
maintainedConfig,
Expand Down Expand Up @@ -124,6 +141,38 @@
});

describe('calculateMetric', () => {
it.each([0, 10])(
'returns the score when the check is at boundary %i',
async boundaryScore => {
(globalThis.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
date: '2024-01-15',
repo: { name: 'github.com/owner/repo', commit: 'x' },
scorecard: { version: '4.0.0', commit: 'y' },
score: 7,
checks: [
{
name: 'Maintained',
score: boundaryScore,
reason: null,
details: null,
documentation: { short: '', url: '' },
},
],
}),
});

const provider = new OpenSSFMetricProvider(
maintainedConfig,
OPENSSF_THRESHOLDS,
);
const result = await provider.calculateMetric(entity);

expect(result).toBe(boundaryScore);
},
);

it('returns the score for the configured check', async () => {
(globalThis.fetch as jest.Mock).mockResolvedValue({
ok: true,
Expand Down Expand Up @@ -154,6 +203,23 @@
expect(fetch).toHaveBeenCalledWith(scorecardLocation, expect.any(Object));
});

it('propagates errors from the OpenSSF client', async () => {
const provider = new OpenSSFMetricProvider(
maintainedConfig,
OPENSSF_THRESHOLDS,
);
const propagatedError = new Error('OpenSSF client failed');
const getScorecardSpy = jest
.spyOn((provider as any).openSSFClient, 'getScorecard')
.mockRejectedValue(propagatedError);

await expect(provider.calculateMetric(entity)).rejects.toBe(
propagatedError,
);
expect(getScorecardSpy).toHaveBeenCalledWith(entity);
expect(fetch).not.toHaveBeenCalled();
});

it('throws when the check is not in the scorecard', async () => {
(globalThis.fetch as jest.Mock).mockResolvedValue({
ok: true,
Expand Down Expand Up @@ -214,4 +280,31 @@
);
});
});

describe('createOpenSSFMetricProvider', () => {
it('creates one provider per configured OpenSSF metric', () => {
const providers = createOpenSSFMetricProvider();

expect(providers).toHaveLength(OPENSSF_METRICS.length);
expect(
providers.every(provider => provider instanceof OpenSSFMetricProvider),
).toBe(true);
});

it('returns providers with normalized ids and configured thresholds', () => {
const providers = createOpenSSFMetricProvider();

const providerIds = providers.map(provider => provider.getProviderId());
const expectedProviderIds = OPENSSF_METRICS.map(metric => {
const normalizedName = metric.name.toLowerCase().replace(/-/g, '_');

Check warning on line 299 in workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFMetricProvider.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ3T5LU9qyblKzOsaU2B&open=AZ3T5LU9qyblKzOsaU2B&pullRequest=2940
return `openssf.${normalizedName}`;
});

expect(providerIds).toEqual(expectedProviderIds);
providers.forEach(provider => {
expect(provider.getProviderDatasourceId()).toBe('openssf');
expect(provider.getMetricThresholds()).toEqual(OPENSSF_THRESHOLDS);
});
});
});
});
Loading