centralize collection of action inputs (#72)

Signed-off-by: Brian DeHamer <[email protected]>
This commit is contained in:
Brian DeHamer
2024-05-24 11:01:44 -07:00
committed by GitHub
parent 074a7714de
commit 3ff4eb4c69
9 changed files with 381 additions and 316 deletions
+5
View File
@@ -2,12 +2,17 @@
* Unit tests for the action's entrypoint, src/index.ts
*/
import * as core from '@actions/core'
import * as main from '../src/main'
// Mock the action's entrypoint
const runMock = jest.spyOn(main, 'run').mockImplementation()
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput')
describe('index', () => {
beforeEach(() => {
getBooleanInputMock.mockImplementation(() => false)
})
it('calls run when imported', async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')
+48 -58
View File
@@ -20,8 +20,6 @@ import * as main from '../src/main'
// Mock the GitHub Actions core library
const infoMock = jest.spyOn(core, 'info')
const startGroupMock = jest.spyOn(core, 'startGroup')
const getInputMock = jest.spyOn(core, 'getInput')
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput')
const setOutputMock = jest.spyOn(core, 'setOutput')
const setFailedMock = jest.spyOn(core, 'setFailed')
@@ -38,6 +36,20 @@ const runMock = jest.spyOn(main, 'run')
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
const defaultInputs: main.RunInputs = {
predicate: '',
predicateType: '',
predicatePath: '',
subjectName: '',
subjectDigest: '',
subjectPath: '',
pushToRegistry: false,
githubToken: '',
privateSigning: false,
batchSize: 50,
batchDelay: 5000
}
describe('action', () => {
// Capture original environment variables and GitHub context so we can restore
// them after each test
@@ -95,24 +107,22 @@ describe('action', () => {
})
describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => {
const inputs = {
'subject-digest': subjectDigest,
'subject-name': subjectName,
'predicate-type': predicateType,
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicateType,
predicate,
'github-token': 'gh-token'
githubToken: 'gh-token'
}
beforeEach(() => {
// Nullify the OIDC token URL
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ''
getInputMock.mockImplementation(mockInput(inputs))
getBooleanInputMock.mockImplementation(() => false)
})
it('sets a failed status', async () => {
await main.run()
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
@@ -124,12 +134,8 @@ describe('action', () => {
})
describe('when no inputs are provided', () => {
beforeEach(() => {
getInputMock.mockImplementation(() => '')
})
it('sets a failed status', async () => {
await main.run()
await main.run(defaultInputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
@@ -139,12 +145,13 @@ describe('action', () => {
})
describe('when the repository is private', () => {
const inputs = {
'subject-digest': subjectDigest,
'subject-name': subjectName,
'predicate-type': predicateType,
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicateType,
predicate,
'github-token': 'gh-token'
githubToken: 'gh-token'
}
beforeEach(async () => {
@@ -154,9 +161,6 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' }
})
getInputMock.mockImplementation(mockInput(inputs))
getBooleanInputMock.mockImplementation(() => false)
await mockFulcio({
baseURL: 'https://fulcio.githubapp.com',
strict: false
@@ -165,7 +169,7 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run()
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalledWith()
@@ -204,12 +208,14 @@ describe('action', () => {
const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials')
const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage')
const inputs = {
'subject-digest': subjectDigest,
'subject-name': subjectName,
'predicate-type': predicateType,
const inputs: main.RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicateType,
predicate,
'github-token': 'gh-token'
githubToken: 'gh-token',
pushToRegistry: true
}
beforeEach(async () => {
@@ -219,11 +225,6 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' }
})
// Mock the action's inputs
getInputMock.mockImplementation(mockInput(inputs))
// This is where we mock the push-to-registry input
getBooleanInputMock.mockImplementation(() => true)
await mockFulcio({
baseURL: 'https://fulcio.sigstore.dev',
strict: false
@@ -244,7 +245,7 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run()
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
@@ -291,6 +292,7 @@ describe('action', () => {
describe('when the subject count exceeds the batch size', () => {
let dir = ''
const filename = 'subject'
let scope: nock.Scope
beforeEach(async () => {
@@ -298,7 +300,6 @@ describe('action', () => {
nock.cleanAll()
const subjectCount = 5
const filename = 'subject'
const content = 'file content'
// Set-up temp directory
@@ -340,17 +341,6 @@ describe('action', () => {
payload: { repository: { visibility: 'private' } },
repo: { owner: 'foo', repo: 'bar' }
})
const inputs = {
'subject-path': path.join(dir, `${filename}-*`),
'predicate-type': predicateType,
predicate,
'github-token': 'gh-token',
'batch-size': '2',
'batch-delay': '500'
}
getInputMock.mockImplementation(mockInput(inputs))
getBooleanInputMock.mockImplementation(() => false)
})
afterEach(async () => {
@@ -359,7 +349,16 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run()
const inputs: main.RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token',
batchSize: 2,
batchDelay: 500
}
await main.run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
@@ -380,15 +379,6 @@ describe('action', () => {
})
})
function mockInput(inputs: Record<string, string>): typeof core.getInput {
return (name: string): string => {
if (name in inputs) {
return inputs[name]
}
return ''
}
}
// 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 {
+46 -40
View File
@@ -1,91 +1,97 @@
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { predicateFromInputs } from '../src/predicate'
import { predicateFromInputs, PredicateInputs } from '../src/predicate'
describe('subjectFromInputs', () => {
afterEach(() => {
process.env['INPUT_PREDICATE'] = ''
process.env['INPUT_PREDICATE-PATH'] = ''
process.env['INPUT_PREDICATE-TYPE'] = ''
})
const blankInputs: PredicateInputs = {
predicateType: '',
predicate: '',
predicatePath: ''
}
describe('when no inputs are provided', () => {
it('throws an error', () => {
expect(() => predicateFromInputs()).toThrow(/predicate-type/i)
expect(() => predicateFromInputs(blankInputs)).toThrow(/predicate-type/i)
})
})
describe('when neither predicate path nor predicate are provided', () => {
beforeEach(() => {
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
})
it('throws an error', () => {
expect(() => predicateFromInputs()).toThrow(
const inputs: PredicateInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate'
}
expect(() => predicateFromInputs(inputs)).toThrow(
/one of predicate-path or predicate must be provided/i
)
})
})
describe('when both predicate path and predicate are provided', () => {
beforeEach(() => {
process.env['INPUT_PREDICATE-PATH'] = 'path/to/predicate'
process.env['INPUT_PREDICATE'] = '{}'
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
})
it('throws an error', () => {
expect(() => predicateFromInputs()).toThrow(
const inputs: PredicateInputs = {
predicateType: 'https://example.com/predicate',
predicate: '{}',
predicatePath: 'path/to/predicate'
}
expect(() => predicateFromInputs(inputs)).toThrow(
/only one of predicate-path or predicate may be provided/i
)
})
})
describe('when specifying a predicate path', () => {
let dir = ''
const filename = 'subject'
const predicateType = 'https://example.com/predicate'
const content = '{}'
let predicatePath = ''
beforeEach(async () => {
// Set-up temp directory
const tmpDir = await fs.realpath(os.tmpdir())
dir = await fs.mkdtemp(tmpDir + path.sep)
const dir = await fs.mkdtemp(tmpDir + path.sep)
const filename = 'subject'
predicatePath = path.join(dir, filename)
// Write file to temp directory
await fs.writeFile(path.join(dir, filename), content)
await fs.writeFile(predicatePath, content)
})
afterEach(async () => {
// Clean-up temp directory
await fs.rm(dir, { recursive: true })
})
beforeEach(() => {
process.env['INPUT_PREDICATE-PATH'] = path.join(dir, filename)
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
await fs.rm(path.parse(predicatePath).dir, { recursive: true })
})
it('returns the predicate', () => {
expect(predicateFromInputs()).toEqual({
type: 'https://example.com/predicate',
params: {}
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicatePath
}
expect(predicateFromInputs(inputs)).toEqual({
type: predicateType,
params: JSON.parse(content)
})
})
})
describe('when specifying a predicate value', () => {
const predicateType = 'https://example.com/predicate'
const content = '{}'
beforeEach(() => {
process.env['INPUT_PREDICATE'] = content
process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate'
})
it('returns the predicate', () => {
expect(predicateFromInputs()).toEqual({
type: 'https://example.com/predicate',
params: {}
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicate: content
}
expect(predicateFromInputs(inputs)).toEqual({
type: predicateType,
params: JSON.parse(content)
})
})
})
+106 -98
View File
@@ -2,47 +2,45 @@ import crypto from 'crypto'
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { subjectFromInputs } from '../src/subject'
import { subjectFromInputs, SubjectInputs } from '../src/subject'
describe('subjectFromInputs', () => {
beforeEach(() => {
process.env['INPUT_PUSH-TO-REGISTRY'] = 'false'
})
afterEach(() => {
process.env['INPUT_SUBJECT-PATH'] = ''
process.env['INPUT_SUBJECT-DIGEST'] = ''
process.env['INPUT_SUBJECT-NAME'] = ''
})
const blankInputs: SubjectInputs = {
subjectPath: '',
subjectName: '',
subjectDigest: ''
}
describe('when no inputs are provided', () => {
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
await expect(subjectFromInputs(blankInputs)).rejects.toThrow(
/one of subject-path or subject-digest must be provided/i
)
})
})
describe('when both subject path and subject digest are provided', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = 'path/to/subject'
process.env['INPUT_SUBJECT-DIGEST'] = 'digest'
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
subjectName: 'foo',
subjectPath: 'path/to/subject',
subjectDigest: 'digest'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/only one of subject-path or subject-digest may be provided/i
)
})
})
describe('when subject digest is provided but not the name', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'digest'
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'digest'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-name must be provided when using subject-digest/i
)
})
@@ -52,39 +50,42 @@ describe('subjectFromInputs', () => {
const name = 'Subject'
describe('when the digest is malformed', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'digest'
process.env['INPUT_SUBJECT-NAME'] = name
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'digest',
subjectName: name
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format "sha256:<hex-digest>"/i
)
})
})
describe('when the alogrithm is not supported', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'md5:deadbeef'
process.env['INPUT_SUBJECT-NAME'] = name
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'md5:deadbeef',
subjectName: name
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format "sha256:<hex-digest>"/i
)
})
})
describe('when the sha256 digest is malformed', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = 'sha256:deadbeef'
process.env['INPUT_SUBJECT-NAME'] = name
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: 'sha256:deadbeef',
subjectName: name
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format "sha256:<hex-digest>"/i
)
})
@@ -95,13 +96,14 @@ describe('subjectFromInputs', () => {
const digest =
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = `${alg}:${digest}`
process.env['INPUT_SUBJECT-NAME'] = name
})
it('returns the subject', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: `${alg}:${digest}`,
subjectName: name
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -110,20 +112,21 @@ describe('subjectFromInputs', () => {
})
})
describe('when the push-to-registry is true', () => {
describe('when the downcaseName is true', () => {
const imageName = 'ghcr.io/FOO/bar'
const alg = 'sha256'
const digest =
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
beforeEach(() => {
process.env['INPUT_SUBJECT-DIGEST'] = `${alg}:${digest}`
process.env['INPUT_SUBJECT-NAME'] = imageName
process.env['INPUT_PUSH-TO-REGISTRY'] = 'true'
})
it('returns the subject (with name downcased)', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectDigest: `${alg}:${digest}`,
subjectName: imageName,
downcaseName: true
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -135,12 +138,13 @@ describe('subjectFromInputs', () => {
describe('when specifying a subject path', () => {
describe('when the file does NOT exist', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = '/f/a/k/e'
})
it('throws an error', async () => {
await expect(subjectFromInputs()).rejects.toThrow(
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: '/f/a/k/e'
}
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/could not find subject at path/i
)
})
@@ -178,12 +182,13 @@ describe('subjectFromInputs', () => {
})
describe('when no name is provided', () => {
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = path.join(dir, filename)
})
it('returns the subject', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: path.join(dir, filename)
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -195,13 +200,14 @@ describe('subjectFromInputs', () => {
describe('when a name is provided', () => {
const name = 'mysubject'
beforeEach(() => {
process.env['INPUT_SUBJECT-PATH'] = path.join(dir, filename)
process.env['INPUT_SUBJECT-NAME'] = name
})
it('returns the subject', async () => {
const subject = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: path.join(dir, filename),
subjectName: name
}
const subject = await subjectFromInputs(inputs)
expect(subject).toBeDefined()
expect(subject).toHaveLength(1)
@@ -211,12 +217,13 @@ describe('subjectFromInputs', () => {
})
describe('when a file glob is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] = path.join(dir, 'subject-*')
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: path.join(dir, 'subject-*')
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(3)
@@ -230,12 +237,13 @@ describe('subjectFromInputs', () => {
})
describe('when a file glob is supplied which also matches non-files', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] = `${dir}*`
})
it('returns the subjects (excluding non-files)', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${dir}*`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(7)
@@ -243,13 +251,13 @@ describe('subjectFromInputs', () => {
})
describe('when a comma-separated list is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] =
`${path.join(dir, 'subject-1')},${path.join(dir, 'subject-2')}`
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-1')},${path.join(dir, 'subject-2')}`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
@@ -266,13 +274,13 @@ describe('subjectFromInputs', () => {
})
describe('when a multi-line list is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] =
`${path.join(dir, 'subject-0')}\n${path.join(dir, 'subject-2')}`
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-0')}\n${path.join(dir, 'subject-2')}`
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
@@ -289,13 +297,13 @@ describe('subjectFromInputs', () => {
})
describe('when a multi-line glob list is supplied', () => {
beforeEach(async () => {
process.env['INPUT_SUBJECT-PATH'] =
`${path.join(dir, 'subject-*')}\n ${path.join(dir, 'other-*')} `
})
it('returns the multiple subjects', async () => {
const subjects = await subjectFromInputs()
const inputs: SubjectInputs = {
...blankInputs,
subjectPath: `${path.join(dir, 'subject-*')}\n ${path.join(dir, 'other-*')} `
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(6)