diff --git a/__test__/git-source-provider.test.ts b/__test__/git-source-provider.test.ts new file mode 100644 index 0000000..aab048d --- /dev/null +++ b/__test__/git-source-provider.test.ts @@ -0,0 +1,316 @@ +import {jest, describe, it, expect, beforeEach} from '@jest/globals' + +// Mock @actions/core before loading git-source-provider +const mockSetOutput = jest.fn() +jest.unstable_mockModule('@actions/core', () => ({ + setOutput: mockSetOutput, + setSecret: jest.fn(), + setFailed: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + startGroup: jest.fn(), + endGroup: jest.fn() +})) + +jest.unstable_mockModule('@actions/io', () => ({ + cp: jest.fn(), + mkdirP: jest.fn(), + mv: jest.fn(), + rmRF: jest.fn(), + which: jest.fn() +})) + +jest.unstable_mockModule('../src/fs-helper.js', () => ({ + directoryExistsSync: jest.fn(() => true), + existsSync: jest.fn(() => true), + fileExistsSync: jest.fn(() => false) +})) + +const mockCreateCommandManager = jest.fn() +jest.unstable_mockModule('../src/git-command-manager.js', () => ({ + createCommandManager: mockCreateCommandManager, + MinimumGitVersion: '2.18', + MinimumGitSparseCheckoutVersion: '2.28' +})) + +const mockDownloadRepository = jest.fn() +jest.unstable_mockModule('../src/github-api-helper.js', () => ({ + downloadRepository: mockDownloadRepository, + getDefaultBranch: jest.fn(async () => 'refs/heads/main'), + tryGetRepositoryObjectFormat: jest.fn(async () => ({ + format: 'sha1', + succeeded: true + })) +})) + +const mockCreateAuthHelper = jest.fn(() => ({ + configureAuth: jest.fn(), + configureGlobalAuth: jest.fn(), + configureSubmoduleAuth: jest.fn(), + configureTempGlobalConfig: jest.fn(), + removeAuth: jest.fn(), + removeGlobalAuth: jest.fn(), + removeGlobalConfig: jest.fn() +})) +jest.unstable_mockModule('../src/git-auth-helper.js', () => ({ + createAuthHelper: mockCreateAuthHelper +})) + +jest.unstable_mockModule('../src/git-directory-helper.js', () => ({ + prepareExistingDirectory: jest.fn() +})) + +jest.unstable_mockModule('../src/ref-helper.js', () => ({ + checkCommitInfo: jest.fn(), + getCheckoutInfo: jest.fn(async () => ({ + ref: 'main', + startPoint: 'refs/remotes/origin/main' + })), + getRefSpec: jest.fn(() => ['+refs/heads/main:refs/remotes/origin/main']), + getRefSpecForAllHistory: jest.fn(() => [ + '+refs/heads/main*:refs/remotes/origin/main*' + ]), + testRef: jest.fn(async () => true) +})) + +jest.unstable_mockModule('../src/state-helper.js', () => ({ + setRepositoryPath: jest.fn(), + setSafeDirectory: jest.fn(), + IsPost: false, + PostSetSafeDirectory: false, + RepositoryPath: '' +})) + +// Dynamic imports after mocking +const gitSourceProvider = await import('../src/git-source-provider.js') +type IGitSourceSettings = + import('../src/git-source-settings.js').IGitSourceSettings + +const commitSha = '1234567890123456789012345678901234567890' +const commitSha256 = + '1234567890123456789012345678901234567890123456789012345678901234' + +function getSettings(): IGitSourceSettings { + return { + allowUnsafePrCheckout: false, + authToken: 'token', + clean: true, + commit: commitSha, + fetchDepth: 1, + fetchTags: false, + filter: undefined, + githubServerUrl: undefined, + lfs: false, + nestedSubmodules: false, + persistCredentials: true, + ref: 'refs/heads/main', + repositoryName: 'my-repo', + repositoryOwner: 'my-org', + repositoryPath: '/home/runner/work/my-repo/my-repo', + setSafeDirectory: false, + showProgress: false, + // Matches getInputs(), which leaves sparseCheckout undefined when the input is empty + sparseCheckout: undefined, + sparseCheckoutConeMode: true, + sshKey: '', + sshKnownHosts: '', + sshStrict: true, + sshUser: '', + submodules: false, + workflowOrganizationId: undefined + } as unknown as IGitSourceSettings +} + +// A minimal git command manager, for the cases that do not fall back to the REST API. +function getGitCommandManager(): any { + return { + checkout: jest.fn(), + config: jest.fn(), + disableSparseCheckout: jest.fn(), + init: jest.fn(), + log1: jest.fn(async (format?: string) => + format ? `${commitSha}\n` : `commit ${commitSha}\n` + ), + remoteAdd: jest.fn(), + fetch: jest.fn(), + tryDisableAutomaticGarbageCollection: jest.fn(async () => true), + version: jest.fn(async () => ({checkMinimum: () => false})) + } +} + +describe('git-source-provider tests', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('sets the commit output when downloading using the REST API', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) + }) + + it('sets the commit output when downloading using the REST API without a commit', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + settings.commit = '' + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', '') + }) + + it('sets an empty commit output when downloading a ref that is not a SHA', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + // getInputs() leaves commit undefined when a non-SHA ref is given for another repository + settings.commit = undefined as unknown as string + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', undefined) + }) + + it('sets the commit output when downloading a SHA-256 object format repository', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + // getInputs() accepts a 64 hex character ref as a commit, for sha256 repositories + settings.commit = commitSha256 + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).toHaveBeenCalledWith( + settings.authToken, + settings.repositoryOwner, + settings.repositoryName, + settings.ref, + commitSha256, + settings.repositoryPath, + settings.githubServerUrl + ) + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha256) + }) + + it('sets the commit output after the repository has been downloaded', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) + expect(mockSetOutput.mock.invocationCallOrder[0]).toBeGreaterThan( + mockDownloadRepository.mock.invocationCallOrder[0] + ) + }) + + it('does not set the commit output when the REST API download fails (control)', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + mockDownloadRepository.mockImplementation(async () => { + throw new Error('Download failed') + }) + const settings = getSettings() + + // Act + await expect(gitSourceProvider.getSource(settings)).rejects.toThrow( + 'Download failed' + ) + + // Assert + expect(mockSetOutput).not.toHaveBeenCalledWith( + 'commit', + expect.anything() as unknown as string + ) + mockDownloadRepository.mockReset() + }) + + it('does not download or set the commit output when an input is not supported by the REST API fallback (control)', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const submoduleSettings = getSettings() + submoduleSettings.submodules = true + const sshKeySettings = getSettings() + sshKeySettings.sshKey = 'ssh-key' + + // Act + await expect( + gitSourceProvider.getSource(submoduleSettings) + ).rejects.toThrow(`Input 'submodules' not supported`) + await expect(gitSourceProvider.getSource(sshKeySettings)).rejects.toThrow( + `Input 'ssh-key' not supported` + ) + + // Assert + expect(mockDownloadRepository).not.toHaveBeenCalled() + expect(mockSetOutput).not.toHaveBeenCalledWith( + 'commit', + expect.anything() as unknown as string + ) + }) + + it('sets the commit output from git when git is available (control)', async () => { + // Arrange + const git = getGitCommandManager() + mockCreateCommandManager.mockImplementation(async () => git) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert + expect(mockDownloadRepository).not.toHaveBeenCalled() + expect(git.checkout).toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledWith('commit', commitSha) + }) + + it('does not configure auth on the REST API fallback path (control)', async () => { + // Arrange + mockCreateCommandManager.mockImplementation(async () => { + throw new Error('Git is not installed') + }) + const settings = getSettings() + + // Act + await gitSourceProvider.getSource(settings) + + // Assert: the fallback returns with authHelper still null, so the finally + // block removes nothing. The added setOutput call does not change that. + expect(mockCreateAuthHelper).not.toHaveBeenCalled() + }) +}) diff --git a/dist/index.js b/dist/index.js index 06ae5d2..495e1f0 100644 --- a/dist/index.js +++ b/dist/index.js @@ -41755,6 +41755,9 @@ async function getSource(settings) { throw new Error(`Input 'ssh-key' not supported when falling back to download using the GitHub REST API. To create a local Git repository instead, add Git ${MinimumGitVersion} or higher to the PATH.`); } await downloadRepository(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.ref, settings.commit, settings.repositoryPath, settings.githubServerUrl); + // Set the commit output. The REST API fallback does not create a local + // Git repository, so the SHA can only come from the resolved settings. + setOutput('commit', settings.commit); return; } // Save state for POST action diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index b9c1d35..5649999 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -99,6 +99,10 @@ export async function getSource(settings: IGitSourceSettings): Promise { settings.repositoryPath, settings.githubServerUrl ) + + // Set the commit output. The REST API fallback does not create a local + // Git repository, so the SHA can only come from the resolved settings. + core.setOutput('commit', settings.commit) return }