Files
attest/__tests__/main.test.ts
T
Meredith Lancaster cef9ee9223 remove comment
Signed-off-by: Meredith Lancaster <malancas@github.com>
2026-02-05 13:18:59 -08:00

608 lines
18 KiB
TypeScript

/**
* 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_<INPUT_NAME>`.
*/
import {
jest,
describe,
expect,
beforeEach,
afterEach,
it
} from '@jest/globals'
import type { RunInputs } from '../src/main.js'
// Create mock functions for core
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 summaryWriteMock = jest.fn<() => Promise<void>>()
// Create a mock summary object
const mockSummary = {
addHeading: jest.fn().mockReturnThis(),
addRaw: jest.fn().mockReturnThis(),
addTable: jest.fn().mockReturnThis(),
addSeparator: jest.fn().mockReturnThis(),
addLink: jest.fn().mockReturnThis(),
addBreak: jest.fn().mockReturnThis(),
addList: jest.fn().mockReturnThis(),
write: summaryWriteMock.mockResolvedValue(undefined)
}
// Mock @actions/core before importing
jest.unstable_mockModule('@actions/core', () => ({
info: infoMock,
warning: warningMock,
startGroup: startGroupMock,
endGroup: endGroupMock,
setOutput: setOutputMock,
setFailed: setFailedMock,
summary: mockSummary
}))
// Create mocks for OCI and attest modules
/* eslint-disable @typescript-eslint/no-explicit-any */
const getRegistryCredentialsMock = jest.fn<(...args: any[]) => any>()
const attachArtifactToImageMock = jest.fn<(...args: any[]) => any>()
const createStorageRecordMock = jest.fn<(...args: any[]) => any>()
const attestMock = jest.fn<(...args: any[]) => any>()
/* eslint-enable @typescript-eslint/no-explicit-any */
// Mock @sigstore/oci
jest.unstable_mockModule('@sigstore/oci', () => ({
getRegistryCredentials: getRegistryCredentialsMock,
attachArtifactToImage: attachArtifactToImageMock
}))
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
attest: attestMock,
createStorageRecord: createStorageRecordMock
}))
// Create a mutable context object for @actions/github
const mockContext: Record<string, unknown> = {}
// Mock for getOctokit to return a mock octokit client
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockReposGet = jest.fn<(...args: any[]) => any>()
const mockOctokit = {
rest: {
repos: {
get: mockReposGet
}
}
}
jest.unstable_mockModule('@actions/github', () => ({
context: mockContext,
getOctokit: jest.fn(() => mockOctokit)
}))
// Helper to set the mocked GitHub context
function setGHContext(context: object): void {
Object.keys(mockContext).forEach(key => delete mockContext[key])
Object.assign(mockContext, context)
}
// Now import the modules 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 { SEARCH_PUBLIC_GOOD_URL } = await import('../src/endpoints.js')
const { run } = (await import('../src/main.js')) as {
run: (inputs: RunInputs) => Promise<void>
}
// MockAgent for mocking @actions/github
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
const defaultInputs: RunInputs = {
predicate: '',
predicateType: '',
predicatePath: '',
subjectName: '',
subjectDigest: '',
subjectPath: '',
subjectChecksums: '',
pushToRegistry: false,
createStorageRecord: true,
showSummary: true,
githubToken: '',
privateSigning: false
}
describe('action', () => {
// Capture original environment variables so we can restore them after each test
const originalEnv = process.env
// 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()
// Set up default GitHub context with empty payload
setGHContext({
payload: {},
repo: { owner: 'test-owner', repo: 'test-repo' }
})
// Set up default return value for attestMock (without tlogID for private/GitHub sigstore)
attestMock.mockResolvedValue({
attestationID,
bundle: {
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
verificationMaterial: {
certificate: {
rawBytes: Buffer.from(
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
).toString('base64')
},
tlogEntries: []
},
content: {}
},
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
})
// Set up default return value for createStorageRecordMock (returns array of record IDs)
createStorageRecordMock.mockResolvedValue([storageRecordID])
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
// Clear the github context
setGHContext({ payload: {}, repo: { owner: '', repo: '' } })
})
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' }
})
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).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 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' }
})
await mockFulcio({
baseURL: 'https://fulcio.sigstore.dev',
strict: false
})
await mockRekor({ baseURL: 'https://rekor.sigstore.dev' })
getRegistryCredentialsMock.mockImplementation(() => ({
username: 'username',
password: 'password'
}))
attachArtifactToImageMock.mockResolvedValue({
digest: 'sha256:123456',
mediaType: 'application/vnd.cncf.notary.v2',
size: 123456
})
// Set up attestMock with tlogID for public good sigstore
attestMock.mockResolvedValue({
attestationID,
bundle: {
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
verificationMaterial: {
certificate: {
rawBytes: Buffer.from(
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'
).toString('base64')
},
tlogEntries: [{ logIndex: '123' }]
},
content: {}
},
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: '123'
})
// Mock the repos.get API call for repoOwnerIsOrg check
mockReposGet.mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
})
it('invokes the action w/o error', async () => {
await run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegistryCredentialsMock).toHaveBeenCalledWith(subjectName)
expect(attachArtifactToImageMock).toHaveBeenCalled()
expect(attestMock).toHaveBeenCalled()
expect(createStorageRecordMock).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
createStorageRecordMock.mockRejectedValueOnce(
new Error('Failed to persist storage record: Not Found')
)
await run(inputs)
expect(attestMock).toHaveBeenCalled()
expect(createStorageRecordMock).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 () => {
// Mock the repos.get API to return a user-owned repo
mockReposGet.mockResolvedValueOnce({ data: { owner: { type: 'User' } } })
await run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegistryCredentialsMock).toHaveBeenCalledWith(subjectName)
expect(attachArtifactToImageMock).toHaveBeenCalled()
expect(attestMock).toHaveBeenCalled()
expect(createStorageRecordMock).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: RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
}
await run(inputs)
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: RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
}
await run(inputs)
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'Too many subjects specified. The maximum number of subjects is 1024.'
)
)
})
})
})