From 321f62e035915a702aee17b2c67a5b08c8f43c3b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 30 Apr 2026 15:58:56 +0200 Subject: [PATCH] refactor(server-nestjs): migrate Sonarqube to NestJS Signed-off-by: William Phetsinorath --- .../configuration/configuration.service.ts | 9 + .../sonarqube-client.service.spec.ts | 205 ++++++++ .../sonarqube/sonarqube-client.service.ts | 267 +++++++++++ .../sonarqube/sonarqube-datastore.service.ts | 67 +++ .../sonarqube/sonarqube-health.service.ts | 33 ++ .../sonarqube-http-client.service.ts | 94 ++++ .../sonarqube/sonarqube-testing.utils.ts | 90 ++++ .../modules/sonarqube/sonarqube.constants.ts | 40 ++ .../src/modules/sonarqube/sonarqube.module.ts | 24 + .../sonarqube/sonarqube.service.spec.ts | 299 ++++++++++++ .../modules/sonarqube/sonarqube.service.ts | 441 ++++++++++++++++++ .../src/modules/sonarqube/sonarqube.utils.ts | 6 + .../src/modules/vault/vault-client.service.ts | 50 +- .../src/modules/vault/vault.utils.ts | 6 + apps/server-nestjs/test/sonarqube.e2e-spec.ts | 176 +++++++ 15 files changed, 1806 insertions(+), 1 deletion(-) create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts create mode 100644 apps/server-nestjs/test/sonarqube.e2e-spec.ts diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts index 67155ecfb..9e11d41c0 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts @@ -90,6 +90,11 @@ export class ConfigurationService { ? process.env.NEXUS_INTERNAL_URL : process.env.NEXUS_URL + // sonarqube + sonarqubeUrl = process.env.SONARQUBE_URL + sonarqubeInternalUrl = process.env.SONARQUBE_INTERNAL_URL + sonarApiToken = process.env.SONAR_API_TOKEN + getInternalOrPublicGitlabUrl() { return this.gitlabInternalUrl ?? this.gitlabUrl } @@ -106,6 +111,10 @@ export class ConfigurationService { return this.nexusInternalUrl ?? this.nexusUrl } + getInternalOrPublicSonarqubeUrl() { + return this.sonarqubeInternalUrl ?? this.sonarqubeUrl + } + NODE_ENV = process.env.NODE_ENV === 'test' ? 'test' diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts new file mode 100644 index 000000000..66689ec44 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -0,0 +1,205 @@ +import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' + +const sonarUrl = 'https://sonarqube.internal' +const sonarToken = 'my-token' +const sonarBearerToken = Buffer.from(`${sonarToken}:`, 'utf8').toString('base64') +const sonarAuthHeader = `Bearer ${sonarBearerToken}` + +const server = setupServer() + +function createTestingModule() { + return Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { + provide: ConfigurationService, + useValue: { + sonarApiToken: sonarToken, + getInternalOrPublicSonarqubeUrl: () => sonarUrl, + } satisfies Partial, + }, + ], + }) +} + +describe('sonarqubeClientService', () => { + let service: SonarqubeClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + const module = await createTestingModule().compile() + service = module.get(SonarqubeClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('userGroupsSearch', () => { + it('should GET user_groups/search with auth', async () => { + const group = makeSonarqubeGroup({ name: 'my-group' }) + server.use( + http.get(`${sonarUrl}/api/user_groups/search`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(sonarAuthHeader) + expect(new URL(request.url).searchParams.get('q')).toBe('my-group') + return HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), groups: [group] }) + }), + ) + const result = await service.searchUserGroup({ q: 'my-group' }) + expect(result.groups).toEqual([group]) + }) + }) + + describe('userGroupsCreate', () => { + it('should POST user_groups/create', async () => { + server.use( + http.post(`${sonarUrl}/api/user_groups/create`, ({ request }) => { + expect(new URL(request.url).searchParams.get('name')).toBe('new-group') + return HttpResponse.json({}) + }), + ) + await expect(service.createUserGroup({ name: 'new-group' })).resolves.not.toThrow() + }) + }) + + describe('usersSearch', () => { + it('should GET users/search', async () => { + const user = makeSonarqubeUser({ login: 'my-user' }) + server.use( + http.get(`${sonarUrl}/api/users/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), users: [user] })), + ) + const result = await service.searchUsers({ q: 'my-user' }) + expect(result.users).toEqual([user]) + }) + }) + + describe('usersCreate', () => { + it('should POST users/create with all params as query string', async () => { + const user = { + email: faker.internet.email(), + local: 'true', + login: faker.internet.username(), + name: faker.internet.username(), + password: faker.internet.password(), + } satisfies CreateUserParams + server.use( + http.post(`${sonarUrl}/api/users/create`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('email')).toBe(user.email) + expect(params.get('local')).toBe(user.local) + return HttpResponse.json({}) + }), + ) + await service.createUser(user) + }) + }) + + describe('usersDeactivate', () => { + it('should POST users/deactivate with anonymize param', async () => { + const user = { + login: faker.internet.username(), + anonymize: true, + } satisfies DeactivateUserParams + server.use( + http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('anonymize')).toBe(user.anonymize) + return HttpResponse.json({}) + }), + ) + await service.deactivateUser(user) + }) + }) + + describe('userTokensRevoke / userTokensGenerate', () => { + it('should POST user_tokens/revoke', async () => { + const token = makeSonarqubeGeneratedToken() + const revoke = { + login: token.login, + name: token.name, + } satisfies RevokeUserTokenParams + server.use( + http.post(`${sonarUrl}/api/user_tokens/revoke`, () => HttpResponse.json({})), + ) + await expect(service.revokeUserToken(revoke)).resolves.not.toThrow() + }) + + it('should POST user_tokens/generate and return the token', async () => { + const generated = makeSonarqubeGeneratedToken() + server.use( + http.post(`${sonarUrl}/api/user_tokens/generate`, () => HttpResponse.json(generated)), + ) + const result = await service.generateUserToken({ login: generated.login, name: generated.name }) + expect(result.token).toBe(generated.token) + }) + }) + + describe('projectsSearch', () => { + it('should GET projects/search', async () => { + const project = makeSonarqubeProject() + server.use( + http.get(`${sonarUrl}/api/projects/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), components: [project] })), + ) + const result = await service.searchProject({ q: project.name }) + expect(result.components).toEqual([project]) + }) + }) + + describe('projectsDelete', () => { + it('should POST projects/delete with project key as query param', async () => { + const project = makeSonarqubeProject() + server.use( + http.post(`${sonarUrl}/api/projects/delete`, ({ request }) => { + expect(new URL(request.url).searchParams.get('project')).toBe(project.key) + return HttpResponse.json({}) + }), + ) + await service.deleteProject({ project: project.key }) + }) + }) + + describe('permissionsAddGroup', () => { + it('should POST permissions/add_group with global params', async () => { + const group = { + groupName: '/admin', + permission: 'admin', + } satisfies AddPermissionGroupParams + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('groupName')).toBe(group.groupName) + expect(params.get('permission')).toBe(group.permission) + expect(params.has('projectKey')).toBe(false) + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup(group) + }) + + it('should POST permissions/add_group with projectKey for project-scoped call', async () => { + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + expect(new URL(request.url).searchParams.get('projectKey')).toBe('proj-key') + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup({ groupName: '/proj', permission: 'scan', projectKey: 'proj-key' }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts new file mode 100644 index 000000000..07f444593 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -0,0 +1,267 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' + +export interface SonarqubePaging { + pageIndex: number + pageSize: number + total: number +} + +export interface SonarqubeGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export interface SonarqubeUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + managed: boolean +} + +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' + +export type SonarqubeProjectQualifier + = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION + | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH + | typeof SONARQUBE_PROJECT_QUALIFIER_DIRECTORY + | typeof SONARQUBE_PROJECT_QUALIFIER_FILE + | typeof SONARQUBE_PROJECT_QUALIFIER_LIBRARY + | typeof SONARQUBE_PROJECT_QUALIFIER_PROJECT + | typeof SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST + | typeof SONARQUBE_PROJECT_QUALIFIER_VIEW + | typeof SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW + +export interface SonarqubeProject { + key: string + name: string + qualifier: SonarqubeProjectQualifier + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +export interface SonarqubeProjectResult { + projectSlug: string + repository: string + key: string +} + +export interface SonarqubeGeneratedToken { + token: string + login: string + name: string +} + +type BaseParams = Record + +export interface SearchUserGroupParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserGroupParams extends BaseParams { + name: string + description?: string +} + +export interface CreatePermissionTemplateParams extends BaseParams { + name: string + description?: string + projectKeyPattern?: string +} + +export interface SetPermissionDefaultTemplateParams extends BaseParams { + templateName: string + projectKeyPattern?: string +} + +export interface AddPermissionProjectCreatorToTemplateParams extends BaseParams { + templateName: string + permission: string +} + +export interface AddPermissionGroupToTemplateParams extends BaseParams { + groupName: string + templateName: string + permission: string +} + +export interface AddPermissionGroupParams extends BaseParams { + groupName: string + permission: string + projectKey?: string +} + +export interface AddPermissionUserParams extends BaseParams { + projectKey: string + permission: string + login: string +} + +export interface SearchUsersParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserParams extends BaseParams { + email: string + local: string + login: string + name: string + password: string +} + +export interface DeactivateUserParams extends BaseParams { + login: string + anonymize: boolean +} + +export interface RevokeUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface GenerateUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface SearchProjectParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateProjectParams extends BaseParams { + project: string + visibility: string + name: string + mainbranch: string +} + +export interface DeleteProjectParams extends BaseParams { + project: string +} + +export interface SearchUserGroupResponse { + paging: SonarqubePaging + groups: SonarqubeGroup[] +} + +export interface SearchUsersResponse { + paging: SonarqubePaging + users: SonarqubeUser[] +} + +export interface SearchProjectResponse { + paging: SonarqubePaging + components: SonarqubeProject[] +} + +@Injectable() +export class SonarqubeClientService { + constructor( + @Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService, + ) {} + + @StartActiveSpan() + searchUserGroup(params: SearchUserGroupParams) { + return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUserGroup(params: CreateUserGroupParams) { + await this.http.fetch('user_groups/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async createPermissionTemplate(params: CreatePermissionTemplateParams) { + await this.http.fetch('permissions/create_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { + await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroup(params: AddPermissionGroupParams) { + await this.http.fetch('permissions/add_group', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionUser(params: AddPermissionUserParams) { + await this.http.fetch('permissions/add_user', { method: 'POST', params }) + } + + @StartActiveSpan() + searchUsers(params: SearchUsersParams) { + return this.http.fetch('users/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUser(params: CreateUserParams) { + await this.http.fetch('users/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deactivateUser(params: DeactivateUserParams) { + await this.http.fetch('users/deactivate', { method: 'POST', params }) + } + + @StartActiveSpan() + async revokeUserToken(params: RevokeUserTokenParams) { + await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + } + + @StartActiveSpan() + generateUserToken(params: GenerateUserTokenParams) { + return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + } + + @StartActiveSpan() + searchProject(params: SearchProjectParams) { + return this.http.fetch('projects/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createProject(params: CreateProjectParams) { + await this.http.fetch('projects/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deleteProject(params: DeleteProjectParams) { + await this.http.fetch('projects/delete', { method: 'POST', params }) + } +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts new file mode 100644 index 000000000..77fdb38c3 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -0,0 +1,67 @@ +import type { Prisma } from '@cpn-console/database' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { SONARQUBE_PLUGIN_NAME } from './sonarqube.constants' + +export const projectSelect = { + id: true, + slug: true, + repositories: { + select: { + internalRepoName: true, + }, + }, + plugins: { + where: { + pluginName: SONARQUBE_PLUGIN_NAME, + }, + select: { + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class SonarqubeDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: SONARQUBE_PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts new file mode 100644 index 000000000..870f53eeb --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' + +@Injectable() +export class SonarqubeHealthService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const urlBase = this.config.getInternalOrPublicSonarqubeUrl() + if (!urlBase) return indicator.down('Not configured') + + const url = new URL('/api/system/health', urlBase).toString() + const token = this.config.sonarApiToken + const headers: Record = {} + if (token) { + const bearerToken = Buffer.from(`${token}:`, 'utf8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` + } + + try { + const response = await fetch(url, { headers }) + if (response.status < 500) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts new file mode 100644 index 000000000..678b05999 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -0,0 +1,94 @@ +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' + +export interface SonarqubeFetchOptions { + method?: string + params?: Record +} + +export interface SonarqubeResponse { + status: number + data: T | null +} + +export type SonarqubeErrorKind = 'NotConfigured' | 'Unexpected' + +export class SonarqubeError extends Error { + readonly kind: SonarqubeErrorKind + readonly status?: number + readonly method?: string + readonly path?: string + + constructor( + kind: SonarqubeErrorKind, + message: string, + details: { status?: number, method?: string, path?: string } = {}, + ) { + super(message) + this.name = 'SonarqubeError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + } +} + +@Injectable() +export class SonarqubeHttpClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + private get baseUrl(): string { + const url = this.config.getInternalOrPublicSonarqubeUrl() + if (!url) throw new SonarqubeError('NotConfigured', 'SONARQUBE_URL or SONARQUBE_INTERNAL_URL is required') + return url + } + + private get apiBaseUrl(): string { + return new URL('api/', this.baseUrl).toString() + } + + private get defaultHeaders(): Record { + if (!this.config.sonarApiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + const bearerToken = Buffer.from(`${this.config.sonarApiToken}:`, 'utf8').toString('base64') + return { + Authorization: `Bearer ${bearerToken}`, + } + } + + async fetch(path: string, options: SonarqubeFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = (options.method ?? 'GET').toUpperCase() + span?.setAttribute('sonarqube.method', method) + span?.setAttribute('sonarqube.path', path) + + const request = this.createRequest(path, method, options.params) + const response = await fetch(request).catch((error) => { + throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) + }) + + span?.setAttribute('sonarqube.http.status', response.status) + return handleResponse(response) + } + + private createRequest(path: string, method: string, params?: Record): Request { + const url = new URL(path, this.apiBaseUrl) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + } + } + return new Request(url.toString(), { method, headers: this.defaultHeaders }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === 204) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts new file mode 100644 index 000000000..d1e78ebea --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -0,0 +1,90 @@ +import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { faker } from '@faker-js/faker' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube-client.service' + +export function makeUserToken(overrides: Partial = {}) { + return { + token: faker.string.uuid(), + login: faker.internet.username(), + name: faker.person.fullName(), + ...overrides, + } satisfies SonarqubeGeneratedToken +} + +export function makeEmptyGroupsResponse() { + return { paging: makeSonarqubePaging(), groups: [] } +} + +export function makeEmptyUsersResponse() { + return { paging: makeSonarqubePaging(), users: [] } +} + +export function makeEmptyProjectsResponse() { + return { paging: makeSonarqubePaging(), components: [] } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.internet.domainWord(), + repositories: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeSonarqubeGroup(overrides: Partial = {}): SonarqubeGroup { + return { + id: faker.string.uuid(), + name: faker.internet.domainWord(), + description: '', + membersCount: 0, + default: false, + ...overrides, + } satisfies SonarqubeGroup +} + +export function makeSonarqubeUser(overrides: Partial = {}): SonarqubeUser { + return { + login: faker.internet.username(), + name: faker.person.fullName(), + active: true, + email: faker.internet.email(), + groups: [], + tokensCount: 0, + local: true, + externalIdentity: '', + externalProvider: '', + managed: false, + ...overrides, + } satisfies SonarqubeUser +} + +export function makeSonarqubeProject(overrides: Partial = {}): SonarqubeProject { + return { + key: faker.string.alphanumeric(20), + name: faker.internet.domainWord(), + qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, + visibility: 'private', + ...overrides, + } satisfies SonarqubeProject +} + +export function makeSonarqubePaging(overrides: Partial = {}): SonarqubePaging { + return { + pageIndex: 1, + pageSize: 100, + total: 0, + ...overrides, + } satisfies SonarqubePaging +} + +export function makeSonarqubeGeneratedToken(overrides: Partial = {}): SonarqubeGeneratedToken { + return { + token: faker.string.alphanumeric(40), + login: faker.internet.username(), + name: `Sonar Token for ${faker.internet.username()}`, + ...overrides, + } satisfies SonarqubeGeneratedToken +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts new file mode 100644 index 000000000..e6744737f --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -0,0 +1,40 @@ +export const SONARQUBE_PLUGIN_NAME = 'sonarqube' +export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' + +// SonarQube global permission names +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'provisioning'] as const + +// Permission template — grants to project creator and sonar-administrators on new projects +export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const + +// Project-level permission sets per role (SonarQube permission API names) +export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const +export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const + +// CI robot/service account — needs Execute Analysis + Browse + See Source Code +export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const + +// Default platform-wide Keycloak group paths (following gitlab /console/* naming) +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default project role group path suffixes (appended to /{projectSlug}) +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' +export const DEFAULT_PROJECT_READONLY_SUFFIX = '/console/readonly' + +// Admin plugin config keys for overriding defaults +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const READONLY_GROUP_PATH_PLUGIN_KEY = 'readonlyGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_ADMIN_SUFFIX_PLUGIN_KEY = 'projectAdminSuffix' +export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' +export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' +export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' +export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts new file mode 100644 index 000000000..89bd0857b --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { VaultModule } from '../vault/vault.module' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeHealthService } from './sonarqube-health.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { SonarqubeService } from './sonarqube.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [ + HealthIndicatorService, + SonarqubeHttpClientService, + SonarqubeClientService, + SonarqubeDatastoreService, + SonarqubeHealthService, + SonarqubeService, + ], + exports: [SonarqubeClientService, SonarqubeHealthService], +}) +export class SonarqubeModule {} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts new file mode 100644 index 000000000..5412665c6 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -0,0 +1,299 @@ +import type { Mocked } from 'vitest' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils.js' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT, SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + makeEmptyGroupsResponse, + makeEmptyProjectsResponse, + makeEmptyUsersResponse, + makeProjectWithDetails, + makeSonarqubePaging, + makeSonarqubeUser, + makeUserToken, +} from './sonarqube-testing.utils' +import { SonarqubeService } from './sonarqube.service' + +function createTestingModule() { + return Test.createTestingModule({ + providers: [ + SonarqubeService, + { + provide: SonarqubeClientService, + useValue: { + searchUserGroup: vi.fn(), + createUserGroup: vi.fn(), + createPermissionTemplate: vi.fn(), + setPermissionDefaultTemplate: vi.fn(), + addPermissionProjectCreatorToTemplate: vi.fn(), + addPermissionGroupToTemplate: vi.fn(), + addPermissionGroup: vi.fn(), + addPermissionUser: vi.fn(), + searchUsers: vi.fn(), + createUser: vi.fn(), + deactivateUser: vi.fn(), + revokeUserToken: vi.fn(), + generateUserToken: vi.fn(), + searchProject: vi.fn(), + createProject: vi.fn(), + deleteProject: vi.fn(), + } satisfies Partial, + }, + { + provide: SonarqubeDatastoreService, + useValue: { + getAllProjects: vi.fn(), + getProject: vi.fn(), + getAdminPluginConfig: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultClientService, + useValue: { + readSonarqubeUser: vi.fn(), + writeSonarqubeUser: vi.fn(), + deleteSonarqubeUser: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + projectRootDir: 'forge', + getInternalOrPublicSonarqubeUrl: () => 'https://sonarqube.internal', + } satisfies Partial, + }, + ], + }) +} + +describe('sonarqubeService', () => { + let service: SonarqubeService + let client: Mocked + let datastore: Mocked + let vault: Mocked + + beforeEach(async () => { + const module = await createTestingModule().compile() + service = module.get(SonarqubeService) + client = module.get(SonarqubeClientService) + datastore = module.get(SonarqubeDatastoreService) + vault = module.get(VaultClientService) + + datastore.getAdminPluginConfig.mockResolvedValue(null) + client.searchUserGroup.mockResolvedValue(makeEmptyGroupsResponse()) + client.createUserGroup.mockResolvedValue(undefined) + client.createPermissionTemplate.mockResolvedValue(undefined) + client.setPermissionDefaultTemplate.mockResolvedValue(undefined) + client.addPermissionProjectCreatorToTemplate.mockResolvedValue(undefined) + client.addPermissionGroupToTemplate.mockResolvedValue(undefined) + client.addPermissionGroup.mockResolvedValue(undefined) + client.addPermissionUser.mockResolvedValue(undefined) + client.searchUsers.mockResolvedValue(makeEmptyUsersResponse()) + client.createUser.mockResolvedValue(undefined) + client.deactivateUser.mockResolvedValue(undefined) + client.revokeUserToken.mockResolvedValue(undefined) + client.searchProject.mockResolvedValue(makeEmptyProjectsResponse()) + client.createProject.mockResolvedValue(undefined) + client.deleteProject.mockResolvedValue(undefined) + vault.readSonarqubeUser.mockResolvedValue(null) + vault.writeSonarqubeUser.mockResolvedValue(undefined) + vault.deleteSonarqubeUser.mockResolvedValue(undefined) + }) + + describe('init', () => { + it('should set up the permission template', async () => { + await service.init() + expect(client.createPermissionTemplate).toHaveBeenCalledWith({ name: 'Forge Default' }) + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should create /console/admin group with global permissions when it does not exist', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/admin' })) + }) + + it('should create /console/readonly and /console/security platform groups', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/readonly' })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/security' })) + }) + + it('should not create groups that already exist', async () => { + client.searchUserGroup.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + groups: [{ id: '1', name: '/console/admin', description: '', membersCount: 1, default: false }], + }) + await service.init() + expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + }) + + it('should skip initialization when URL is not configured', async () => { + const module = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: ConfigurationService, useValue: { getInternalOrPublicSonarqubeUrl: () => undefined } }, + ], + }).compile() + await module.get(SonarqubeService).init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + }) + + it('should use custom group paths from admin plugin config', async () => { + datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { + if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') + return Promise.resolve(null) + }) + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/custom/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/custom/admin' })) + }) + }) + + describe('handleUpsert', () => { + it('should create the 5 project role groups in SonarQube', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/admin` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/devops` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/developer` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/security` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/readonly` })) + }) + + it('should create a new user and write vault credentials', async () => { + const project = makeProjectWithDetails() + const userToken = makeUserToken({ login: project.slug }) + client.generateUserToken.mockResolvedValue(userToken) + + await service.handleUpsert(project) + + expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: userToken.token })) + }) + + it('should set role-based permissions on new repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createProject).toHaveBeenCalledWith(expect.objectContaining({ visibility: 'private', name: `${project.slug}-repo` })) + expect(client.addPermissionUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/devops` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + }) + + it('should not recreate user or write vault when both user and secret exist', async () => { + const project = makeProjectWithDetails({ slug: 'existing', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).not.toHaveBeenCalled() + expect(vault.writeSonarqubeUser).not.toHaveBeenCalled() + }) + + it('should rotate token when user exists but vault secret is missing', async () => { + const project = makeProjectWithDetails({ repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_PASSWORD: 'not initialized' })) + }) + + it('should delete sonarqube projects for removed repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: `${project.slug}-kept-aabb`, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: `${project.slug}-removed-ccdd`, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: `${project.slug}-removed-ccdd` }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: `${project.slug}-kept-aabb` }) + }) + + it('should use comma-separated group path suffixes from project plugin config', async () => { + const project = makeProjectWithDetails({ + repositories: [{ internalRepoName: 'repo' }], + plugins: [{ key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/owner` })) + }) + }) + + describe('handleDelete', () => { + it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + components: [{ key: 'doomed-repo-aabb', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) + + await service.handleDelete(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: 'doomed-repo-aabb' }) + expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') + }) + + it('should skip anonymization when the user does not exist', async () => { + const project = makeProjectWithDetails({ slug: 'no-user' }) + + await service.handleDelete(project) + + expect(client.deactivateUser).not.toHaveBeenCalled() + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects and run init', async () => { + const projects = [ + makeProjectWithDetails({ repositories: [] }), + makeProjectWithDetails({ repositories: [] }), + ] + datastore.getAllProjects.mockResolvedValue(projects) + client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) + + await service.handleCron() + + expect(client.searchProject).toHaveBeenCalledTimes(2) + expect(client.createPermissionTemplate).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts new file mode 100644 index 000000000..1a6eec948 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -0,0 +1,441 @@ +import type { OnModuleInit } from '@nestjs/common' +import type { SonarqubeUserSecret } from '../vault/vault-client.service' +import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { generateProjectKey, generateRandomPassword } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_PERMISSION_TEMPLATE_NAME, + DEFAULT_PROJECT_ADMIN_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_SUFFIX, + DEFAULT_PROJECT_DEVOPS_SUFFIX, + DEFAULT_PROJECT_READONLY_SUFFIX, + DEFAULT_PROJECT_SECURITY_SUFFIX, + DEFAULT_READONLY_GROUP_PATH, + DEFAULT_SECURITY_GROUP_PATH, + DEFAULT_TEMPLATE_PERMISSIONS, + GLOBAL_ADMIN_PERMISSIONS, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_PERMISSIONS, + PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_PERMISSIONS, + PROJECT_READONLY_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_PERMISSIONS, + PROJECT_SECURITY_SUFFIX_PLUGIN_KEY, + READONLY_GROUP_PATH_PLUGIN_KEY, + ROBOT_PROJECT_PERMISSIONS, + SECURITY_GROUP_PATH_PLUGIN_KEY, + SONARQUBE_PLUGIN_NAME, +} from './sonarqube.constants' + +interface SonarqubeRolePaths { + admin: string[] + devops: string[] + developer: string[] + security: string[] + readonly: string[] +} + +@Injectable() +export class SonarqubeService implements OnModuleInit { + private readonly logger = new Logger(SonarqubeService.name) + + constructor( + @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, + @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('SonarqubeService initialized') + } + + async onModuleInit() { + await this.init().catch(error => this.logger.error('SonarQube initialization failed', error)) + } + + @StartActiveSpan() + async init(): Promise { + if (!this.config.getInternalOrPublicSonarqubeUrl()) { + this.logger.warn('SonarQube URL not configured — skipping initialization') + return + } + this.logger.log('Initializing SonarQube platform configuration') + const adminGroupPath = await this.getAdminGroupPath() + const [readonlyGroupPath, securityGroupPath] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + ]) + await this.ensureDefaultPermissionTemplate() + await Promise.all([ + this.ensureGroupWithGlobalPermissions(adminGroupPath, GLOBAL_ADMIN_PERMISSIONS), + this.ensureGroup(readonlyGroupPath), + this.ensureGroup(securityGroupPath), + ]) + this.logger.log('SonarQube platform configuration initialized') + } + + @OnEvent('project.upsert') + @StartActiveSpan() + async handleUpsert(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`SonarQube sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + @StartActiveSpan() + async handleDelete(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.deleteProjectGroup(project) + this.logger.log(`SonarQube deletion completed for project ${project.slug}`) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting SonarQube reconciliation') + await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) + const projects = await this.datastore.getAllProjects() + span?.setAttribute('sonarqube.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`SonarQube reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('sonarqube.projects.count', projects.length) + await Promise.all(projects.map(p => + this.ensureProjectGroup(p).catch(error => + this.logger.error(`Failed to reconcile SonarQube project (slug=${p.slug})`, error), + ), + )) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const rolePaths = await this.getProjectRoleGroupPaths(project) + await Promise.all([ + this.ensureUser(project.slug, project.slug), + this.ensureProjectSonarGroups(rolePaths), + this.ensureProjectRepositories(project, rolePaths), + ]) + } + + @StartActiveSpan() + private async deleteProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const sonarProjects = await this.findProjectsForSlug(project.slug) + span?.setAttribute('sonarqube.projects.count', sonarProjects.length) + this.logger.log(`Deleting ${sonarProjects.length} SonarQube repositories for project ${project.slug}`) + + await Promise.all(sonarProjects.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted SonarQube repository (key=${sp.key})`) + })) + + const user = await this.findUser(project.slug) + if (user) { + await this.client.deactivateUser({ login: project.slug, anonymize: true }) + this.logger.log(`Anonymized SonarQube user (login=${project.slug})`) + } else { + this.logger.verbose(`SonarQube user not found, skipping anonymization (login=${project.slug})`) + } + + await this.vault.deleteSonarqubeUser(project.slug) + this.logger.verbose(`Deleted SonarQube vault credentials (slug=${project.slug})`) + } + + @StartActiveSpan() + private async ensureDefaultPermissionTemplate(): Promise { + this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionGroupToTemplate({ groupName: 'sonar-administrators', templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await this.client.setPermissionDefaultTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`SonarQube permission template ensured (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + + @StartActiveSpan() + private async ensureUser(username: string, projectSlug: string): Promise { + const existingSecret = await this.vault.readSonarqubeUser(projectSlug) + const user = await this.findUser(username) + let newSecret: SonarqubeUserSecret | undefined + + if (!user) { + this.logger.log(`Creating SonarQube user (login=${username})`) + const password = generateRandomPassword(30) + await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + } else if (existingSecret) { + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + } else { + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + } + + if (newSecret) { + await this.vault.writeSonarqubeUser(projectSlug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + } + } + + private async ensureProjectSonarGroups(rolePaths: SonarqubeRolePaths): Promise { + const allGroups = [ + ...rolePaths.admin, + ...rolePaths.devops, + ...rolePaths.developer, + ...rolePaths.security, + ...rolePaths.readonly, + ] + await Promise.all(allGroups.map(group => this.ensureGroup(group))) + } + + @StartActiveSpan() + private async ensureProjectRepositories(project: ProjectWithDetails, rolePaths: SonarqubeRolePaths): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repositories.count', project.repositories.length) + + const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + this.findProjectsForSlug(project.slug), + ]) + + const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) + if (orphans.length) this.logger.log(`Removing ${orphans.length} orphan SonarQube repositories for project ${project.slug}`) + + await Promise.all([ + ...orphans.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted orphan SonarQube repository (key=${sp.key})`) + }), + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey(project.slug, repository.internalRepoName) + if (!existingSonarProjects.some(sp => sp.repository === repository.internalRepoName)) { + await this.client.createProject({ + project: projectKey, + visibility: 'private', + name: `${project.slug}-${repository.internalRepoName}`, + mainbranch: 'main', + }) + this.logger.log(`Created SonarQube repository (key=${projectKey})`) + } + await this.ensureProjectPermissions(projectKey, project.slug, rolePaths, readonlyGroupPath, securityGroupPath) + this.logger.verbose(`Ensured permissions on SonarQube repository (key=${projectKey})`) + }), + ]) + } + + private async ensureProjectPermissions( + projectKey: string, + login: string, + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, + ): Promise { + await Promise.all([ + ...ROBOT_PROJECT_PERMISSIONS.map(permission => + this.client.addPermissionUser({ projectKey, permission, login }), + ), + ...buildGroupPermissions(rolePaths, readonlyGroupPath, securityGroupPath).flatMap(({ groupName, permissions }) => + permissions.map(permission => this.client.addPermissionGroup({ projectKey, permission, groupName })), + ), + ]) + } + + private async ensureGroupWithGlobalPermissions(groupName: string, permissions: readonly string[]): Promise { + await this.ensureGroup(groupName) + await Promise.all(permissions.map(permission => + this.client.addPermissionGroup({ groupName, permission }), + )) + } + + private async ensureGroup(groupName: string): Promise { + const result = await this.client.searchUserGroup({ q: groupName }) + if (result.groups.some(g => g.name === groupName)) { + this.logger.verbose(`SonarQube group already exists (name=${groupName})`) + } else { + await this.client.createUserGroup({ name: groupName }) + this.logger.log(`Created SonarQube group (name=${groupName})`) + } + } + + private async rotateToken(login: string): Promise { + const name = `Sonar Token for ${login}` + await this.client.revokeUserToken({ login, name }).catch(() => {}) + const { token } = await this.client.generateUserToken({ login, name }) + this.logger.log(`Rotated SonarQube token (login=${login})`) + return token + } + + private async getAdminGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getReadonlyGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_READONLY_GROUP_PATH + } + + private async getSecurityGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise { + const [admin, devops, developer, security, readonly] = await Promise.all([ + this.getProjectAdminGroupPaths(project), + this.getProjectDevopsGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectSecurityGroupPaths(project), + this.getProjectReadonlyGroupPaths(project), + ]) + return { admin, devops, developer, security, readonly } + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_ADMIN_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDevopsGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVOPS_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectSecurityGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_SECURITY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectReadonlyGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_READONLY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async findUser(login: string): Promise { + let page = 1 + const pageSize = 100 + while (true) { + const response = await this.client.searchUsers({ q: login, ps: pageSize, p: page }) + const found = response.users.find(u => u.login === login) + if (found) return found + if (!response.users.length || response.paging.pageIndex * response.paging.pageSize >= response.paging.total) return undefined + page++ + } + } + + private async findProjectsForSlug(projectSlug: string): Promise { + let found: SonarqubeProjectResult[] = [] + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const result = await this.client.searchProject({ q: projectSlug, p: page, ps: pageSize }) + total = result.paging.total + found = [...found, ...filterProjectsOwningSlug(result.components, projectSlug)] + } while (page * pageSize < total) + return found + } +} + +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(suffix => `/${projectSlug}${suffix}`) +} + +function buildGroupPermissions( + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, +): { groupName: string, permissions: readonly string[] }[] { + return [ + ...rolePaths.admin.map(groupName => ({ groupName, permissions: PROJECT_ADMIN_PERMISSIONS })), + ...rolePaths.devops.map(groupName => ({ groupName, permissions: PROJECT_DEVOPS_PERMISSIONS })), + ...rolePaths.developer.map(groupName => ({ groupName, permissions: PROJECT_DEVELOPER_PERMISSIONS })), + ...rolePaths.security.map(groupName => ({ groupName, permissions: PROJECT_SECURITY_PERMISSIONS })), + ...rolePaths.readonly.map(groupName => ({ groupName, permissions: PROJECT_READONLY_PERMISSIONS })), + { groupName: securityGroupPath, permissions: PROJECT_SECURITY_PERMISSIONS }, + { groupName: readonlyGroupPath, permissions: PROJECT_READONLY_PERMISSIONS }, + ] +} + +function filterProjectsOwningSlug( + components: { key: string }[], + projectSlug: string, +): SonarqubeProjectResult[] { + return components.reduce((acc, { key: sonarKey }) => { + const parts = sonarKey.split('-') + parts.pop() + for (let i = parts.length - 1; i > 0; i--) { + const project = parts.slice(0, i).join('-') + const repository = parts.slice(i).join('-') + if (sonarKey.startsWith(`${project}-${repository}-`) && project === projectSlug) { + acc.push({ projectSlug, repository, key: sonarKey }) + break + } + } + return acc + }, []) +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts new file mode 100644 index 000000000..8b8fc8adf --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -0,0 +1,6 @@ +export function sonarProjectPropertiesFile(projectKey: string) { + return [ + `sonar.projectKey=${projectKey}`, + 'sonar.qualitygate.wait=true', + ] +} diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index 1de60fe04..2ea913b61 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -3,7 +3,7 @@ import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultError, VaultHttpClientService } from './vault-http-client.service' -import { generateGitlabMirrorCredPath, generateProjectPath, generateTechReadOnlyCredPath } from './vault.utils' +import { generateGitlabMirrorCredPath, generateProjectPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' export interface VaultSysPoliciesAclUpsertRequest { policy: string @@ -68,6 +68,12 @@ export interface VaultIdentityGroupResponse { } } +export interface SonarqubeUserSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} + export interface VaultMetadata { created_time: string custom_metadata: Record | null @@ -237,6 +243,48 @@ export class VaultClientService { await this.write(creds, vaultPath) } + @StartActiveSpan() + async readSonarqubeUser(projectSlug: string): Promise | null> { + const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.path': vaultPath, + }) + this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { + const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.path': vaultPath, + }) + this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.write(secret, vaultPath) + } + + @StartActiveSpan() + async deleteSonarqubeUser(projectSlug: string): Promise { + const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.path': vaultPath, + }) + this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.delete(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + @StartActiveSpan() async writeMirrorTriggerToken(secret: Record): Promise { const span = trace.getActiveSpan() diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts index 060628618..576da08e2 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -15,3 +15,9 @@ export function generateTechReadOnlyCredPath(projectRootDir: string | undefined, ? `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` : `${projectSlug}/tech/GITLAB_MIRROR` } + +export function generateSonarqubeCredPath(projectRootDir: string | undefined, projectSlug: string) { + return projectRootDir + ? `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` + : `${projectSlug}/SONAR` +} diff --git a/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/apps/server-nestjs/test/sonarqube.e2e-spec.ts new file mode 100644 index 000000000..ea061ad74 --- /dev/null +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -0,0 +1,176 @@ +import type { TestingModule } from '@nestjs/testing' +import { generateProjectKey } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' +import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' +import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' +import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' +import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' + +const canRunSonarqubeE2E + = Boolean(process.env.E2E) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) + +describeWithSonarqube('SonarqubeService (e2e)', () => { + let moduleRef: TestingModule + let sonarqubeService: SonarqubeService + let sonarqubeClient: SonarqubeClientService + let vaultService: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRepoName: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [SonarqubeModule, VaultModule, ConfigurationModule, InfrastructureModule], + }).compile() + + await moduleRef.init() + + sonarqubeService = moduleRef.get(SonarqubeService) + sonarqubeClient = moduleRef.get(SonarqubeClientService) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + testRepoName = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (sonarqubeService && testProjectSlug) { + await sonarqubeService.handleDelete( + makeProjectWithDetails({ slug: testProjectSlug, repositories: [] }), + ).catch(() => {}) + } + + if (prisma) { + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create platform groups during initialization', async () => { + // init() is triggered by moduleRef.init() via onModuleInit — groups must already exist + const [adminResult, readonlyResult, securityResult] = await Promise.all([ + sonarqubeClient.searchUserGroup({ q: '/console/admin' }), + sonarqubeClient.searchUserGroup({ q: '/console/readonly' }), + sonarqubeClient.searchUserGroup({ q: '/console/security' }), + ]) + + expect(adminResult.groups.some(g => g.name === '/console/admin')).toBe(true) + expect(readonlyResult.groups.some(g => g.name === '/console/readonly')).toBe(true) + expect(securityResult.groups.some(g => g.name === '/console/security')).toBe(true) + }) + + it('should reconcile project in SonarQube (groups, user, repository, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E SonarQube Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: testRepoName, + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleUpsert(project) + + // All 5 project role groups should exist in SonarQube + const projectGroupNames = [ + `/${testProjectSlug}/console/admin`, + `/${testProjectSlug}/console/devops`, + `/${testProjectSlug}/console/developer`, + `/${testProjectSlug}/console/security`, + `/${testProjectSlug}/console/readonly`, + ] + for (const groupName of projectGroupNames) { + const result = await sonarqubeClient.searchUserGroup({ q: groupName }) + expect(result.groups.some(g => g.name === groupName), `group ${groupName} should exist`).toBe(true) + } + + // Robot/CI user should exist + const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) + expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + + // SonarQube analysis project for the repository should exist + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(true) + + // Vault credentials should be written with correct username and token + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret?.data?.SONAR_USERNAME).toBe(testProjectSlug) + expect(vaultSecret?.data?.SONAR_TOKEN).toBeTruthy() + expect(vaultSecret?.data?.SONAR_PASSWORD).toBeTruthy() + }, 30000) + + it('should delete the project from SonarQube and remove vault credentials', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleDelete(project) + + // SonarQube analysis project should be removed + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(false) + + // Vault credentials should be removed + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret).toBeNull() + }, 30000) +})