2
0

input-helper.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import {
  2. jest,
  3. describe,
  4. it,
  5. expect,
  6. beforeAll,
  7. beforeEach,
  8. afterAll
  9. } from '@jest/globals'
  10. import * as path from 'path'
  11. const originalGitHubWorkspace = process.env['GITHUB_WORKSPACE']
  12. const gitHubWorkspace = path.resolve('/checkout-tests/workspace')
  13. // Inputs for mock @actions/core
  14. let inputs = {} as any
  15. // Mutable mock github context
  16. const mockGithubContext: any = {
  17. ref: 'refs/heads/some-ref',
  18. sha: '1234567890123456789012345678901234567890',
  19. repo: {owner: 'some-owner', repo: 'some-repo'},
  20. eventName: '',
  21. payload: {}
  22. }
  23. // Replicate @actions/core getInput behavior: it trims whitespace by default
  24. // (String.prototype.trim(), which strips characters such as a leading U+FEFF BOM)
  25. // unless trimWhitespace is explicitly set to false.
  26. const getInputImpl = (name: string, options?: {trimWhitespace?: boolean}) => {
  27. const val = inputs[name] ?? ''
  28. if (options && options.trimWhitespace === false) {
  29. return val
  30. }
  31. return typeof val === 'string' ? val.trim() : val
  32. }
  33. // Mock @actions/core before loading input-helper
  34. jest.unstable_mockModule('@actions/core', () => ({
  35. getInput: jest.fn(getInputImpl),
  36. getBooleanInput: jest.fn((name: string) => inputs[name]),
  37. getMultilineInput: jest.fn((name: string) =>
  38. inputs[name] ? String(inputs[name]).split('\n').filter(Boolean) : []
  39. ),
  40. error: jest.fn(),
  41. warning: jest.fn(),
  42. info: jest.fn(),
  43. debug: jest.fn(),
  44. setFailed: jest.fn(),
  45. setOutput: jest.fn(),
  46. setSecret: jest.fn()
  47. }))
  48. // Mock @actions/github before loading input-helper
  49. jest.unstable_mockModule('@actions/github', () => ({
  50. context: mockGithubContext,
  51. getOctokit: jest.fn()
  52. }))
  53. // Mock fs-helper
  54. const mockDirectoryExistsSync = jest.fn((p: string) => p === gitHubWorkspace)
  55. jest.unstable_mockModule('../src/fs-helper.js', () => ({
  56. directoryExistsSync: mockDirectoryExistsSync,
  57. fileExistsSync: jest.fn()
  58. }))
  59. // Mock workflow-context-helper
  60. const mockGetOrganizationId = jest.fn(async () => 123456)
  61. jest.unstable_mockModule('../src/workflow-context-helper.js', () => ({
  62. getOrganizationId: mockGetOrganizationId
  63. }))
  64. // Dynamic imports after mocking
  65. const core = await import('@actions/core')
  66. const inputHelper = await import('../src/input-helper.js')
  67. type IGitSourceSettings =
  68. import('../src/git-source-settings.js').IGitSourceSettings
  69. describe('input-helper tests', () => {
  70. beforeAll(() => {
  71. // GitHub workspace
  72. process.env['GITHUB_WORKSPACE'] = gitHubWorkspace
  73. })
  74. beforeEach(() => {
  75. // Reset inputs
  76. inputs = {}
  77. jest.clearAllMocks()
  78. // Re-apply default mocks
  79. ;(core.getInput as jest.Mock<any>).mockImplementation(getInputImpl as any)
  80. mockDirectoryExistsSync.mockImplementation(
  81. (p: string) => p === gitHubWorkspace
  82. )
  83. mockGetOrganizationId.mockResolvedValue(123456)
  84. })
  85. afterAll(() => {
  86. // Restore GitHub workspace
  87. delete process.env['GITHUB_WORKSPACE']
  88. if (originalGitHubWorkspace) {
  89. process.env['GITHUB_WORKSPACE'] = originalGitHubWorkspace
  90. }
  91. // Restore @actions/github context
  92. mockGithubContext.ref = 'refs/heads/some-ref'
  93. mockGithubContext.sha = '1234567890123456789012345678901234567890'
  94. })
  95. it('sets defaults', async () => {
  96. const settings: IGitSourceSettings = await inputHelper.getInputs()
  97. expect(settings).toBeTruthy()
  98. expect(settings.authToken).toBeFalsy()
  99. expect(settings.clean).toBe(true)
  100. expect(settings.commit).toBeTruthy()
  101. expect(settings.commit).toBe('1234567890123456789012345678901234567890')
  102. expect(settings.filter).toBe(undefined)
  103. expect(settings.sparseCheckout).toBe(undefined)
  104. expect(settings.sparseCheckoutConeMode).toBe(true)
  105. expect(settings.fetchDepth).toBe(1)
  106. expect(settings.fetchTags).toBe(false)
  107. expect(settings.showProgress).toBe(true)
  108. expect(settings.lfs).toBe(false)
  109. expect(settings.ref).toBe('refs/heads/some-ref')
  110. expect(settings.repositoryName).toBe('some-repo')
  111. expect(settings.repositoryOwner).toBe('some-owner')
  112. expect(settings.repositoryPath).toBe(gitHubWorkspace)
  113. expect(settings.setSafeDirectory).toBe(true)
  114. expect(settings.allowUnsafePrCheckout).toBe(false)
  115. })
  116. it('qualifies ref', async () => {
  117. let originalRef = mockGithubContext.ref
  118. try {
  119. mockGithubContext.ref = 'some-unqualified-ref'
  120. const settings: IGitSourceSettings = await inputHelper.getInputs()
  121. expect(settings).toBeTruthy()
  122. expect(settings.commit).toBe('1234567890123456789012345678901234567890')
  123. expect(settings.ref).toBe('refs/heads/some-unqualified-ref')
  124. } finally {
  125. mockGithubContext.ref = originalRef
  126. }
  127. })
  128. it('requires qualified repo', async () => {
  129. inputs.repository = 'some-unqualified-repo'
  130. try {
  131. await inputHelper.getInputs()
  132. throw 'should not reach here'
  133. } catch (err) {
  134. expect(`(${(err as any).message}`).toMatch(
  135. "Invalid repository 'some-unqualified-repo'"
  136. )
  137. }
  138. })
  139. it('roots path', async () => {
  140. inputs.path = 'some-directory/some-subdirectory'
  141. const settings: IGitSourceSettings = await inputHelper.getInputs()
  142. expect(settings.repositoryPath).toBe(
  143. path.join(gitHubWorkspace, 'some-directory', 'some-subdirectory')
  144. )
  145. })
  146. it('sets ref to empty when explicit sha', async () => {
  147. inputs.ref = '1111111111222222222233333333334444444444'
  148. const settings: IGitSourceSettings = await inputHelper.getInputs()
  149. expect(settings.ref).toBeFalsy()
  150. expect(settings.commit).toBe('1111111111222222222233333333334444444444')
  151. })
  152. it('sets ref to empty when explicit sha-256', async () => {
  153. inputs.ref =
  154. '1111111111222222222233333333334444444444555555555566666666667777'
  155. const settings: IGitSourceSettings = await inputHelper.getInputs()
  156. expect(settings.ref).toBeFalsy()
  157. expect(settings.commit).toBe(
  158. '1111111111222222222233333333334444444444555555555566666666667777'
  159. )
  160. })
  161. it('sets sha to empty when explicit ref', async () => {
  162. inputs.ref = 'refs/heads/some-other-ref'
  163. const settings: IGitSourceSettings = await inputHelper.getInputs()
  164. expect(settings.ref).toBe('refs/heads/some-other-ref')
  165. expect(settings.commit).toBeFalsy()
  166. })
  167. it('does not reclassify a ref as sha when a BOM is prefixed', async () => {
  168. // A fork branch named "<U+FEFF>" + 40 hex chars. core.getInput trims the
  169. // BOM by default, which previously collapsed this into a bare SHA and
  170. // bypassed the unsafe fork PR checkout guard.
  171. inputs.ref = '\uFEFF522d932fae5296da51fdf431934425ecf891c6a2'
  172. const settings: IGitSourceSettings = await inputHelper.getInputs()
  173. expect(settings.commit).toBeFalsy()
  174. expect(settings.ref).toBe('522d932fae5296da51fdf431934425ecf891c6a2')
  175. })
  176. it('does not reclassify a sha-256 ref as sha when a BOM is prefixed', async () => {
  177. inputs.ref =
  178. '\uFEFF1111111111222222222233333333334444444444555555555566666666667777'
  179. const settings: IGitSourceSettings = await inputHelper.getInputs()
  180. expect(settings.commit).toBeFalsy()
  181. expect(settings.ref).toBe(
  182. '1111111111222222222233333333334444444444555555555566666666667777'
  183. )
  184. })
  185. it('treats a sha surrounded by ascii whitespace as a commit', async () => {
  186. // ASCII whitespace can only come from the workflow author's YAML (git ref
  187. // names cannot contain it), so trimming it and treating the value as a
  188. // commit is safe.
  189. inputs.ref = ' 1111111111222222222233333333334444444444 '
  190. const settings: IGitSourceSettings = await inputHelper.getInputs()
  191. expect(settings.ref).toBeFalsy()
  192. expect(settings.commit).toBe('1111111111222222222233333333334444444444')
  193. })
  194. it('sets workflow organization ID', async () => {
  195. const settings: IGitSourceSettings = await inputHelper.getInputs()
  196. expect(settings.workflowOrganizationId).toBe(123456)
  197. })
  198. describe('unsafe PR checkout guard', () => {
  199. const forkPayload = {
  200. repository: {id: 100},
  201. pull_request: {
  202. head: {
  203. sha: '1234567890123456789012345678901234567890',
  204. repo: {id: 200, full_name: 'attacker/fork'}
  205. },
  206. merge_commit_sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  207. }
  208. }
  209. it('allows the default self-checkout on a fork pull_request_target', async () => {
  210. const originalEvent = mockGithubContext.eventName
  211. const originalPayload = mockGithubContext.payload
  212. try {
  213. mockGithubContext.eventName = 'pull_request_target'
  214. mockGithubContext.payload = forkPayload
  215. // Simulate a rebase/fast-forward merge where the base tip (event SHA)
  216. // equals the PR head SHA. The default self-checkout must still succeed.
  217. mockGithubContext.sha = '1234567890123456789012345678901234567890'
  218. const settings: IGitSourceSettings = await inputHelper.getInputs()
  219. expect(settings.commit).toBe('1234567890123456789012345678901234567890')
  220. } finally {
  221. mockGithubContext.eventName = originalEvent
  222. mockGithubContext.payload = originalPayload
  223. }
  224. })
  225. it('refuses an explicit fork repository on pull_request_target', async () => {
  226. const originalEvent = mockGithubContext.eventName
  227. const originalPayload = mockGithubContext.payload
  228. try {
  229. mockGithubContext.eventName = 'pull_request_target'
  230. mockGithubContext.payload = forkPayload
  231. inputs.repository = 'attacker/fork'
  232. await expect(inputHelper.getInputs()).rejects.toThrow(
  233. /Refusing to check out fork pull request code/
  234. )
  235. } finally {
  236. mockGithubContext.eventName = originalEvent
  237. mockGithubContext.payload = originalPayload
  238. }
  239. })
  240. })
  241. })