diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index 33b81f4f48..cc935a2e43 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -11,7 +11,7 @@ import { OBSERVABILITY_REPOSITORY } from '../observability/observability.constan import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' -import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipeline, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails, makeVaultSecret } from './gitlab-testing.utils' +import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipeline, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails, makeUser, makeVaultSecret } from './gitlab-testing.utils' import { INFRA_APPS_REPO_NAME, MIRROR_REPO_NAME, PLUGIN_NAME, TOPIC_PLUGIN_MANAGED, TOPIC_SYSTEM_MANAGED } from './gitlab.constants' import { GitlabService } from './gitlab.service' @@ -55,6 +55,75 @@ describe('gitlabService', () => { expect(service).toBeDefined() }) + describe('projectMember events', () => { + it('should reconcile project members on projectMember.upsert', async () => { + const project = makeProjectWithDetails({ + members: [{ user: { id: 'u1', email: 'member@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + datastore.getProject.mockResolvedValue(project) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async user => makeExpandedUserSchema({ + id: user.email === 'member@example.com' ? 999 : 998, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + })) + + const result = await service.handleProjectMemberUpsert({ projectId: project.id, userId: 'u1' }) + + expect(result.gitlab.status).toBe('OK') + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) + }) + + it('should fail with KO when the project no longer exists on projectMember.upsert', async () => { + datastore.getProject.mockResolvedValue(null) + + const result = await service.handleProjectMemberUpsert({ projectId: 'missing', userId: 'u1' }) + + expect(result.gitlab.status).toBe('KO') + }) + + it('should remove the group member on projectMember.delete', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 321, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + datastore.getProject.mockResolvedValue(project) + datastore.getUser.mockResolvedValue(makeUser({ id: 'u1', email: 'leaver@example.com' })) + gitlab.getUserByEmail.mockResolvedValue(makeExpandedUserSchema({ id: 555, email: 'leaver@example.com', username: 'leaver', name: 'Leaver User' })) + gitlab.getProjectGroup.mockResolvedValue(group) + + const result = await service.handleProjectMemberDelete({ projectId: project.id, userId: 'u1' }) + + expect(result.gitlab.status).toBe('OK') + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 555) + }) + + it('should tolerate a user absent from GitLab on projectMember.delete', async () => { + datastore.getProject.mockResolvedValue(makeProjectWithDetails()) + datastore.getUser.mockResolvedValue(makeUser({ id: 'u1', email: 'ghost@example.com' })) + gitlab.getUserByEmail.mockResolvedValue(null) + + const result = await service.handleProjectMemberDelete({ projectId: 'p1', userId: 'u1' }) + + expect(result.gitlab.status).toBe('OK') + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should tolerate a missing GitLab group on projectMember.delete', async () => { + datastore.getProject.mockResolvedValue(makeProjectWithDetails()) + datastore.getUser.mockResolvedValue(makeUser({ id: 'u1', email: 'leaver@example.com' })) + gitlab.getUserByEmail.mockResolvedValue(makeExpandedUserSchema({ id: 555, email: 'leaver@example.com', username: 'leaver', name: 'Leaver User' })) + gitlab.getProjectGroup.mockResolvedValue(undefined) + + const result = await service.handleProjectMemberDelete({ projectId: 'p1', userId: 'u1' }) + + expect(result.gitlab.status).toBe('OK') + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + }) + describe('handleUpsert', () => { it('should reconcile project members and repositories', async () => { const project = makeProjectWithDetails() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 4af900a9d4..64b5562a4d 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,6 +1,6 @@ import type { MemberSchema } from '@gitbeaker/core' import type { ConfigType } from '@nestjs/config' -import type { RepositorySyncEventPayload } from '../events/app-events.service' +import type { ProjectMemberEventPayload, RepositorySyncEventPayload } from '../events/app-events.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { MirrorUserSecret, VaultSecret } from '../vault/vault-client.service' import type { GroupSchemaWith } from './gitlab-client.service' @@ -89,6 +89,16 @@ export class GitlabService { return capturePluginResult('gitlab', () => this.syncRepositoryMirror(payload)) } + @OnEvent('projectMember.upsert') + async handleProjectMemberUpsert(payload: ProjectMemberEventPayload): Promise> { + return capturePluginResult('gitlab', () => this.syncProjectMembers(payload.projectId)) + } + + @OnEvent('projectMember.delete') + async handleProjectMemberDelete(payload: ProjectMemberEventPayload): Promise> { + return capturePluginResult('gitlab', () => this.removeProjectMember(payload)) + } + @StartActiveSpan() private async syncRepositoryMirror(payload: RepositorySyncEventPayload) { const { projectSlug, internalRepoName, syncAllBranches } = payload @@ -120,6 +130,49 @@ export class GitlabService { this.logger.log(`GitLab cleanup completed for project ${project.slug}`) } + @StartActiveSpan() + private async syncProjectMembers(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`Handling a project member upsert event (projectId=${projectId})`) + const project = await this.datastore.getProject(projectId) + if (!project) { + throw new Error(`Project not found for member sync (projectId=${projectId})`) + } + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group) + await this.ensureProjectGroupMembers(project, group, members) + this.logger.log(`GitLab member sync completed (projectId=${projectId}, slug=${project.slug})`) + } + + @StartActiveSpan() + private async removeProjectMember(payload: ProjectMemberEventPayload) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', payload.projectId) + span?.setAttribute('project.member.userId', payload.userId) + this.logger.log(`Handling a project member delete event (projectId=${payload.projectId}, userId=${payload.userId})`) + const user = await this.datastore.getUser(payload.userId) + if (!user) { + throw new Error(`User not found for member removal (userId=${payload.userId})`) + } + const project = await this.datastore.getProject(payload.projectId) + if (!project) { + throw new Error(`Project not found for member removal (projectId=${payload.projectId})`) + } + const gitlabUser = await this.gitlab.getUserByEmail(user.email) + if (!gitlabUser) { + this.logger.log(`User absent from GitLab, nothing to remove (projectId=${payload.projectId}, userId=${payload.userId})`) + return + } + const group = await this.gitlab.getProjectGroup(project.slug) + if (!group) { + this.logger.log(`No GitLab group for project, nothing to remove (projectId=${payload.projectId}, slug=${project.slug})`) + return + } + await this.gitlab.removeGroupMember(group, gitlabUser.id) + this.logger.log(`GitLab member removed (projectId=${payload.projectId}, gitlabUserId=${gitlabUser.id})`) + } + // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() {