From bf5425b83fd3b62d9744e8d0c33965aa35bcbeec Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 28 Aug 2026 13:53:51 +0200 Subject: [PATCH 1/3] fix(server-nestjs): make GitLab CI variable provisioning idempotent Signed-off-by: William Phetsinorath Change-Id: If066b86e9ee93fb1f33c9eb2a44eac076a6a6964 --- .../gitlab/gitlab-client.service.spec.ts | 145 ++++++++---------- .../modules/gitlab/gitlab-client.service.ts | 44 ++++-- .../sonarqube/sonarqube.service.spec.ts | 12 +- .../modules/sonarqube/sonarqube.service.ts | 4 +- 4 files changed, 99 insertions(+), 106 deletions(-) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index 41889dc66f..eec750e348 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -464,81 +464,11 @@ describe('gitlab-client', () => { makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), ]) - await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: con - expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ - admin: true, - })) - }) - - it('should not disable existing auditor flag when enabling admin flag', async () => { - const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } - const gitlabUser = { - email: consoleUser.email, - username: 'auditor-admin', - name: 'Auditor Admin', - } - const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction - gitlabUsersAllMock.mockResolvedValue([ - makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), - ]) - - await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) - - expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ - auditor: true, - })) - }) - }) - - it('should create pipeline trigger token if not exists', async () => { - const projectSlug = 'project-1' - const repoId = 1 - const tokenDescription = 'mirroring-from-external-repo' - - const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction - gitlabProjectsAllMock.mockResolvedValue({ - data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], - paginationInfo: { next: null }, - }) - gitlabApi.Projects.edit.mockResolvedValue(makeProjectSchema({ id: repoId, name: 'mirror' })) - - const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction - gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ - data: [], - paginationInfo: makeOffsetPagination({ next: null }), - }) - - gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) - - const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) - - expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) - expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) - }) - }) +... [OUTPUT TRUNCATED - 3,224 chars omitted out of 53,152 total] ... - describe('group Members', () => { - it('should get group members', async () => { - const groupId = 1 - const group = makeGroupSchema({ id: groupId }) - const members = [makeMemberSchema({ id: 1, name: 'user' })] - const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction - gitlabGroupMembersAllMock.mockResolvedValue(members) - const result = await service.getGroupMembers(group) - expect(result).toEqual(members) - expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) - }) - - it('should add group member', async () => { - const groupId = 1 - const group = makeGroupSchema({ id: groupId }) - const userId = 2 - const accessLevel = 30 - gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) - - await service.addGroupMember(group, userId, accessLevel) expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) }) @@ -1044,53 +974,102 @@ describe('gitlab-client', () => { it('should create a missing group variable', async () => { gitlabApi.GroupVariables.show.mockRejectedValueOnce(makeGitbeakerRequestError({ description: '404 Group variable Not Found', status: 404 })) - await service.setGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' }) + await service.ensureGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' }) expect(gitlabApi.GroupVariables.create).toHaveBeenCalledWith(groupId, 'SONAR_TOKEN', 'secret', expect.objectContaining({ masked: true, variableType: 'env_var' })) expect(gitlabApi.GroupVariables.edit).not.toHaveBeenCalled() }) it('should update a group variable when value differs', async () => { + gitlabApi.GroupVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: { key: ['(SONAR_TOKEN) has already been taken'] }, status: 400 })) gitlabApi.GroupVariables.show.mockResolvedValueOnce({ key: 'SONAR_TOKEN', value: 'old', variable_type: 'env_var', masked: true, protected: false }) - await service.setGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'new', { masked: true, protected: false, variableType: 'env_var' }) + await service.ensureGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'new', { masked: true, protected: false, variableType: 'env_var' }) expect(gitlabApi.GroupVariables.edit).toHaveBeenCalledWith(groupId, 'SONAR_TOKEN', 'new', expect.objectContaining({ variableType: 'env_var' })) - expect(gitlabApi.GroupVariables.create).not.toHaveBeenCalled() }) it('should skip write when group variable matches', async () => { + gitlabApi.GroupVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: { key: ['(SONAR_TOKEN) has already been taken'] }, status: 400 })) gitlabApi.GroupVariables.show.mockResolvedValueOnce({ key: 'SONAR_TOKEN', value: 'secret', variable_type: 'env_var', masked: true, protected: false }) - await service.setGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' }) + await service.ensureGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' }) - expect(gitlabApi.GroupVariables.create).not.toHaveBeenCalled() + expect(gitlabApi.GroupVariables.create).toHaveBeenCalledTimes(1) expect(gitlabApi.GroupVariables.edit).not.toHaveBeenCalled() }) it('should create a missing repo variable', async () => { gitlabApi.ProjectVariables.show.mockRejectedValueOnce(makeGitbeakerRequestError({ description: '404 Project variable Not Found', status: 404 })) - await service.setGitlabRepoVariable(repoId, 'PROJECT_KEY', 'key', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' }) + await service.ensureGitlabRepoVariable(repoId, 'PROJECT_KEY', 'key', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' }) expect(gitlabApi.ProjectVariables.create).toHaveBeenCalledWith(repoId, 'PROJECT_KEY', 'key', expect.objectContaining({ variableType: 'env_var', environmentScope: '*' })) }) it('should skip write when repo variable matches', async () => { + gitlabApi.ProjectVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: { key: ['(PROJECT_KEY) has already been taken'] }, status: 400 })) + gitlabApi.ProjectVariables.show.mockResolvedValueOnce({ key: 'PROJECT_KEY', value: 'key', variable_type: 'env_var', masked: false, protected: false, environment_scope: '*' }) + + await service.ensureGitlabRepoVariable(repoId, 'PROJECT_KEY', 'key', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' }) + + expect(gitlabApi.ProjectVariables.create).toHaveBeenCalledTimes(1) + expect(gitlabApi.ProjectVariables.edit).not.toHaveBeenCalled() + }) + + it('should tolerate a create collision for a repo variable and no-op when it already matches (race)', async () => { + // create() is rejected because the variable already exists; the reload finds it + // and the method must not throw (idempotent reconcile, no-op since it matches). + gitlabApi.ProjectVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: { key: ['(PROJECT_KEY) has already been taken'] }, status: 400 })) gitlabApi.ProjectVariables.show.mockResolvedValueOnce({ key: 'PROJECT_KEY', value: 'key', variable_type: 'env_var', masked: false, protected: false, environment_scope: '*' }) - await service.setGitlabRepoVariable(repoId, 'PROJECT_KEY', 'key', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' }) + await service.ensureGitlabRepoVariable(repoId, 'PROJECT_KEY', 'key', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' }) - expect(gitlabApi.ProjectVariables.create).not.toHaveBeenCalled() + expect(gitlabApi.ProjectVariables.create).toHaveBeenCalledTimes(1) + expect(gitlabApi.ProjectVariables.edit).not.toHaveBeenCalled() }) + it('should tolerate a create collision for a repo variable and edit when the existing value differs (race)', async () => { + gitlabApi.ProjectVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: { key: ['(PROJECT_KEY) has already been taken'] }, status: 400 })) + gitlabApi.ProjectVariables.show.mockResolvedValueOnce({ key: 'PROJECT_KEY', value: 'old', variable_type: 'env_var', masked: false, protected: false, environment_scope: '*' }) + + await service.ensureGitlabRepoVariable(repoId, 'PROJECT_KEY', 'new', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' }) + + expect(gitlabApi.ProjectVariables.create).toHaveBeenCalledTimes(1) + expect(gitlabApi.ProjectVariables.edit).toHaveBeenCalledWith(repoId, 'PROJECT_KEY', 'new', expect.objectContaining({ variableType: 'env_var', environmentScope: '*' })) + }) + + it('should propagate a non-collision create error for a repo variable', async () => { + gitlabApi.ProjectVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: 'Internal Server Error', status: 500 })) + + await expect(service.ensureGitlabRepoVariable(repoId, 'PROJECT_KEY', 'key', { masked: false, protected: false, variableType: 'env_var', environmentScope: '*' })) + .rejects.toThrow(GitbeakerRequestError) + }) + + it('should tolerate a create collision for a group variable and no-op when it already matches (race)', async () => { + gitlabApi.GroupVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: { key: ['(SONAR_TOKEN) has already been taken'] }, status: 400 })) + gitlabApi.GroupVariables.show.mockResolvedValueOnce({ key: 'SONAR_TOKEN', value: 'secret', variable_type: 'env_var', masked: true, protected: false }) + + await service.ensureGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' }) + + expect(gitlabApi.GroupVariables.create).toHaveBeenCalledTimes(1) + expect(gitlabApi.GroupVariables.edit).not.toHaveBeenCalled() + }) + + it('should propagate a non-collision create error for a group variable', async () => { + gitlabApi.GroupVariables.create.mockRejectedValueOnce(makeGitbeakerRequestError({ description: 'Internal Server Error', status: 500 })) + + await expect(service.ensureGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' })) + .rejects.toThrow(GitbeakerRequestError) + }) it('should tolerate a non-string cause.description (regression: .includes is not a function)', async () => { const error = new GitbeakerRequestError('boom', { cause: { description: 404 as unknown as string, request: new Request('https://gitlab.internal.example/api'), response: new Response(null, { status: 404 }) } }) - gitlabApi.GroupVariables.show.mockRejectedValueOnce(error) + gitlabApi.GroupVariables.create.mockRejectedValueOnce(error) - await service.setGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' }) + await expect(service.ensureGitlabGroupVariable(groupId, 'SONAR_TOKEN', 'secret', { masked: true, protected: false, variableType: 'env_var' })) + .rejects.toThrow(GitbeakerRequestError) expect(gitlabApi.GroupVariables.create).toHaveBeenCalledWith(groupId, 'SONAR_TOKEN', 'secret', expect.objectContaining({ masked: true, variableType: 'env_var' })) }) }) -}) +}) \ No newline at end of file diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 57305c057d..98e08162ed 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -495,25 +495,32 @@ export class GitlabClientService { } // CI Variables - public async setGitlabGroupVariable( + public async ensureGitlabGroupVariable( groupId: number, key: string, value: string, options: { masked: boolean, protected: boolean, variableType: VariableType }, ): Promise { - const current = await this.client.GroupVariables.show(groupId, key).catch((error) => { - if (isGitbeakerNotFound(error)) return undefined - throw error - }) - if (!current) { + try { await this.client.GroupVariables.create(groupId, key, value, { variableType: options.variableType, masked: options.masked, protected: options.protected, }) return + } catch (error) { + // GitLab enforces (key, environment_scope) uniqueness; a concurrent or prior + // reconcile may have created the variable between our read and this create. + // Tolerate it like every other ensure* writer: re-read once and reconcile + // instead of failing the whole sync. + if (!hasGitbeakerCause(error, 'has already been taken')) throw error + this.logger.warn(`GitLab group variable already exists (race); reloading (groupId=${groupId}, key=${key})`) } - if (current.masked === options.masked + const current = await this.client.GroupVariables.show(groupId, key).catch((error) => { + if (isGitbeakerNotFound(error)) return undefined + throw error + }) + if (current && current.masked === options.masked && current.value === value && current.protected === options.protected && current.variable_type === options.variableType) { @@ -527,17 +534,13 @@ export class GitlabClientService { }) } - public async setGitlabRepoVariable( + public async ensureGitlabRepoVariable( repoId: number, key: string, value: string, options: { masked: boolean, protected: boolean, variableType: VariableType, environmentScope: string }, ): Promise { - const current = await this.client.ProjectVariables.show(repoId, key, { filter: { environment_scope: options.environmentScope } }).catch((error) => { - if (isGitbeakerNotFound(error)) return undefined - throw error - }) - if (!current) { + try { await this.client.ProjectVariables.create(repoId, key, value, { variableType: options.variableType, masked: options.masked, @@ -545,8 +548,19 @@ export class GitlabClientService { environmentScope: options.environmentScope, }) return + } catch (error) { + // GitLab enforces (key, environment_scope) uniqueness; a concurrent or prior + // reconcile may have created the variable between our read and this create. + // Tolerate it like every other ensure* writer: re-read once and reconcile + // instead of failing the whole sync. + if (!hasGitbeakerCause(error, 'has already been taken')) throw error + this.logger.warn(`GitLab repo variable already exists (race); reloading (repoId=${repoId}, key=${key})`) } - if (current.masked === options.masked + const current = await this.client.ProjectVariables.show(repoId, key, { filter: { environment_scope: options.environmentScope } }).catch((error) => { + if (isGitbeakerNotFound(error)) return undefined + throw error + }) + if (current && current.masked === options.masked && current.value === value && current.protected === options.protected && current.variable_type === options.variableType) { @@ -744,4 +758,4 @@ export class GitlabClientService { this.logger.debug(`Pagination done (total=${total})`) } -} +} \ No newline at end of file diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index ae54b6d0c5..ce79407a01 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -60,8 +60,8 @@ describe('sonarqubeService', () => { config = mockDeep>({ }) gitlab = mockDeep({ - setGitlabGroupVariable: vi.fn().mockResolvedValue(undefined), - setGitlabRepoVariable: vi.fn().mockResolvedValue(undefined), + ensureGitlabGroupVariable: vi.fn().mockResolvedValue(undefined), + ensureGitlabRepoVariable: vi.fn().mockResolvedValue(undefined), getOrCreateProjectGroup: vi.fn().mockResolvedValue({ id: 42, full_path: 'root', name: 'root' }), getOrCreateProjectGroupRepo: vi.fn().mockResolvedValue({ id: 99 }), }) @@ -185,10 +185,10 @@ describe('sonarqubeService', () => { await service.handleUpsert(project) const key = generateProjectKey(project.slug, 'repo') - expect(gitlab.setGitlabRepoVariable).toHaveBeenCalledWith(repoId, 'PROJECT_KEY', key, expect.objectContaining({ masked: false, variableType: 'env_var', environmentScope: '*' })) - expect(gitlab.setGitlabRepoVariable).toHaveBeenCalledWith(repoId, 'PROJECT_NAME', `${project.slug}-repo`, expect.objectContaining({ masked: false, variableType: 'env_var', environmentScope: '*' })) - expect(gitlab.setGitlabRepoVariable).toHaveBeenCalledWith(repoId, 'SONAR_PROJECT_PROPERTIES', expect.stringContaining(`sonar.projectKey=${key}`), expect.objectContaining({ masked: false, variableType: 'file', environmentScope: '*' })) - expect(gitlab.setGitlabGroupVariable).toHaveBeenCalledWith(groupId, 'SONAR_TOKEN', 'tok', expect.objectContaining({ masked: true, variableType: 'env_var' })) + expect(gitlab.ensureGitlabRepoVariable).toHaveBeenCalledWith(repoId, 'PROJECT_KEY', key, expect.objectContaining({ masked: false, variableType: 'env_var', environmentScope: '*' })) + expect(gitlab.ensureGitlabRepoVariable).toHaveBeenCalledWith(repoId, 'PROJECT_NAME', `${project.slug}-repo`, expect.objectContaining({ masked: false, variableType: 'env_var', environmentScope: '*' })) + expect(gitlab.ensureGitlabRepoVariable).toHaveBeenCalledWith(repoId, 'SONAR_PROJECT_PROPERTIES', expect.stringContaining(`sonar.projectKey=${key}`), expect.objectContaining({ masked: false, variableType: 'file', environmentScope: '*' })) + expect(gitlab.ensureGitlabGroupVariable).toHaveBeenCalledWith(groupId, 'SONAR_TOKEN', 'tok', expect.objectContaining({ masked: true, variableType: 'env_var' })) }) it('should not recreate user or write vault when both user and secret exist', async () => { diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index 1f5925c652..561afbd7be 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -269,7 +269,7 @@ export class SonarqubeService implements OnModuleInit { // SONAR_TOKEN is shared across every repository of the project; expose it once at the group level. if (sonarSecret?.data?.SONAR_TOKEN) { - await this.gitlab.setGitlabGroupVariable( + await this.gitlab.ensureGitlabGroupVariable( gitlabGroup.id, 'SONAR_TOKEN', sonarSecret.data.SONAR_TOKEN, @@ -322,7 +322,7 @@ export class SonarqubeService implements OnModuleInit { variables.push({ key: 'SONAR_TOKEN', value: sonarSecret.data.SONAR_TOKEN, variableType: 'env_var', masked: true }) } await Promise.all(variables.map(async (variable) => { - await this.gitlab.setGitlabRepoVariable(repo.id, variable.key, variable.value, { + await this.gitlab.ensureGitlabRepoVariable(repo.id, variable.key, variable.value, { masked: variable.masked, protected: false, variableType: variable.variableType, From ad5791d295ffed0954ff4d192c9d54188283e17b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 28 Aug 2026 16:52:38 +0200 Subject: [PATCH 2/3] fix(server-nestjs): restore accidentally deleted GitLab client tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2617's idempotent variable commit (78b70fc1) deleted three unrelated test blocks — auditor flag tests, pipeline trigger token tests, and group Members tests — that exist on the base branch. This commit restores them while keeping the legitimate idempotent variable tests added by the PR. Signed-off-by: William Phetsinorath Change-Id: I2b7cffab1ad665f7f441c61f13a37f446a6a6964 --- .../gitlab/gitlab-client.service.spec.ts | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index eec750e348..4b25e4be88 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -464,11 +464,81 @@ describe('gitlab-client', () => { makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), ]) - await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: con + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) -... [OUTPUT TRUNCATED - 3,224 chars omitted out of 53,152 total] ... + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + admin: true, + })) + }) + + it('should not disable existing auditor flag when enabling admin flag', async () => { + const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } + const gitlabUser = { + email: consoleUser.email, + username: 'auditor-admin', + name: 'Auditor Admin', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + auditor: true, + })) + }) + }) + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabApi.Projects.edit.mockResolvedValue(makeProjectSchema({ id: repoId, name: 'mirror' })) + + const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) + expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const members = [makeMemberSchema({ id: 1, name: 'user' })] + const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(group) + expect(result).toEqual(members) + expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + const accessLevel = 30 + gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) + await service.addGroupMember(group, userId, accessLevel) expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) }) From f332a9f3ea96681b40aa72d0894c7f4ec8f634ba Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 28 Aug 2026 17:40:28 +0200 Subject: [PATCH 3/3] fix(server-nestjs): use optional chaining in GitLab variable reconcile guards Addresses SonarQube S3604-style warnings on the ensure* variable writers and restores the EOF newline stripped from the touched files. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I1ac1fb1bae0b821d9c0265bad089253e6a6a6964 --- .../src/modules/gitlab/gitlab-client.service.spec.ts | 2 +- .../src/modules/gitlab/gitlab-client.service.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index 4b25e4be88..c480a82dc7 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -1142,4 +1142,4 @@ describe('gitlab-client', () => { expect(gitlabApi.GroupVariables.create).toHaveBeenCalledWith(groupId, 'SONAR_TOKEN', 'secret', expect.objectContaining({ masked: true, variableType: 'env_var' })) }) }) -}) \ No newline at end of file +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 98e08162ed..b88280e406 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -520,7 +520,7 @@ export class GitlabClientService { if (isGitbeakerNotFound(error)) return undefined throw error }) - if (current && current.masked === options.masked + if (current?.masked === options.masked && current.value === value && current.protected === options.protected && current.variable_type === options.variableType) { @@ -560,7 +560,7 @@ export class GitlabClientService { if (isGitbeakerNotFound(error)) return undefined throw error }) - if (current && current.masked === options.masked + if (current?.masked === options.masked && current.value === value && current.protected === options.protected && current.variable_type === options.variableType) { @@ -758,4 +758,4 @@ export class GitlabClientService { this.logger.debug(`Pagination done (total=${total})`) } -} \ No newline at end of file +}