/** * 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 type { Predicate } from '@actions/attest' import { jest } from '@jest/globals' import type { RunInputs } from '../src/main' // Create mock functions before mocking modules const infoMock = jest.fn() const warningMock = jest.fn() const startGroupMock = jest.fn() const endGroupMock = jest.fn() const setOutputMock = jest.fn() const setFailedMock = jest.fn() const debugMock = jest.fn() // OCI mocks const getRegCredsMock = jest.fn() const attachArtifactMock = jest.fn() // Attest mocks const attestMock = jest.fn() const createStorageRecordMock = jest.fn() // Local attest mocks // eslint-disable-next-line @typescript-eslint/no-explicit-any const createAttestationMock = jest.fn<() => Promise>() const repoOwnerIsOrgMock = jest.fn() // Provenance mock const generateProvenancePredicateMock = jest.fn<() => Promise>() // GitHub context mock const mockContext = { repo: { owner: 'foo', repo: 'bar' }, payload: { repository: { visibility: 'private' } } } const mockGetOctokit = jest.fn() // Summary mock with chainable methods const summaryMock = { write: jest.fn().mockReturnThis(), addRaw: jest.fn().mockReturnThis(), addHeading: jest.fn().mockReturnThis(), addLink: jest.fn().mockReturnThis(), addTable: jest.fn().mockReturnThis(), addBreak: jest.fn().mockReturnThis(), addSeparator: jest.fn().mockReturnThis(), addQuote: jest.fn().mockReturnThis(), addCodeBlock: jest.fn().mockReturnThis(), addList: jest.fn().mockReturnThis(), addImage: jest.fn().mockReturnThis(), addDetails: jest.fn().mockReturnThis(), addEOL: jest.fn().mockReturnThis(), emptyBuffer: jest.fn().mockReturnThis(), stringify: jest.fn().mockReturnValue(''), isEmptyBuffer: jest.fn().mockReturnValue(true), clear: jest.fn().mockReturnThis() } // Mock @actions/core jest.unstable_mockModule('@actions/core', () => ({ info: infoMock, warning: warningMock, startGroup: startGroupMock, endGroup: endGroupMock, setOutput: setOutputMock, setFailed: setFailedMock, debug: debugMock, summary: summaryMock })) // Mock @actions/github jest.unstable_mockModule('@actions/github', () => ({ context: mockContext, getOctokit: mockGetOctokit })) // Mock @sigstore/oci jest.unstable_mockModule('@sigstore/oci', () => ({ getRegistryCredentials: getRegCredsMock, attachArtifactToImage: attachArtifactMock })) // Mock @actions/attest jest.unstable_mockModule('@actions/attest', () => ({ attest: attestMock, createStorageRecord: createStorageRecordMock })) // Mock ../src/attest jest.unstable_mockModule('../src/attest', () => ({ createAttestation: createAttestationMock, repoOwnerIsOrg: repoOwnerIsOrgMock })) // Mock ../src/provenance jest.unstable_mockModule('../src/provenance', () => ({ generateProvenancePredicate: generateProvenancePredicateMock })) // Dynamic imports after mocking const { mockFulcio, mockRekor, mockTSA } = await import('@sigstore/mock') const fs = (await import('fs/promises')).default const nock = (await import('nock')).default const os = (await import('os')).default const path = (await import('path')).default const { MockAgent, setGlobalDispatcher } = await import('undici') const { run } = await import('../src/main') // MockAgent for mocking @actions/github const mockAgent = new MockAgent() setGlobalDispatcher(mockAgent) const defaultInputs: RunInputs = { predicate: '', predicateType: '', predicatePath: '', sbomPath: '', subjectName: '', subjectDigest: '', subjectPath: '', subjectChecksums: '', pushToRegistry: false, createStorageRecord: true, showSummary: true, githubToken: '', privateSigning: false } describe('action', () => { // Capture original environment variables so we can restore after each test const originalEnv = process.env const originalContext = { repo: { owner: 'foo', repo: 'bar' }, payload: { repository: { visibility: 'private' } } } // 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: 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 run(inputs) 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 run(defaultInputs) 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: 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' } }) // Mock createAttestation to return expected values createAttestationMock.mockResolvedValue({ attestationID, certificate: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', tlogID: 'tlog-123', attestationDigest: 'sha256:123456', bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' } }) 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 run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom') expect(infoMock).toHaveBeenCalledWith( expect.stringMatching( `Attestation created for ${subjectName}@${subjectDigest}` ) ) expect(createAttestationMock).toHaveBeenCalled() }) }) describe('when the repository is public', () => { const inputs: 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' } }) // Setup createAttestation mock createAttestationMock.mockResolvedValue({ attestationID, certificate: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', tlogID: 'tlog-123', attestationDigest: 'sha256:123456', bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }, storageRecordIds: [storageRecordID] }) await mockFulcio({ baseURL: 'https://fulcio.sigstore.dev', strict: false }) await mockRekor({ baseURL: 'https://rekor.sigstore.dev' }) mockGetOctokit.mockReturnValue({ rest: { repos: { get: jest .fn<() => Promise<{ data: { owner: { type: string } } }>>() .mockResolvedValue({ data: { owner: { type: 'Organization' } } }) } } }) }) it('invokes the action w/o error', async () => { await run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(createAttestationMock).toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom') expect(infoMock).toHaveBeenCalledWith( expect.stringMatching( `Attestation created for ${subjectName}@${subjectDigest}` ) ) }) it('catches error when storage record creation fails and continues', async () => { // Mock createAttestation to simulate storage record failure (but still succeed overall) createAttestationMock.mockResolvedValue({ attestationID, certificate: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', tlogID: 'tlog-123', attestationDigest: 'sha256:123456', bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' } // No storageRecordIDs - simulates empty/failed storage record }) await run(inputs) expect(createAttestationMock).toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled() }) it('does not create a storage record when the repo is owned by a user', async () => { // Mock createAttestation to not return storage record IDs createAttestationMock.mockResolvedValue({ attestationID, certificate: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', tlogID: 'tlog-123', attestationDigest: 'sha256:123456', bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' } }) await run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(createAttestationMock).toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith( expect.stringMatching( `Attestation created for ${subjectName}@${subjectDigest}` ) ) }) }) 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: RunInputs = { ...defaultInputs, subjectPath: path.join(dir, `${filename}-*`), predicateType, predicate, githubToken: 'gh-token' } await run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom') expect(infoMock).toHaveBeenNthCalledWith( 2, 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: RunInputs = { ...defaultInputs, subjectPath: path.join(dir, `${filename}-*`), predicateType, predicate, githubToken: 'gh-token' } await run(inputs) expect(setFailedMock).toHaveBeenCalledWith( new Error( 'Too many subjects specified (>1024). The maximum number of subjects is 1024.' ) ) }) }) describe('attestation type detection', () => { describe('when sbom-path is provided with predicate inputs', () => { it('sets a failed status for conflicting inputs', async () => { const inputs: RunInputs = { ...defaultInputs, subjectDigest, subjectName, sbomPath: '/path/to/sbom.json', predicateType: 'https://example.com/predicate', githubToken: 'gh-token' } await run(inputs) expect(setFailedMock).toHaveBeenCalledWith( new Error( 'Cannot specify sbom-path together with predicate-type, predicate, or predicate-path' ) ) }) }) describe('when predicate is provided without predicate-type', () => { it('sets a failed status for missing predicate-type', async () => { const inputs: RunInputs = { ...defaultInputs, subjectDigest, subjectName, predicate: '{}', githubToken: 'gh-token' } await run(inputs) expect(setFailedMock).toHaveBeenCalledWith( new Error( 'predicate-type is required when using predicate or predicate-path' ) ) }) }) describe('when custom attestation inputs are provided', () => { const inputs: RunInputs = { ...defaultInputs, subjectDigest, subjectName, predicateType, predicate, githubToken: 'gh-token' } beforeEach(async () => { 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('logs the attestation type as Custom', async () => { await run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom') }) }) describe('when provenance attestation is detected', () => { const inputs: RunInputs = { ...defaultInputs, subjectDigest, subjectName, githubToken: 'gh-token' } const mockProvPredicate = { type: 'https://slsa.dev/provenance/v1', params: { buildDefinition: {}, runDetails: {} } } beforeEach(async () => { // Configure mock for provenance predicate generateProvenancePredicateMock.mockResolvedValue(mockProvPredicate) // Configure mock for createAttestation createAttestationMock.mockResolvedValue({ attestationID: '1234567890', certificate: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', tlogID: 'tlog-123', attestationDigest: 'sha256:123456', bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' } }) 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('logs the attestation type as Build Provenance and generates predicate', async () => { await run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith( 'Attestation type: Build Provenance' ) }) }) describe('when sbom attestation is detected', () => { let tmpDir: string let sbomFilePath: string const spdxSBOM = { spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', name: 'test-package', packages: [] } beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'main-test-')) sbomFilePath = path.join(tmpDir, 'sbom.spdx.json') await fs.writeFile(sbomFilePath, JSON.stringify(spdxSBOM)) 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' }) }) afterEach(async () => { await fs.rm(tmpDir, { recursive: true }) }) it('logs the attestation type as SBOM and generates predicate', async () => { const inputs: RunInputs = { ...defaultInputs, subjectDigest, subjectName, sbomPath: sbomFilePath, githubToken: 'gh-token' } await run(inputs) expect(setFailedMock).not.toHaveBeenCalled() expect(infoMock).toHaveBeenCalledWith('Attestation type: SBOM') }) }) }) }) // Helper to update the mock context function setGHContext(context: { repo?: { owner: string; repo: string } payload?: { repository?: { visibility: string } } }): void { if (context.repo) { mockContext.repo = context.repo } if (context.payload) { mockContext.payload = context.payload as typeof mockContext.payload } }