diff --git a/src/git/connect.ts b/src/git/connect.ts index 232b237c..d6650ffd 100644 --- a/src/git/connect.ts +++ b/src/git/connect.ts @@ -1,5 +1,4 @@ import Debug from 'debug' -import { GitConfig } from '../otomi-models' import { Git } from '../git' const debug = Debug('otomi:git-connect') @@ -12,7 +11,7 @@ export function getUrl(url: string): string { return !url || url.includes('://') ? url : `${getProtocol(url)}://${url}` } -export function getAuthenticatedUrl(gitConfig: GitConfig): string { +export function getAuthenticatedUrl(gitConfig: { repoUrl: string; username?: string; password: string }): string { const protocol = getProtocol(gitConfig.repoUrl) if (protocol === 'file') { return gitConfig.repoUrl diff --git a/src/openapi/testrepoconnect.yaml b/src/openapi/testrepoconnect.yaml index ac293056..4ac5c32c 100644 --- a/src/openapi/testrepoconnect.yaml +++ b/src/openapi/testrepoconnect.yaml @@ -11,4 +11,6 @@ TestRepoConnect: - unknown - success - failed + message: + type: string type: object diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index cdf1b3c5..16a75b52 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -10,7 +10,9 @@ import { import OtomiStack from 'src/otomi-stack' import { loadSpec } from './app' import { BadRequestError, NotExistError, ValidationError } from './error' +import { pathExists, unlink } from 'fs-extra' import { Git } from './git' +import { extractRepositoryRefs, getAuthenticatedGitClient } from './utils/codeRepoUtils' jest.mock('./tty', () => ({ __esModule: true, @@ -69,6 +71,18 @@ jest.mock('./utils/sealedSecretUtils', () => { } }) +jest.mock('./utils/codeRepoUtils', () => ({ + ...jest.requireActual('./utils/codeRepoUtils'), + getAuthenticatedGitClient: jest.fn(), + extractRepositoryRefs: jest.fn(), +})) + +jest.mock('fs-extra', () => ({ + ...jest.requireActual('fs-extra'), + pathExists: jest.fn().mockResolvedValue(false), + unlink: jest.fn().mockResolvedValue(undefined), +})) + beforeAll(async () => { jest.spyOn(console, 'log').mockImplementation(() => {}) jest.spyOn(console, 'debug').mockImplementation(() => {}) @@ -1491,3 +1505,173 @@ describe('OtomiStack locked state', () => { expect(stack.getApiStatus()).toEqual({ locked: false }) }) }) + +describe('getRepoBranches', () => { + let otomiStack: OtomiStack + + beforeEach(async () => { + otomiStack = new OtomiStack() + await otomiStack.init() + + const { FileStore } = require('./fileStore/file-store') + otomiStack.fileStore = new FileStore() + + createTestTeam(otomiStack, 'demo', {}) + + const codeRepo: AplCodeRepoResponse = { + kind: 'AplTeamCodeRepo', + metadata: { name: 'code-1', labels: { 'apl.io/teamId': 'demo' } }, + spec: { gitService: 'gitea', repositoryUrl: 'https://gitea.test.com' }, + status: {}, + } + otomiStack.fileStore.setTeamResource(codeRepo) + + jest.spyOn(otomiStack, 'getApp').mockReturnValue({ id: 'gitea', values: { enabled: true } } as App) + jest + .spyOn(otomiStack, 'getSettings') + .mockResolvedValue({ cluster: { name: '', provider: 'custom', domainSuffix: 'test.example.com' } }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should return ["HEAD"] when no codeRepoName is provided', async () => { + const result = await otomiStack.getRepoBranches('', 'demo') + expect(result).toEqual(['HEAD']) + }) + + it('should return ["HEAD"] when repo has no repositoryUrl', async () => { + const codeRepoNoUrl: AplCodeRepoResponse = { + kind: 'AplTeamCodeRepo', + metadata: { name: 'no-url-repo', labels: { 'apl.io/teamId': 'demo' } }, + spec: { gitService: 'gitea' } as any, + status: {}, + } + otomiStack.fileStore.setTeamResource(codeRepoNoUrl) + + const result = await otomiStack.getRepoBranches('no-url-repo', 'demo') + expect(result).toEqual(['HEAD']) + }) + + it('should return branches from extractRepositoryRefs', async () => { + const mockGitInstance = { listRemote: jest.fn() } + ;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ git: mockGitInstance, url: 'https://gitea.test.com' }) + ;(extractRepositoryRefs as jest.Mock).mockResolvedValue(['main', 'develop']) + + const result = await otomiStack.getRepoBranches('code-1', 'demo') + + expect(getAuthenticatedGitClient).toHaveBeenCalledWith( + 'https://gitea.test.com', + 'demo', + 'test.example.com', + expect.anything(), + undefined, + ) + expect(extractRepositoryRefs).toHaveBeenCalledWith('https://gitea.test.com', mockGitInstance) + expect(result).toEqual(['main', 'develop']) + }) + + it('should return [] when extractRepositoryRefs throws', async () => { + const mockGitInstance = { listRemote: jest.fn() } + ;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ git: mockGitInstance, url: 'https://gitea.test.com' }) + ;(extractRepositoryRefs as jest.Mock).mockRejectedValue(new Error('Network error')) + + const result = await otomiStack.getRepoBranches('code-1', 'demo') + expect(result).toEqual([]) + }) + + it('should return [] when getAuthenticatedGitClient throws', async () => { + ;(getAuthenticatedGitClient as jest.Mock).mockRejectedValue(new Error('Auth failed')) + + const result = await otomiStack.getRepoBranches('code-1', 'demo') + expect(result).toEqual([]) + }) + + it('should clean up the SSH key file after fetching branches', async () => { + const keyPath = '/tmp/otomi/sshKey-test' + const mockGitInstance = { listRemote: jest.fn() } + ;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ + git: mockGitInstance, + url: 'https://gitea.test.com', + keyPath, + }) + ;(extractRepositoryRefs as jest.Mock).mockResolvedValue(['main']) + ;(pathExists as jest.Mock).mockResolvedValue(true) + + await otomiStack.getRepoBranches('code-1', 'demo') + + expect(pathExists).toHaveBeenCalledWith(keyPath) + expect(unlink).toHaveBeenCalledWith(keyPath) + }) +}) + +describe('getTestRepoConnect', () => { + let otomiStack: OtomiStack + + beforeEach(async () => { + otomiStack = new OtomiStack() + await otomiStack.init() + + const { FileStore } = require('./fileStore/file-store') + otomiStack.fileStore = new FileStore() + + jest.spyOn(otomiStack, 'getApp').mockReturnValue({ id: 'gitea', values: { enabled: true } } as App) + jest + .spyOn(otomiStack, 'getSettings') + .mockResolvedValue({ cluster: { name: '', provider: 'custom', domainSuffix: 'test.example.com' } }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should return { status: "success" } when git.listRemote succeeds', async () => { + const mockGitInstance = { listRemote: jest.fn().mockResolvedValue('') } + ;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ + git: mockGitInstance, + url: 'https://gitea.test.com', + }) + + const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret') + + expect(result).toEqual({ status: 'success' }) + expect(mockGitInstance.listRemote).toHaveBeenCalledWith(['https://gitea.test.com']) + }) + + it('should return { status: "failed" } when git.listRemote throws', async () => { + const mockGitInstance = { listRemote: jest.fn().mockRejectedValue(new Error('Connection refused')) } + ;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ + git: mockGitInstance, + url: 'https://gitea.test.com', + }) + + const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret') + + expect(result).toEqual({ status: 'failed', message: 'Connection refused' }) + }) + + it('should return { status: "failed" } when getAuthenticatedGitClient throws', async () => { + ;(getAuthenticatedGitClient as jest.Mock).mockRejectedValue(new Error('Invalid credentials')) + + const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret') + + expect(result).toEqual({ status: 'failed', message: 'Invalid credentials' }) + }) + + it('should clean up the SSH key file after testing connection', async () => { + const keyPath = '/tmp/otomi/sshKey-test' + const mockGitInstance = { listRemote: jest.fn().mockResolvedValue('') } + ;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ + git: mockGitInstance, + url: 'https://gitea.test.com', + keyPath, + }) + ;(pathExists as jest.Mock).mockResolvedValue(true) + + await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret') + + expect(pathExists).toHaveBeenCalledWith(keyPath) + expect(unlink).toHaveBeenCalledWith(keyPath) + }) +}) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 70dbbbe5..114778b1 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -145,14 +145,7 @@ import { watchPodUntilRunning, } from './k8s-operations' import CloudTty from './tty' -import { - getGiteaRepoUrls, - getPrivateRepoBranches, - getPublicRepoBranches, - normalizeRepoUrl, - testPrivateRepoConnect, - testPublicRepoConnect, -} from './utils/codeRepoUtils' +import { extractRepositoryRefs, getAuthenticatedGitClient, getGiteaRepoUrls } from './utils/codeRepoUtils' import { isKnativeSupported } from './utils/k8sUtils' import { getV1ObjectFromApl } from './utils/manifests' import { @@ -175,8 +168,9 @@ import { userSecretDataToUser, } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' -import { fetchWorkloadCatalog, isInteralGiteaURL } from './utils/workloadUtils' +import { fetchWorkloadCatalog } from './utils/workloadUtils' import { getAuthenticatedUrl } from './git/connect' +import { pathExists, unlink } from 'fs-extra' interface ExcludedApp extends App { managed: boolean @@ -1493,69 +1487,60 @@ export default class OtomiStack { const coderepo = this.getAplCodeRepo(teamId, codeRepoName) const { repositoryUrl, secret: secretName } = coderepo.spec - + if (!repositoryUrl) return ['HEAD'] + const giteaValues = this.getApp('gitea').values const { cluster } = await this.getSettings(['cluster']) try { - let sshPrivateKey = '' - let username = '' - let accessToken = '' - - if (secretName) { - const secret = await getSecretValues(secretName, `team-${teamId}`) - sshPrivateKey = secret?.['ssh-privatekey'] || '' - username = secret?.username || '' - accessToken = secret?.password || '' - } - - const isPrivate = !!secretName - const isSSH = !!sshPrivateKey - - const repoUrl = isInteralGiteaURL(repositoryUrl, cluster?.domainSuffix) - ? repositoryUrl - : normalizeRepoUrl(repositoryUrl, isPrivate, isSSH) - - if (!repoUrl) return ['HEAD'] - - if (isPrivate) { - return await getPrivateRepoBranches(repoUrl, sshPrivateKey, username, accessToken) + const { git, url, keyPath } = await getAuthenticatedGitClient( + repositoryUrl, + teamId, + cluster?.domainSuffix, + giteaValues, + secretName, + ) + try { + return await extractRepositoryRefs(url, git) + } catch (error) { + const errorMessage = error.response?.data?.message || error?.message || 'Failed to get repo branches' + debug('Error getting branches:', errorMessage) + return [] + } finally { + if (keyPath && (await pathExists(keyPath))) { + await unlink(keyPath) + } } - - return await getPublicRepoBranches(repoUrl) } catch (error) { - const errorMessage = error.response?.data?.message || error?.message || 'Failed to get repo branches' - debug('Error getting branches:', errorMessage) + debug('Error getting branches:', error.message) return [] } } - async getTestRepoConnect(url: string, teamId: string, secretName: string): Promise { - try { - let sshPrivateKey = '', - username = '', - accessToken = '' - - const isPrivate = !!secretName - - if (isPrivate) { - const secret = await getSecretValues(secretName, `team-${teamId}`) - sshPrivateKey = secret?.['ssh-privatekey'] || '' - username = secret?.username || '' - accessToken = secret?.password || '' - } - - const isSSH = !!sshPrivateKey - const repoUrl = normalizeRepoUrl(url, isPrivate, isSSH) - - if (!repoUrl) return { status: 'failed' } + async getTestRepoConnect(repositoryUrl: string, teamId: string, secretName: string): Promise { + const giteaValues = this.getApp('gitea').values + const { cluster } = await this.getSettings(['cluster']) - if (isPrivate) { - return (await testPrivateRepoConnect(repoUrl, sshPrivateKey, username, accessToken)) as TestRepoConnect + try { + const { git, url, keyPath } = await getAuthenticatedGitClient( + repositoryUrl, + teamId, + cluster?.domainSuffix, + giteaValues, + secretName, + ) + try { + await git.listRemote([url]) + return { status: 'success' } + } catch (error) { + const message = error.response?.data?.message || error?.message + return { status: 'failed', message } + } finally { + if (keyPath && (await pathExists(keyPath))) { + await unlink(keyPath) + } } - - return (await testPublicRepoConnect(repoUrl)) as TestRepoConnect } catch (error) { - return { status: 'failed' } + return { status: 'failed', message: error.message } } } diff --git a/src/utils/codeRepoUtils.test.ts b/src/utils/codeRepoUtils.test.ts index 58976ea0..984dc9c3 100644 --- a/src/utils/codeRepoUtils.test.ts +++ b/src/utils/codeRepoUtils.test.ts @@ -1,20 +1,15 @@ import axios from 'axios' -import { pathExists, unlink } from 'fs-extra' -import { chmod, writeFile } from 'fs/promises' +import { writeFile } from 'fs/promises' import simpleGit, { SimpleGit } from 'simple-git' import { OtomiError } from 'src/error' import { v4 as uuidv4 } from 'uuid' -import * as codeRepoUtils from './codeRepoUtils' +import { getSecretValues } from '../k8s-operations' import { extractRepositoryRefs, + getAuthenticatedGitClient, getGiteaRepoUrls, - getPrivateRepoBranches, - getPublicRepoBranches, normalizeRepoUrl, normalizeSSHKey, - setupGitAuthentication, - testPrivateRepoConnect, - testPublicRepoConnect, } from './codeRepoUtils' jest.mock('simple-git', () => ({ @@ -26,7 +21,6 @@ jest.mock('simple-git', () => ({ })) jest.mock('axios') -jest.mock('fs-extra') jest.mock('fs/promises') jest.mock('uuid') jest.mock('src/error', () => ({ @@ -37,81 +31,15 @@ jest.mock('src/error', () => ({ return error }), })) +jest.mock('../k8s-operations', () => ({ + getSecretValues: jest.fn(), +})) describe('codeRepoUtils', () => { beforeEach(() => { jest.clearAllMocks() }) - describe('testPrivateRepoConnect', () => { - it('should connect to private repo with SSH key', async () => { - const mockGit: Partial = { - env: jest.fn(), - listRemote: jest.fn().mockResolvedValueOnce('success'), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - ;(uuidv4 as jest.Mock).mockReturnValue('test-uuid') - ;(pathExists as jest.Mock).mockResolvedValueOnce(true) - - const sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' - - const result = await testPrivateRepoConnect('git@github.com:user/repo.git', sshKey) - - expect(writeFile).toHaveBeenCalledWith('/tmp/otomi/sshKey-test-uuid', `${sshKey}\n`, { mode: 0o600 }) - expect(chmod).toHaveBeenCalledWith('/tmp/otomi/sshKey-test-uuid', 0o600) - expect(mockGit.env).toHaveBeenCalledWith( - 'GIT_SSH_COMMAND', - 'ssh -i /tmp/otomi/sshKey-test-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', - ) - expect(mockGit.listRemote).toHaveBeenCalledWith(['git@github.com:user/repo.git']) - expect(result).toEqual({ status: 'success' }) - expect(unlink).toHaveBeenCalledWith('/tmp/otomi/sshKey-test-uuid') - }) - - it('should connect to private repo with HTTPS authentication', async () => { - const mockGit: Partial = { - listRemote: jest.fn().mockResolvedValueOnce('success'), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - - const result = await testPrivateRepoConnect('https://github.com/user/repo.git', undefined, 'username', 'token') - - expect(mockGit.listRemote).toHaveBeenCalledWith(['https://username:token@github.com/user/repo.git']) - expect(result).toEqual({ status: 'success' }) - }) - - it('should fail to connect with invalid URL', async () => { - const result = await testPrivateRepoConnect('invalid-url') - expect(result).toEqual({ status: 'failed' }) - }) - }) - - describe('testPublicRepoConnect', () => { - it('should connect to public repo', async () => { - const mockGit: Partial = { - listRemote: jest.fn().mockResolvedValueOnce('success'), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - - const result = await testPublicRepoConnect('https://github.com/user/repo.git') - - expect(mockGit.listRemote).toHaveBeenCalledWith(['https://github.com/user/repo.git']) - expect(result).toEqual({ status: 'success' }) - }) - - it('should fail to connect to invalid public repo', async () => { - const mockGit: Partial = { - listRemote: jest.fn().mockRejectedValueOnce(new Error('failed')), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - - const result = await testPublicRepoConnect('https://github.com/user/repo.git') - - expect(mockGit.listRemote).toHaveBeenCalledWith(['https://github.com/user/repo.git']) - expect(result).toEqual({ status: 'failed' }) - }) - }) - describe('normalizeRepoUrl', () => { it('should normalize SSH URL', () => { const result = normalizeRepoUrl('git@github.com:user/repo.git', true, true) @@ -189,10 +117,6 @@ describe('codeRepoUtils', () => { }) describe('extractRepositoryRefs', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - it('should extract branches and tags correctly', async () => { const mockGit: Partial = { listRemote: jest.fn().mockResolvedValueOnce(` @@ -202,9 +126,8 @@ describe('codeRepoUtils', () => { mnop3456 refs/tags/v1.1.0 `), } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - const result = await extractRepositoryRefs('https://github.com/user/repo.git') + const result = await extractRepositoryRefs('https://github.com/user/repo.git', mockGit as SimpleGit) expect(result).toEqual(['main', 'develop', 'v1.0.0', 'v1.1.0']) expect(mockGit.listRemote).toHaveBeenCalledWith(['--refs', 'https://github.com/user/repo.git']) @@ -214,9 +137,8 @@ describe('codeRepoUtils', () => { const mockGit: Partial = { listRemote: jest.fn().mockResolvedValueOnce(''), } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - const result = await extractRepositoryRefs('https://github.com/user/repo.git') + const result = await extractRepositoryRefs('https://github.com/user/repo.git', mockGit as SimpleGit) expect(result).toEqual([]) }) @@ -229,22 +151,20 @@ describe('codeRepoUtils', () => { efgh5678 refs/tags/v1.0.0 `), } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - const result = await extractRepositoryRefs('https://github.com/user/repo.git') + const result = await extractRepositoryRefs('https://github.com/user/repo.git', mockGit as SimpleGit) expect(result).toEqual(['main', 'v1.0.0']) }) - it('should return empty array on listRemote error', async () => { + it('should propagate errors from listRemote', async () => { const mockGit: Partial = { listRemote: jest.fn().mockRejectedValueOnce(new Error('Network error')), } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - const result = await extractRepositoryRefs('https://github.com/user/repo.git') - - expect(result).toEqual([]) + await expect(extractRepositoryRefs('https://github.com/user/repo.git', mockGit as SimpleGit)).rejects.toThrow( + 'Network error', + ) }) }) @@ -273,21 +193,22 @@ whitespaces }) describe('setupGitAuthentication', () => { - it('should setup SSH authentication with private key', async () => { + it('should setup SSH authentication with private key from secret', async () => { const sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' const repoUrl = 'git@github.com:user/repo.git' + const teamId = 'team1' + const secretName = 'my-ssh-secret' - const mockGit: Partial = { - env: jest.fn(), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + const mockGitInstance = { env: jest.fn(), listRemote: jest.fn() } + mockGitInstance.env.mockReturnValue(mockGitInstance) + ;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance) ;(uuidv4 as jest.Mock).mockReturnValue('test-uuid') ;(writeFile as jest.Mock).mockResolvedValue(undefined) - ;(chmod as jest.Mock).mockResolvedValue(undefined) + ;(getSecretValues as jest.Mock).mockResolvedValue({ 'ssh-privatekey': sshKey }) - const result = await setupGitAuthentication(repoUrl, sshKey) + const result = await getAuthenticatedGitClient(repoUrl, teamId, undefined, undefined, secretName) - expect(mockGit.env).toHaveBeenCalledWith( + expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', 'ssh -i /tmp/otomi/sshKey-test-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', ) @@ -295,120 +216,56 @@ whitespaces expect(result.keyPath).toBe('/tmp/otomi/sshKey-test-uuid') }) - it('should setup HTTPS authentication with username and token', async () => { + it('should setup HTTPS authentication with credentials from secret', async () => { const repoUrl = 'https://github.com/user/repo.git' + const teamId = 'team1' + const secretName = 'my-https-secret' const username = 'testuser' const accessToken = 'test-token' - const result = await setupGitAuthentication(repoUrl, undefined, username, accessToken) - - expect(result.url).toBe( - `https://${encodeURIComponent(username)}:${encodeURIComponent(accessToken)}@github.com/user/repo.git`, - ) - }) - - it('should throw error for missing HTTPS credentials', async () => { - const repoUrl = 'https://github.com/user/repo.git' - - await expect(setupGitAuthentication(repoUrl)).rejects.toThrow( - 'Username and access token are required for HTTPS authentication', - ) - }) - - it('should throw error for invalid repository URL', async () => { - const repoUrl = 'invalid-url' + const mockGitInstance = { env: jest.fn() } + mockGitInstance.env.mockReturnValue(mockGitInstance) + ;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance) + ;(getSecretValues as jest.Mock).mockResolvedValue({ username, password: accessToken }) - await expect(setupGitAuthentication(repoUrl)).rejects.toThrow('Invalid repository URL format') - }) - }) + const result = await getAuthenticatedGitClient(repoUrl, teamId, undefined, undefined, secretName) - describe('getPrivateRepoBranches', () => { - it('should get branches from private repo using SSH', async () => { - const repoUrl = 'git@github.com:user/repo.git' - const sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' - - const mockRefs = ['main', 'develop', 'feature/test'] - const mockGit: Partial = { - listRemote: jest.fn().mockResolvedValueOnce(` - abcd1234\trefs/heads/main - efgh5678\trefs/heads/develop - ijkl9012\trefs/heads/feature/test - `), - } - - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - ;(uuidv4 as jest.Mock).mockReturnValue('test-uuid') - ;(pathExists as jest.Mock).mockResolvedValue(true) - - const mockSetupGitAuthentication = { - url: repoUrl, - sshKey, - git: mockGit as SimpleGit, - keyPath: '/mock/path/test-uuid', - } - jest.spyOn(codeRepoUtils, 'setupGitAuthentication').mockResolvedValueOnce(mockSetupGitAuthentication) - const result = await getPrivateRepoBranches(repoUrl) - - expect(result).toEqual(mockRefs) + expect(result.url).toBe(`https://${username}:${accessToken}@github.com/user/repo.git`) }) - it('should return empty array for failed private repo connection', async () => { - const repoUrl = 'git@github.com:user/repo.git' - const sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' - - ;(simpleGit as jest.Mock).mockImplementation(() => ({ - listRemote: jest.fn().mockRejectedValue(new Error('Connection failed')), - })) - - const result = await getPrivateRepoBranches(repoUrl, sshKey) - - expect(result).toEqual([]) - }) - }) - - describe('getPublicRepoBranches', () => { - it('should retrieve branches from a public repository', async () => { + it('should return unauthenticated URL when no secret provided for public HTTPS repo', async () => { const repoUrl = 'https://github.com/user/repo.git' + const teamId = 'team1' - const mockGit: Partial = { - listRemote: jest.fn().mockResolvedValueOnce(` - abcd1234 refs/heads/main - efgh5678 refs/heads/develop - ijkl9012 refs/tags/v1.0.0 - `), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + const mockGitInstance = { env: jest.fn() } + mockGitInstance.env.mockReturnValue(mockGitInstance) + ;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance) - const result = await getPublicRepoBranches(repoUrl) + const result = await getAuthenticatedGitClient(repoUrl, teamId) - expect(result).toEqual(['main', 'develop', 'v1.0.0']) - expect(mockGit.listRemote).toHaveBeenCalledWith(['--refs', repoUrl]) + expect(result.url).toBe('https://github.com/user/repo.git') }) - it('should return empty array when retrieving refs fails', async () => { - const repoUrl = 'https://github.com/user/repo.git' - - const mockGit: Partial = { - listRemote: jest.fn().mockRejectedValueOnce(new Error('Network error')), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + it('should throw error for SSH repo without secret', async () => { + const repoUrl = 'git@github.com:user/repo.git' + const teamId = 'team1' - const result = await getPublicRepoBranches(repoUrl) + const mockGitInstance = { env: jest.fn() } + mockGitInstance.env.mockReturnValue(mockGitInstance) + ;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance) - expect(result).toEqual([]) + await expect(getAuthenticatedGitClient(repoUrl, teamId)).rejects.toThrow('SSH requires a secret with private key') }) - it('should handle an empty repository with no refs', async () => { - const repoUrl = 'https://github.com/user/repo.git' - - const mockGit: Partial = { - listRemote: jest.fn().mockResolvedValueOnce(''), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + it('should throw error for invalid repository URL', async () => { + const repoUrl = 'invalid-url' + const teamId = 'team1' - const result = await getPublicRepoBranches(repoUrl) + const mockGitInstance = { env: jest.fn() } + mockGitInstance.env.mockReturnValue(mockGitInstance) + ;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance) - expect(result).toEqual([]) + await expect(getAuthenticatedGitClient(repoUrl, teamId)).rejects.toThrow('Invalid repository URL format') }) }) }) diff --git a/src/utils/codeRepoUtils.ts b/src/utils/codeRepoUtils.ts index c31db102..f0d7a053 100644 --- a/src/utils/codeRepoUtils.ts +++ b/src/utils/codeRepoUtils.ts @@ -1,10 +1,12 @@ /* eslint-disable prefer-destructuring */ import axios from 'axios' -import { pathExists, unlink } from 'fs-extra' -import { chmod, writeFile } from 'fs/promises' +import { writeFile } from 'fs/promises' import simpleGit, { SimpleGit } from 'simple-git' import { OtomiError } from 'src/error' import { v4 as uuidv4 } from 'uuid' +import { getAuthenticatedUrl } from '../git/connect' +import { getSecretValues } from '../k8s-operations' +import { APL_SECRETS_NAMESPACE, GITEA_SECRETS_NAME } from '../constants' const axiosInstance = (adminUsername, adminPassword, domainSuffix) => axios.create({ @@ -106,117 +108,119 @@ export function normalizeSSHKey(sshPrivateKey) { return `-----BEGIN OPENSSH PRIVATE KEY-----\n${basePrivateKey}\n-----END OPENSSH PRIVATE KEY-----` } -export async function setupGitAuthentication( - repoUrl: string, - sshPrivateKey?: string, - username?: string, - accessToken?: string, -): Promise<{ git: SimpleGit; url: string; keyPath?: string }> { - let keyPath: string | undefined - const git: SimpleGit = simpleGit() - let url = repoUrl - - if (url.startsWith('git@')) { - const normalizedKey: string = sshPrivateKey ? normalizeSSHKey(sshPrivateKey) : '' - if (normalizedKey) { - const keyId = uuidv4() as string - keyPath = `/tmp/otomi/sshKey-${keyId}` - await writeFile(keyPath, `${normalizedKey}\n`, { mode: 0o600 }) - await chmod(keyPath, 0o600) - const GIT_SSH_COMMAND = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null` - git.env('GIT_SSH_COMMAND', GIT_SSH_COMMAND) - } - } else if (url.startsWith('https://')) { - if (!username || !accessToken) throw new Error('Username and access token are required for HTTPS authentication') - url = repoUrl.replace('https://', `https://${encodeURIComponent(username)}:${encodeURIComponent(accessToken)}@`) - } else throw new Error('Invalid repository URL format. Must be SSH or HTTPS.') +export function getInternalGiteaUrl(domainSuffix: string | undefined): string { + if (!domainSuffix) return '' + return `https://gitea.${domainSuffix}` +} - return { git, url, keyPath } +export async function getGiteaAuth(appValues: Record): Promise< + | { + username: string + password: string + } + | undefined +> { + if (!appValues?.enabled) { + return undefined + } + const giteaSecrets = await getSecretValues(GITEA_SECRETS_NAME, APL_SECRETS_NAMESPACE) + if (!giteaSecrets?.adminPassword) { + return undefined + } + return { + username: (appValues?.adminUsername as string) || 'otomi-admin', + password: giteaSecrets?.adminPassword, + } } -export async function testPrivateRepoConnect( +export async function getAuthenticatedGitClient( repoUrl: string, - sshPrivateKey?: string, - username?: string, - accessToken?: string, -) { - let keyPath: string | undefined - try { - const authResult = await setupGitAuthentication(repoUrl, sshPrivateKey, username, accessToken) - keyPath = authResult.keyPath - await authResult.git.listRemote([authResult.url]) - return { status: 'success' } - } catch (error) { - return { status: 'failed' } - } finally { - if (repoUrl.startsWith('git@') && keyPath && (await pathExists(keyPath))) await unlink(keyPath) + teamId: string, + domainSuffix?: string, + giteaAppValues?: Record, + secretName?: string, +): Promise<{ git: SimpleGit; url: string; keyPath?: string }> { + const git: SimpleGit = simpleGit().env('GIT_TERMINAL_PROMPT', '0') + + const isPrivate = !!secretName + const isSSH = repoUrl.startsWith('git@') + const isHTTPS = repoUrl.startsWith('https://') + if (!isSSH && !isHTTPS) { + throw new Error('Invalid repository URL format. Must be SSH or HTTPS.') } -} + const normalizedUrl = normalizeRepoUrl(repoUrl, isPrivate, isSSH) + const giteaInternalUrl = getInternalGiteaUrl(domainSuffix) -export async function testPublicRepoConnect(repoUrl: string) { - const git = simpleGit() - try { - await git.listRemote([repoUrl]) - return { status: 'success' } - } catch (error) { - return { status: 'failed' } + if (!normalizedUrl) { + throw new Error('Invalid URL provided') + } + if (isSSH && !secretName) { + throw new Error('SSH requires a secret with private key') } -} -export async function extractRepositoryRefs(repoUrl: string, git: SimpleGit = simpleGit()): Promise { - try { - let formattedRepoUrl = repoUrl - if (repoUrl.startsWith('https://gitea')) { - git.env({ - GIT_TERMINAL_PROMPT: '0', - GIT_SSL_NO_VERIFY: 'true', + if (secretName) { + // Prefer to use provided credentials, even if internal Git repo is used + const secret = await getSecretValues(secretName, `team-${teamId}`) + if (!secret) { + throw new Error(`Secret ${secretName} not found in namespace team-${teamId}`) + } + if (isSSH) { + const sshKey = secret?.['ssh-privatekey'] + if (!sshKey) { + throw new Error(`No value found for "ssh-privatekey" in secret ${secretName}`) + } + const normalizedKey = normalizeSSHKey(sshKey) + if (normalizedKey) { + const keyId = uuidv4() + const keyPath = `/tmp/otomi/sshKey-${keyId}` + await writeFile(keyPath, `${normalizedKey}\n`, { mode: 0o600 }) + const GIT_SSH_COMMAND = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null` + git.env('GIT_SSH_COMMAND', GIT_SSH_COMMAND) + return { git, url: normalizedUrl, keyPath } + } else { + throw new Error( + `Value found in "ssh-privatekey" in secret ${secretName} found, but invalid or not a private key`, + ) + } + } else { + if (!secret?.password) { + throw new Error( + `Access token (or password) is required for HTTPS authentication in value "password" in secret ${secretName}`, + ) + } + const authUrl = getAuthenticatedUrl({ + repoUrl: normalizedUrl, + username: secret.username, + password: secret.password, }) - // FIXME: When values is not on Gitea, this is broken - const username = process.env.GIT_USER as string - const accessToken = process.env.GIT_PASSWORD as string - formattedRepoUrl = repoUrl.replace( - 'https://', - `https://${encodeURIComponent(username)}:${encodeURIComponent(accessToken)}@`, - ) + return { git, url: authUrl } } - - const rawData = await git.listRemote(['--refs', formattedRepoUrl]) - const branches: string[] = [] - const tags: string[] = [] - - rawData.split('\n').forEach((line) => { - const parts = line.split('\t') - if (parts.length !== 2) return - const ref = parts[1] - if (ref.startsWith('refs/heads/')) branches.push(ref.replace('refs/heads/', '')) - else if (ref.startsWith('refs/tags/')) tags.push(ref.replace('refs/tags/', '')) - }) - - return [...branches, ...tags] - } catch (error) { - return [] + } else if (giteaInternalUrl && giteaAppValues && normalizedUrl === giteaInternalUrl) { + // For internal Gitea, use internal credentials if nothing else was provided + const giteaAuth = await getGiteaAuth(giteaAppValues) + if (!giteaAuth) { + throw new Error('Internal Gitea URL provided, but app not configured or no credentials found') + } + const authUrl = getAuthenticatedUrl({ repoUrl: normalizedUrl, ...giteaAuth }) + return { git, url: authUrl } + } else { + // Default to unauthenticated HTTPS + return { git, url: normalizedUrl } } } -export async function getPrivateRepoBranches( - repoUrl: string, - sshPrivateKey?: string, - username?: string, - accessToken?: string, -) { - let keyPath: string | undefined - try { - const authResult = await setupGitAuthentication(repoUrl, sshPrivateKey, username, accessToken) - keyPath = authResult.keyPath - return await extractRepositoryRefs(authResult.url, authResult.git) - } catch (error) { - return [] - } finally { - if (repoUrl.startsWith('git@') && keyPath && (await pathExists(keyPath))) await unlink(keyPath) - } -} +export async function extractRepositoryRefs(url: string, git: SimpleGit): Promise { + const rawData = await git.listRemote(['--refs', url]) + const branches: string[] = [] + const tags: string[] = [] + + rawData.split('\n').forEach((line) => { + const parts = line.split('\t') + if (parts.length !== 2) return + const ref = parts[1] + if (ref.startsWith('refs/heads/')) branches.push(ref.replace('refs/heads/', '')) + else if (ref.startsWith('refs/tags/')) tags.push(ref.replace('refs/tags/', '')) + }) -export async function getPublicRepoBranches(repoUrl: string) { - const branches = await extractRepositoryRefs(repoUrl) - return branches + return [...branches, ...tags] }