/** * Unit tests for the action's main functionality, src/main.ts * * These should be run as if the action was called from a workflow. * Specifically, the inputs listed in `action.yml` should be set as environment * variables following the pattern `INPUT_`. */ import * as core from '@actions/core' import * as github from '@actions/github' import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock' import * as oci from '@sigstore/oci' import * as attest from '@actions/attest' import * as localAttest from '../src/attest' import fs from 'fs/promises' import nock from 'nock' import os from 'os' import path from 'path' import { MockAgent, setGlobalDispatcher } from 'undici' import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints' import * as main from '../src/main' // Mock the GitHub Actions core library const infoMock = jest.spyOn(core, 'info') const warningMock = jest.spyOn(core, 'warning') const startGroupMock = jest.spyOn(core, 'startGroup') 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(() => {}) const summaryWriteMock = jest.spyOn(core.summary, 'write') summaryWriteMock.mockResolvedValue(core.summary) // Mock the action's main function const runMock = jest.spyOn(main, 'run') // MockAgent for mocking @actions/github const mockAgent = new MockAgent() setGlobalDispatcher(mockAgent) const defaultInputs: main.RunInputs = { predicate: '', predicateType: '', predicatePath: '', subjectName: '', subjectDigest: '', subjectPath: '', subjectChecksums: '', pushToRegistry: false, createStorageRecord: true, showSummary: true, githubToken: '', privateSigning: false } describe('action', () => { // Capture original environment variables and GitHub context so we can restore // them after each test const originalEnv = process.env const originalContext = { ...github.context } // Mock OIDC token endpoint const tokenURL = 'https://token.url' // Fake an OIDC token const oidcSubject = 'foo@bar.com' const oidcPayload = { sub: oidcSubject, iss: '' } const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString( 'base64' )}.}` const subjectName = 'ghcr.io/registry/foo/bar' const subjectDigest = 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32' const predicate = '{}' const predicateType = 'https://in-toto.io/attestation/release/v0.1' const attestationID = '1234567890' const storageRecordID = 987654321 beforeEach(() => { jest.clearAllMocks() nock(tokenURL) .get('/') .query({ audience: 'sigstore' }) .reply(200, { value: oidcToken }) const pool = mockAgent.get('https://api.github.com') pool .intercept({ path: /^\/repos\/.*\/.*\/attestations$/, method: 'post' }) .reply(201, { id: attestationID }) pool .intercept({ path: /^\/orgs\/.*\/artifacts\/metadata\/storage-record$/, method: 'post' }) .reply(200, { storage_records: [{ id: storageRecordID }] }) process.env = { ...originalEnv, ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL, ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token', RUNNER_TEMP: process.env.RUNNER_TEMP || '/tmp' } }) afterEach(() => { // Restore the original environment process.env = originalEnv // Restore the original github.context setGHContext(originalContext) }) describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => { const inputs: main.RunInputs = { ...defaultInputs, subjectDigest, subjectName, predicateType, predicate, githubToken: 'gh-token' } beforeEach(() => { // Nullify the OIDC token URL process.env.ACTIONS_ID_TOKEN_REQUEST_URL = '' }) it('sets a failed status', async () => { await main.run(inputs) expect(runMock).toHaveReturned() expect(setFailedMock).toHaveBeenCalledWith( new Error( 'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.' ) ) }) }) describe('when no inputs are provided', () => { it('sets a failed status', async () => { await main.run(defaultInputs) expect(runMock).toHaveReturned() expect(setFailedMock).toHaveBeenCalledWith( new Error( 'One of subject-path, subject-digest, or subject-checksums must be provided' ) ) }) }) describe('when the repository is private', () => { const inputs: main.RunInputs = { ...defaultInputs, subjectDigest, subjectName, predicateType, predicate, githubToken: 'gh-token' } beforeEach(async () => { // Set the GH context with private repository visibility and a repo owner. setGHContext({ payload: { repository: { visibility: 'private' } }, repo: { owner: 'foo', repo: 'bar' } }) await mockFulcio({ baseURL: 'https://fulcio.githubapp.com', strict: false }) await mockTSA({ baseURL: 'https://timestamp.githubapp.com' }) }) it('invokes the action w/o error', async () => { await main.run(inputs) expect(runMock).toHaveReturned() expect(setFailedMock).not.toHaveBeenCalledWith() expect(infoMock).toHaveBeenNthCalledWith( 1, expect.stringMatching( `Attestation created for ${subjectName}@${subjectDigest}` ) ) expect(startGroupMock).toHaveBeenNthCalledWith( 1, expect.stringMatching('GitHub Sigstore') ) expect(infoMock).toHaveBeenNthCalledWith( 2, expect.stringMatching('-----BEGIN CERTIFICATE-----') ) expect(infoMock).toHaveBeenNthCalledWith( 3, expect.stringMatching(/attestation uploaded/i) ) expect(infoMock).toHaveBeenNthCalledWith( 4, expect.stringMatching(attestationID) ) expect(setOutputMock).toHaveBeenNthCalledWith( 1, 'bundle-path', expect.stringMatching('attestation.json') ) expect(setOutputMock).toHaveBeenNthCalledWith( 2, 'attestation-id', expect.stringMatching(attestationID) ) expect(setOutputMock).toHaveBeenNthCalledWith( 3, 'attestation-url', expect.stringContaining(`foo/bar/attestations/${attestationID}`) ) expect(setFailedMock).not.toHaveBeenCalled() }) }) describe('when the repository is public', () => { const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials') const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage') const repoOwnerIsOrgSpy = jest.spyOn(localAttest, 'repoOwnerIsOrg') const createStorageRecordSpy = jest.spyOn(attest, 'createStorageRecord') const createAttestationSpy = jest.spyOn(localAttest, 'createAttestation') const inputs: main.RunInputs = { ...defaultInputs, subjectDigest, subjectName, predicateType, predicate, githubToken: 'gh-token', pushToRegistry: true } beforeEach(async () => { // Set the GH context with public repository visibility and a repo owner. setGHContext({ payload: { repository: { visibility: 'public' } }, repo: { owner: 'foo', repo: 'bar' } }) await mockFulcio({ baseURL: 'https://fulcio.sigstore.dev', strict: false }) await mockRekor({ baseURL: 'https://rekor.sigstore.dev' }) getRegCredsSpy.mockImplementation(() => ({ username: 'username', password: 'password' })) attachArtifactSpy.mockResolvedValue({ digest: 'sha256:123456', mediaType: 'application/vnd.cncf.notary.v2', size: 123456 }) repoOwnerIsOrgSpy.mockResolvedValue(true) }) it('invokes the action w/o error', async () => { await main.run(inputs) expect(runMock).toHaveReturned() expect(setFailedMock).not.toHaveBeenCalled() expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName) expect(attachArtifactSpy).toHaveBeenCalled() expect(createAttestationSpy).toHaveBeenCalled() expect(repoOwnerIsOrgSpy).toHaveBeenCalled() expect(createStorageRecordSpy).toHaveBeenCalled() expect(warningMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenNthCalledWith( 1, expect.stringMatching( `Attestation created for ${subjectName}@${subjectDigest}` ) ) expect(startGroupMock).toHaveBeenNthCalledWith( 1, expect.stringMatching('Public Good Sigstore') ) expect(infoMock).toHaveBeenNthCalledWith( 2, expect.stringMatching('-----BEGIN CERTIFICATE-----') ) expect(infoMock).toHaveBeenNthCalledWith( 3, expect.stringMatching(/signature uploaded/i) ) expect(infoMock).toHaveBeenNthCalledWith( 4, expect.stringMatching(SEARCH_PUBLIC_GOOD_URL) ) expect(infoMock).toHaveBeenNthCalledWith( 5, expect.stringMatching(/attestation uploaded/i) ) expect(infoMock).toHaveBeenNthCalledWith( 6, expect.stringMatching(attestationID) ) expect(infoMock).toHaveBeenNthCalledWith( 9, expect.stringMatching('Storage record created') ) expect(infoMock).toHaveBeenNthCalledWith( 10, expect.stringMatching('Storage record IDs: 987654321') ) expect(setOutputMock).toHaveBeenNthCalledWith( 1, 'bundle-path', expect.stringMatching('attestation.json') ) expect(setOutputMock).toHaveBeenNthCalledWith( 2, 'attestation-id', expect.stringMatching(attestationID) ) expect(setOutputMock).toHaveBeenNthCalledWith( 3, 'attestation-url', expect.stringContaining(`foo/bar/attestations/${attestationID}`) ) expect(setOutputMock).toHaveBeenNthCalledWith( 4, 'storage-record-ids', expect.stringMatching(storageRecordID.toString()) ) expect(setFailedMock).not.toHaveBeenCalled() }) it('catches error when storage record creation fails and continues', async () => { // Mock the createStorageRecord function and throw an error createStorageRecordSpy.mockRejectedValueOnce( new Error('Failed to persist storage record: Not Found') ) await main.run(inputs) expect(runMock).toHaveReturned() expect(createAttestationSpy).toHaveBeenCalled() expect(repoOwnerIsOrgSpy).toHaveBeenCalled() expect(createStorageRecordSpy).toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled() expect(warningMock).toHaveBeenNthCalledWith( 1, expect.stringMatching('Failed to create storage record') ) }) it('does not create a storage record when the repo is owned by a user', async () => { repoOwnerIsOrgSpy.mockResolvedValueOnce(false) await main.run(inputs) expect(runMock).toHaveReturned() expect(setFailedMock).not.toHaveBeenCalled() expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName) expect(attachArtifactSpy).toHaveBeenCalled() expect(createAttestationSpy).toHaveBeenCalled() expect(repoOwnerIsOrgSpy).toHaveBeenCalled() expect(createStorageRecordSpy).not.toHaveBeenCalled() expect(warningMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith( expect.stringMatching( `Attestation created for ${subjectName}@${subjectDigest}` ) ) expect(infoMock).not.toHaveBeenCalledWith( expect.stringMatching('Storage record created') ) expect(infoMock).not.toHaveBeenCalledWith( expect.stringMatching('Storage record IDs: 987654321') ) expect(setOutputMock).toHaveBeenCalledWith( 'attestation-id', expect.stringMatching(attestationID) ) expect(setOutputMock).not.toHaveBeenCalledWith( 'storage-record-ids', expect.stringMatching(storageRecordID.toString()) ) expect(setFailedMock).not.toHaveBeenCalled() }) }) describe('when the subject count is greater than 1', () => { let dir = '' const filename = 'subject' beforeEach(async () => { const subjectCount = 5 const content = 'file content' // Set-up temp directory const tmpDir = await fs.realpath(os.tmpdir()) dir = await fs.mkdtemp(tmpDir + path.sep) // Add files for glob testing for (let i = 0; i < subjectCount; i++) { await fs.writeFile(path.join(dir, `${filename}-${i}`), content) } // Set the GH context with private repository visibility and a repo owner. setGHContext({ payload: { repository: { visibility: 'private' } }, repo: { owner: 'foo', repo: 'bar' } }) // Set-up a Fulcio mock for each subject await mockFulcio({ baseURL: 'https://fulcio.githubapp.com', strict: false }) // Set-up a TSA mock for each subject await mockTSA({ baseURL: 'https://timestamp.githubapp.com' }) }) afterEach(async () => { // Clean-up temp directory await fs.rm(dir, { recursive: true }) }) it('invokes the action w/o error', async () => { const inputs: main.RunInputs = { ...defaultInputs, subjectPath: path.join(dir, `${filename}-*`), predicateType, predicate, githubToken: 'gh-token' } await main.run(inputs) expect(runMock).toHaveReturned() expect(setFailedMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenNthCalledWith( 1, expect.stringMatching('Attestation created for 5 subjects') ) }) }) describe('when the subject count exceeds the max', () => { let dir = '' const filename = 'subject' beforeEach(async () => { const subjectCount = 1025 const content = 'file content' // Set-up temp directory const tmpDir = await fs.realpath(os.tmpdir()) dir = await fs.mkdtemp(tmpDir + path.sep) // Add files for glob testing for (let i = 0; i < subjectCount; i++) { await fs.writeFile(path.join(dir, `${filename}-${i}`), content) } // Set the GH context with private repository visibility and a repo owner. setGHContext({ payload: { repository: { visibility: 'private' } }, repo: { owner: 'foo', repo: 'bar' } }) }) afterEach(async () => { // Clean-up temp directory await fs.rm(dir, { recursive: true }) }) it('sets a failed status', async () => { const inputs: main.RunInputs = { ...defaultInputs, subjectPath: path.join(dir, `${filename}-*`), predicateType, predicate, githubToken: 'gh-token' } await main.run(inputs) expect(runMock).toHaveReturned() expect(setFailedMock).toHaveBeenCalledWith( new Error( 'Too many subjects specified. The maximum number of subjects is 1024.' ) ) }) }) }) // Stubbing the GitHub context is a bit tricky. We need to use // `Object.defineProperty` because `github.context` is read-only. function setGHContext(context: object): void { Object.defineProperty(github, 'context', { value: context }) }