diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af1e44e..e670321 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,17 +41,17 @@ jobs: - name: Lint id: npm-lint run: npm run lint - # - name: Test - # id: npm-ci-test - # run: npm run ci-test + + - name: Test + id: npm-ci-test + run: npm run ci-test test-attest-sbom-with-local-sbom-file: name: Test attest-sbom action with local sbom file runs-on: ubuntu-latest permissions: - contents: read + contents: write id-token: write - packages: write steps: - name: Checkout @@ -71,9 +71,8 @@ jobs: name: Test attest-sbom action runs-on: ubuntu-latest permissions: - contents: read + contents: write id-token: write - packages: write steps: - name: Checkout diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts new file mode 100644 index 0000000..34a4dfe --- /dev/null +++ b/__tests__/index.test.ts @@ -0,0 +1,17 @@ +/** + * Unit tests for the action's entrypoint, src/index.ts + */ + +import * as main from '../src/main' + +// Mock the action's entrypoint +const runMock = jest.spyOn(main, 'run').mockImplementation() + +describe('index', () => { + it('calls run when imported', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../src/index') + + expect(runMock).toHaveBeenCalled() + }) +}) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts new file mode 100644 index 0000000..d266624 --- /dev/null +++ b/__tests__/main.test.ts @@ -0,0 +1,112 @@ +import * as core from '@actions/core' +import * as main from '../src/main' +import * as fs from 'fs' +import os from 'os' +import * as path from 'path' + +// Mock the GitHub Actions core library +jest.mock('@actions/core') +const getInputMock = jest.spyOn(core, 'getInput') +const setOutputMock = jest.spyOn(core, 'setOutput') +const setFailedMock = jest.spyOn(core, 'setFailed') + +// Ensure that setFailed doesn't set an exit code during tests +setFailedMock.mockImplementation(() => {}) + +describe('SBOM Action', () => { + let tempDir = '/' + let outputs = {} as Record + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom')) + + jest.resetAllMocks() + setOutputMock.mockImplementation((key, value) => { + outputs[key] = value + }) + }) + + afterEach(() => { + fs.rmdirSync(tempDir, { recursive: true }) + outputs = {} + }) + + it('successfully processes an SBOM', async () => { + const spdxSBOM = JSON.stringify({ + spdxVersion: 'SPDX-2.2', + SPDXID: 'SPDXRef-DOCUMENT', + packages: [] + }) + const filePath = path.join(tempDir, 'spdxSBOM.json') + fs.writeFileSync(filePath, spdxSBOM) + + const inputs = { + 'sbom-path': filePath + } + getInputMock.mockImplementation(mockInput(inputs)) + const originalEnv = process.env + process.env = { ...originalEnv, RUNNER_TEMP: '/tmp' } + + // Run the main function + await main.run() + + // Verify that outputs were set correctly + expect(setOutputMock).toHaveBeenCalledTimes(2) + expect(setOutputMock).toHaveBeenNthCalledWith( + 2, + 'predicate-type', + 'https://spdx.dev/Document/v2.2' + ) + expect(outputs['predicate-path']).toBeTruthy() + const predicatePath = outputs['predicate-path'] + + // Verify that the temporary file exists + expect(fs.existsSync(predicatePath)).toBe(true) + + // Read the content of the temporary file + const fileContent = fs.readFileSync(predicatePath, 'utf-8') + + // Verify that the content matches the predicate params + expect(JSON.parse(fileContent)).toEqual(JSON.parse(spdxSBOM)) + + // Clean up the temporary file + fs.unlinkSync(predicatePath) + + process.env = originalEnv + }) + + it('fails when an error occurs without input', async () => { + await main.run() + expect(setFailedMock).toHaveBeenCalledWith( + 'TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined' + ) + }) + + it('fails when an error occurs with wrong sbom format', async () => { + const spdxSBOM = JSON.stringify({ + SPDXID: 'SPDXRef-DOCUMENT' + }) + const filePath = path.join(tempDir, 'spdxSBOM.json') + fs.writeFileSync(filePath, spdxSBOM) + + const inputs = { + 'sbom-path': filePath + } + getInputMock.mockImplementation(mockInput(inputs)) + const originalEnv = process.env + process.env = { ...originalEnv, RUNNER_TEMP: '/tmp' } + + // Run the main function + await main.run() + expect(setFailedMock).toHaveBeenCalledWith('Unsupported SBOM format') + }) +}) + +function mockInput(inputs: Record): typeof core.getInput { + return (name: string): string => { + if (name in inputs) { + return inputs[name] + } + return '' + } +} diff --git a/__tests__/sbom.test.ts b/__tests__/sbom.test.ts new file mode 100644 index 0000000..248fee1 --- /dev/null +++ b/__tests__/sbom.test.ts @@ -0,0 +1,125 @@ +import { + storePredicate, + parseSBOMFromPath, + generateSBOMPredicate, + SBOM +} from '../src/sbom' +import type { Predicate } from '@actions/attest' +import * as fs from 'fs' +import os from 'os' +import * as path from 'path' + +describe('parseSBOMFromPath', () => { + let tempDir = '/' + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom')) + }) + + afterEach(() => { + fs.rmdirSync(tempDir, { recursive: true }) + }) + + it('correctly parses an SPDX file', async () => { + const spdxSBOM = JSON.stringify({ + spdxVersion: 'SPDX-2.2', + SPDXID: 'SPDXRef-DOCUMENT' + }) + const filePath = path.join(tempDir, 'spdxSBOM.json') + fs.writeFileSync(filePath, spdxSBOM) + await expect(parseSBOMFromPath(filePath)).resolves.toEqual({ + type: 'spdx', + object: JSON.parse(spdxSBOM) + }) + }) + + it('correctly parses a CycloneDX file', async () => { + const cycloneDXSBOM = JSON.stringify({ + bomFormat: 'CycloneDX', + serialNumber: '123', + specVersion: '1.2' + }) + const filePath = path.join(tempDir, 'cyclonedxSBOM.json') + fs.writeFileSync(filePath, cycloneDXSBOM) + + await expect(parseSBOMFromPath(filePath)).resolves.toEqual({ + type: 'cyclonedx', + object: JSON.parse(cycloneDXSBOM) + }) + }) + + it('throws an error for unsupported SBOM formats', async () => { + const filePath = path.join(tempDir, 'random.json') + fs.writeFileSync(filePath, JSON.stringify({ random: 'value' })) + await expect(parseSBOMFromPath(filePath)).rejects.toThrow( + 'Unsupported SBOM format' + ) + }) +}) + +describe('storePredicate', () => { + it('should store the predicate to a temporary file', () => { + const predicate = { params: { key: 'value' } } as Predicate + + // Mocking the process.env['RUNNER_TEMP'] value + const originalEnv = process.env + process.env = { ...originalEnv, RUNNER_TEMP: '/tmp' } + + const tempFile = storePredicate(predicate) + + // Verify that the temporary file exists + expect(fs.existsSync(tempFile)).toBe(true) + + // Read the content of the temporary file + const fileContent = fs.readFileSync(tempFile, 'utf-8') + + // Verify that the content matches the predicate params + expect(JSON.parse(fileContent)).toEqual(predicate.params) + + // Clean up the temporary file + fs.unlinkSync(tempFile) + + // Restore the original process.env + process.env = originalEnv + }) + + it('should throw an error if RUNNER_TEMP environment variable is missing', () => { + const predicate = { params: { key: 'value' } } as Predicate + + // Mocking the process.env['RUNNER_TEMP'] value + const originalEnv = process.env + process.env = {} + + // Verify that an error is thrown + expect(() => storePredicate(predicate)).toThrow( + 'Missing RUNNER_TEMP environment variable' + ) + + // Restore the original process.env + process.env = originalEnv + }) +}) + +describe('generateSBOMPredicate', () => { + it('generates SPDX predicate correctly', () => { + const sbom = { type: 'spdx', object: { spdxVersion: 'SPDX-2.2' } } as SBOM + const result = generateSBOMPredicate(sbom) + expect(result.type).toContain('https://spdx.dev/Document/v2.2') + expect(result.params).toEqual(sbom.object) + }) + + it('generates CycloneDX predicate correctly', () => { + const sbom = { type: 'cyclonedx', object: {} } as SBOM + const result = generateSBOMPredicate(sbom) + expect(result.type).toEqual('https://cyclonedx.org/bom') + expect(result.params).toEqual(sbom.object) + }) + + it('throws error for unsupported SBOM formats', () => { + const sbom = { type: 'spdx', object: {} } + // @ts-expect-error test error case + expect(() => generateSBOMPredicate(sbom)).toThrow( + 'Cannot find spdxVersion in the SBOM' + ) + }) +}) diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..a535bd6 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +process.stdout.write = jest.fn() diff --git a/package.json b/package.json index 90df567..bae1ab0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "js", "ts" ], + "setupFilesAfterEnv": [ + "./jest.setup.js" + ], "testMatch": [ "**/*.test.ts" ],