diff --git a/src/openapi/definitions.yaml b/src/openapi/definitions.yaml index 98d77d7a..afc75a8e 100644 --- a/src/openapi/definitions.yaml +++ b/src/openapi/definitions.yaml @@ -939,8 +939,8 @@ replicas: title: Number of replicas default: 1 repoUrl: - description: Path to a remote git repo without protocol. Will use https to access. - pattern: '^(?:(?:https://)?[A-Za-z0-9.-]+\.[A-Za-z]{2,}|git@[A-Za-z0-9.-]+\.[A-Za-z]{2,}:)/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+(?:\.git)?$' + description: Path to a remote git repo. Will use https to access when protocol is omitted. + pattern: '^(?:https://)?[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:/[A-Za-z0-9_.-]+){2,}(?:\.git)?/?$' type: string x-message: a valid git repo URL example: github.com/example/repo diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 31818660..86a73aec 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -1135,14 +1135,35 @@ describe('APL code repositories tests', () => { expect(otomiStack.doDeleteDeployment).toHaveBeenCalled() }) - test('should create an external public code repository', async () => { + test.each([ + { + input: 'github.com/github-samples/pets-workshop', + expected: 'https://github.com/github-samples/pets-workshop.git', + }, + { + input: 'https://github.com/github-samples/pets-workshop', + expected: 'https://github.com/github-samples/pets-workshop.git', + }, + { + input: 'https://github.com/github-samples/pets-workshop/', + expected: 'https://github.com/github-samples/pets-workshop.git', + }, + { + input: 'https://github.com/github-samples/pets-workshop.git', + expected: 'https://github.com/github-samples/pets-workshop.git', + }, + { + input: 'https://github.com/github-samples/pets-workshop.git/', + expected: 'https://github.com/github-samples/pets-workshop.git', + }, + ])('should create an external public code repository and normalize url: $input', async ({ input, expected }) => { const codeRepo = await otomiStack.createAplCodeRepo('demo', { metadata: { name: 'ext-pub-1', }, spec: { gitService: 'github', - repositoryUrl: 'https://github.test.com', + repositoryUrl: input, }, kind: 'AplTeamCodeRepo', }) @@ -1150,19 +1171,64 @@ describe('APL code repositories tests', () => { expect(codeRepo.metadata.name).toBe('ext-pub-1') expect(codeRepo.metadata.labels['apl.io/teamId']).toBe('demo') expect(codeRepo.spec.gitService).toBe('github') - expect(codeRepo.spec.repositoryUrl).toBe('https://github.test.com') + expect(codeRepo.spec.repositoryUrl).toBe(expected) expect(codeRepo.spec.private).toBeFalsy() expect(codeRepo.spec.secret).toBeUndefined() const stored = otomiStack.fileStore.getTeamResource('AplTeamCodeRepo', 'demo', 'ext-pub-1') + expect(stored).toBeDefined() expect(stored?.spec.gitService).toBe('github') - expect(stored?.spec.repositoryUrl).toBe('https://github.test.com') + expect(stored?.spec.repositoryUrl).toBe(expected) expect(stored?.spec.secret).toBeUndefined() expect(otomiStack.doDeployment).toHaveBeenCalled() }) + test.each(['ssh://git@github.com/github-samples/pets-workshop.git'])( + 'should reject unsupported ssh repository url: %s', + async (repositoryUrl) => { + await expect( + otomiStack.createAplCodeRepo('demo', { + metadata: { + name: 'ext-pub-1', + }, + spec: { + gitService: 'github', + repositoryUrl, + }, + kind: 'AplTeamCodeRepo', + }), + ).rejects.toThrow() + }, + ) + + test('should reject duplicate external repository url after normalization', async () => { + await otomiStack.createAplCodeRepo('demo', { + metadata: { + name: 'ext-pub-1', + }, + spec: { + gitService: 'github', + repositoryUrl: 'https://github.com/github-samples/pets-workshop/', + }, + kind: 'AplTeamCodeRepo', + }) + + await expect( + otomiStack.createAplCodeRepo('demo', { + metadata: { + name: 'ext-pub-2', + }, + spec: { + gitService: 'github', + repositoryUrl: 'https://github.com/github-samples/pets-workshop', + }, + kind: 'AplTeamCodeRepo', + }), + ).rejects.toThrow('Code repository URL already exists') + }) + test('should get an existing external public code repository', () => { const extPubRepo: AplCodeRepoResponse = { kind: 'AplTeamCodeRepo', @@ -1256,7 +1322,7 @@ describe('APL code repositories tests', () => { }, spec: { gitService: 'github', - repositoryUrl: 'https://github.test.com', + repositoryUrl: 'https://github.com/github-samples/pets-workshop/', private: true, secret: 'test', }, @@ -1266,12 +1332,15 @@ describe('APL code repositories tests', () => { expect(codeRepo.metadata.name).toBe('ext-priv-1') expect(codeRepo.metadata.labels['apl.io/teamId']).toBe('demo') expect(codeRepo.spec.gitService).toBe('github') - expect(codeRepo.spec.repositoryUrl).toBe('https://github.test.com') + expect(codeRepo.spec.repositoryUrl).toBe('https://github.com/github-samples/pets-workshop.git') expect(codeRepo.spec.private).toBe(true) expect(codeRepo.spec.secret).toBe('test') const stored = otomiStack.fileStore.getTeamResource('AplTeamCodeRepo', 'demo', 'ext-priv-1') + expect(stored).toBeDefined() + expect(stored?.spec.gitService).toBe('github') + expect(stored?.spec.repositoryUrl).toBe('https://github.com/github-samples/pets-workshop.git') expect(stored?.spec.private).toBe(true) expect(stored?.spec.secret).toBe('test') diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index a8c4818e..7f2a6927 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1434,18 +1434,65 @@ export default class OtomiStack { } async createAplCodeRepo(teamId: string, data: AplCodeRepoRequest): Promise { - // Check if URL already exists const existingRepos = this.getTeamAplCodeRepos(teamId) - const allRepoUrls = existingRepos.map((repo) => repo.spec.repositoryUrl) || [] - if (allRepoUrls.includes(data.spec.repositoryUrl)) throw new AlreadyExists('Code repository URL already exists') - const allNames = existingRepos.map((repo) => repo.metadata.name) || [] - if (allNames.includes(data.metadata.name)) throw new AlreadyExists('Code repo name already exists') - if (!data.spec.private) unset(data.spec, 'secret') - if (data.spec.gitService === 'gitea') unset(data.spec, 'private') - const teamObject = toTeamObject(teamId, data) + const isExternalRepo = data.spec.gitService !== 'gitea' + + const repositoryUrl = isExternalRepo + ? normalizeRepoUrl( + data.spec.repositoryUrl, + data.spec.private ?? false, + false, // SSH is not allowed + ) + : data.spec.repositoryUrl.trim().replace(/\/$/, '') + + if (!repositoryUrl) { + throw new Error('Invalid repository URL') + } + + const normalizeForComparison = (repo: AplCodeRepoResponse) => { + if (repo.spec.gitService === 'gitea') { + return repo.spec.repositoryUrl.trim().replace(/\/$/, '') + } + + return normalizeRepoUrl(repo.spec.repositoryUrl, repo.spec.private ?? false, false) + } + + const allRepoUrls = existingRepos + .map(normalizeForComparison) + .filter((repoUrl): repoUrl is string => Boolean(repoUrl)) + + if (allRepoUrls.includes(repositoryUrl)) { + throw new AlreadyExists('Code repository URL already exists') + } + + const allNames = existingRepos.map((repo) => repo.metadata.name) + + if (allNames.includes(data.metadata.name)) { + throw new AlreadyExists('Code repo name already exists') + } + + const sanitizedData: AplCodeRepoRequest = { + ...data, + spec: { + ...data.spec, + repositoryUrl, + }, + } + + if (!sanitizedData.spec.private) { + unset(sanitizedData.spec, 'secret') + } + + if (sanitizedData.spec.gitService === 'gitea') { + unset(sanitizedData.spec, 'private') + } + + const teamObject = toTeamObject(teamId, sanitizedData) const aplRecord = await this.saveTeamConfigItem(teamObject) + await this.doDeployment(aplRecord) + return aplRecord.content as AplCodeRepoResponse }