diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts index 7c5cb641f4..ff347a258f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/clients/OpenSSFClient.test.ts @@ -22,15 +22,17 @@ import type { OpenSSFResponse } from './types'; 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; @@ -68,6 +70,25 @@ describe('OpenSSFClient', () => { }); describe('getScorecard', () => { + it.each([ + ['missing annotation', createEntity(undefined)], + ['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, @@ -92,22 +113,12 @@ describe('OpenSSFClient', () => { }); 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', - ); - }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFMetricProvider.test.ts index c81b18c364..55f236a6a2 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFMetricProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-openssf/src/metricProviders/OpenSSFMetricProvider.test.ts @@ -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'; @@ -41,6 +44,12 @@ const maintainedConfig = { 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(); @@ -75,6 +84,14 @@ describe('OpenSSFMetricProvider', () => { 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, @@ -124,6 +141,38 @@ describe('OpenSSFMetricProvider', () => { }); 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, @@ -154,6 +203,23 @@ describe('OpenSSFMetricProvider', () => { 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, @@ -214,4 +280,31 @@ describe('OpenSSFMetricProvider', () => { ); }); }); + + 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, '_'); + return `openssf.${normalizedName}`; + }); + + expect(providerIds).toEqual(expectedProviderIds); + providers.forEach(provider => { + expect(provider.getProviderDatasourceId()).toBe('openssf'); + expect(provider.getMetricThresholds()).toEqual(OPENSSF_THRESHOLDS); + }); + }); + }); });