Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/git/connect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Debug from 'debug'
import { GitConfig } from '../otomi-models'
import { Git } from '../git'

const debug = Debug('otomi:git-connect')
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/testrepoconnect.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ TestRepoConnect:
- unknown
- success
- failed
message:
type: string
type: object
184 changes: 184 additions & 0 deletions src/otomi-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {})
Expand Down Expand Up @@ -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)
})
})
105 changes: 45 additions & 60 deletions src/otomi-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment on lines 1513 to +1514
return []
}
}

async getTestRepoConnect(url: string, teamId: string, secretName: string): Promise<TestRepoConnect> {
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<TestRepoConnect> {
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 }
Comment on lines 1542 to +1543
}
}

Expand Down
Loading
Loading