diff --git a/src/api-v2.authz.test.ts b/src/api-v2.authz.test.ts index 88eb3bc4..a79f17f9 100644 --- a/src/api-v2.authz.test.ts +++ b/src/api-v2.authz.test.ts @@ -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']) @@ -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', () => { diff --git a/src/api/v2/git.ts b/src/api/v2/git.ts index 593c7a13..37dc6375 100644 --- a/src/api/v2/git.ts +++ b/src/api/v2/git.ts @@ -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 }) diff --git a/src/git.test.ts b/src/git.test.ts index f114bbce..0ca5554c 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -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']) + }) +}) diff --git a/src/git.ts b/src/git.ts index d708bcbc..f64d6f4b 100644 --- a/src/git.ts +++ b/src/git.ts @@ -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' @@ -352,6 +353,24 @@ export class Git { } } + async probePushAccess(url: string, password: string, user?: string): Promise { + 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 { debug(`Creating worktree at: ${worktreePath} from branch: ${branch}`) await ensureDir(dirname(worktreePath), { mode: 0o744 })