Skip to content
Draft
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
71 changes: 70 additions & 1 deletion apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down
55 changes: 54 additions & 1 deletion apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -89,6 +89,16 @@ export class GitlabService {
return capturePluginResult('gitlab', () => this.syncRepositoryMirror(payload))
}

@OnEvent('projectMember.upsert')
async handleProjectMemberUpsert(payload: ProjectMemberEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
return capturePluginResult('gitlab', () => this.syncProjectMembers(payload.projectId))
}

@OnEvent('projectMember.delete')
async handleProjectMemberDelete(payload: ProjectMemberEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
return capturePluginResult('gitlab', () => this.removeProjectMember(payload))
}

@StartActiveSpan()
private async syncRepositoryMirror(payload: RepositorySyncEventPayload) {
const { projectSlug, internalRepoName, syncAllBranches } = payload
Expand Down Expand Up @@ -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() {
Expand Down