Skip to content
Closed
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
19 changes: 16 additions & 3 deletions src/api-v2.authz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { initApp, loadSpec } from 'src/app'
import getToken from 'src/fixtures/jwt'
import OtomiStack from 'src/otomi-stack'
import request from 'supertest'
import { Git } from './git'
import { getSessionStack } from './middleware'
import * as getValuesSchemaModule from './utils'
import TestAgent from 'supertest/lib/agent'
import { FileStore } from './fileStore/file-store'
import { Git } from './git'
import { getSessionStack } from './middleware'
import { AplKind } from './otomi-models'
import * as getValuesSchemaModule from './utils'

const platformAdminToken = getToken(['platform-admin'])
const teamAdminToken = getToken(['team-admin', 'team-team1'])
Expand Down Expand Up @@ -1129,6 +1129,19 @@ describe('API V2 authz tests', () => {
test('platform admin can migrate git', async () => {
await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200)
})

test('returns 200 when push access probe fails with permission denied', async () => {
jest.spyOn(otomiStack.git, 'testRemoteConnection').mockResolvedValue(true)
jest.spyOn(otomiStack.git, 'probePushAccess').mockRejectedValue(new Error('permission denied'))

await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200)
})

test('returns 200 when remote connectivity check indicates repository not found', async () => {
jest.spyOn(otomiStack.git, 'testRemoteConnection').mockRejectedValue(new Error('repository not found'))

await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200)
})
})

describe('Team Admin', () => {
Expand Down
9 changes: 9 additions & 0 deletions src/api/v2/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise
}
}

// Validate push permission by creating and deleting a temporary branch on the new remote
try {
await req.otomi.git.probePushAccess(repoUrl, password, username)
} catch (e: unknown) {
const error = { message: `Error validating push access to new git remote`, statusCode: 400 }
res.json(error)
return
}

// Write config + commit locally → push to new remote (if empty) → push to current remote
await req.otomi.migrateGitSettings({ repoUrl, username, password, email, branch, remoteHasContent })

Expand Down
36 changes: 36 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,39 @@ describe('Git.pushToNewRemote', () => {
expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote'])
})
})

describe('Git.probePushAccess', () => {
beforeEach(() => jest.clearAllMocks())

it('creates and deletes temporary probe branch on migration-remote', async () => {
mockRemote.mockResolvedValue('')
mockPush.mockResolvedValue({})
const repo = makeRepo()

await repo.probePushAccess('https://example.com/repo.git', 'p', 'u')

expect(mockRemote).toHaveBeenCalledWith(
expect.arrayContaining(['add', 'migration-remote', expect.stringContaining('u')]),
)
expect(mockPush).toHaveBeenNthCalledWith(
1,
'migration-remote',
expect.stringMatching(/^HEAD:refs\/heads\/apl-migration-probe-/),
)
expect(mockPush).toHaveBeenNthCalledWith(
2,
'migration-remote',
expect.stringMatching(/^:refs\/heads\/apl-migration-probe-/),
)
expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote'])
})

it('removes migration-remote in finally even when probe push fails', async () => {
mockRemote.mockResolvedValue('')
mockPush.mockRejectedValue(new Error('permission denied'))
const repo = makeRepo()

await expect(repo.probePushAccess('https://example.com/repo.git', 'p', 'u')).rejects.toThrow('permission denied')
expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote'])
})
})
19 changes: 19 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { AxiosResponse } from 'axios'
import { randomUUID } from 'crypto'
import Debug from 'debug'
import diff from 'deep-diff'
import { rmSync } from 'fs'
Expand Down Expand Up @@ -352,6 +353,24 @@ export class Git {
}
}

async probePushAccess(url: string, password: string, user?: string): Promise<void> {
const authUrl = password ? getUrlAuth(url, user, password) : url
const probeBranch = `apl-migration-probe-${randomUUID()}`
const probeRef = `refs/heads/${probeBranch}`

try {
await this.git.remote(['add', 'migration-remote', authUrl!])
await this.git.push('migration-remote', `HEAD:${probeRef}`)
await this.git.push('migration-remote', `:${probeRef}`)
} finally {
try {
await this.git.remote(['remove', 'migration-remote'])
} catch (e) {
debug(`Could not remove migration-remote: ${getSanitizedErrorMessage(e)}`)
}
}
}

async createWorktree(worktreePath: string, branch: string = this.branch): Promise<void> {
debug(`Creating worktree at: ${worktreePath} from branch: ${branch}`)
await ensureDir(dirname(worktreePath), { mode: 0o744 })
Expand Down
Loading