Skip to content
Closed
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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,11 @@ Les utilisateurs faisant parti du group `admin` ont également accès à l'inter
| __Openshift__ | | Namespace | | |
| __ArgoCD__ | | | (infra) Secret, AppProject, Application | |
| __Gitlab__ | Group | | Repository (Dépôt) | User |
| __Harbor__ | Project | | Repository [1] | |
| __Harbor__ | Project | | Repository | |
| __Keycloak__ | | Group | | User / member |
| __Sonar__ | User | | | |
| __Nexus__ | Repositories, role, user ... | | | |

[1] N'est pas crée par la console mais par le produit de la CI

## Architecture du dépôt

La gestion des dépendances est effectuée à l'aide de [pnpm](https://pnpm.io/) selon la structure de dossiers suivante :
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { sonarqubeConfigFactory } from '../../config/sonarqube.config'
import { getAll } from '../../utils/iterable.utils'
import { SonarqubeClientService } from './sonarqube-client.service'
import { SonarqubeHttpClientService } from './sonarqube-http-client.service'
import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils'
Expand Down Expand Up @@ -80,8 +81,9 @@ describe('sonarqubeClientService', () => {
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])
const result = await getAll(service.searchUsers({ q: 'my-user' }))
expect(result).toHaveLength(1)
expect(result[0]).toEqual(user)
})
})

Expand Down Expand Up @@ -155,8 +157,9 @@ describe('sonarqubeClientService', () => {
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])
const result = await getAll(service.searchProject({ q: project.name }))
expect(result).toHaveLength(1)
expect(result[0]).toEqual(project)
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import type {
SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST,
SONARQUBE_PROJECT_QUALIFIER_VIEW,
} from './sonarqube.constants'
import { Inject, Injectable } from '@nestjs/common'
import { Inject, Injectable, Logger } from '@nestjs/common'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { SonarqubeHttpClientService } from './sonarqube-http-client.service'
import { SONARQUBE_MAX_PAGES, SONARQUBE_PAGE_SIZE } from './sonarqube.constants'

export interface SonarqubePaging {
pageIndex: number
Expand Down Expand Up @@ -196,10 +197,37 @@ export interface SearchProjectResponse {

@Injectable()
export class SonarqubeClientService {
private readonly logger = new Logger(SonarqubeClientService.name)

constructor(
@Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService,
) {}

private async* paginate<T>(fetchPage: (page: number, pageSize: number) => Promise<T[]>): AsyncGenerator<T> {
const pageSize = SONARQUBE_PAGE_SIZE
let page = 1
let pagesFetched = 0
this.logger.debug('Pagination start', { action: 'paginate' })
// ponytail: hard cap (1000 * 100) guards a misbehaving endpoint that always returns a full page; raise if a project legitimately exceeds 100k items
while (pagesFetched < SONARQUBE_MAX_PAGES) {
try {
const items = await fetchPage(page, pageSize)
pagesFetched += 1
this.logger.debug('Pagination page fetched', { action: 'paginate', page, items: items.length, pageSize, pagesFetched })
for (const item of items) yield item
if (items.length < pageSize) break
page += 1
} catch (error) {
this.logger.error('Pagination request failed', { action: 'paginate', page, err: error })
throw error
}
}
if (pagesFetched >= SONARQUBE_MAX_PAGES) {
this.logger.error('Pagination hit max page cap, stopping early', { action: 'paginate', pagesFetched })
}
this.logger.debug('Pagination done', { action: 'paginate', pagesFetched })
}

@StartActiveSpan()
searchUserGroup(params: SearchUserGroupParams) {
return this.http.fetch<SearchUserGroupResponse>('user_groups/search', { query: params }).then(res => res.data!)
Expand Down Expand Up @@ -246,8 +274,11 @@ export class SonarqubeClientService {
}

@StartActiveSpan()
searchUsers(params: SearchUsersParams) {
return this.http.fetch<SearchUsersResponse>('users/search', { query: params }).then(res => res.data!)
async* searchUsers(params: SearchUsersParams): AsyncGenerator<SonarqubeUser> {
yield* this.paginate(async (page, pageSize) => {
const res = await this.http.fetch<SearchUsersResponse>('users/search', { query: { ...params, p: page, ps: pageSize } })
return res.data!.users
})
}

@StartActiveSpan()
Expand All @@ -271,8 +302,11 @@ export class SonarqubeClientService {
}

@StartActiveSpan()
searchProject(params: SearchProjectParams) {
return this.http.fetch<SearchProjectResponse>('projects/search', { query: params }).then(res => res.data!)
async* searchProject(params: SearchProjectParams): AsyncGenerator<SonarqubeProject> {
yield* this.paginate(async (page, pageSize) => {
const res = await this.http.fetch<SearchProjectResponse>('projects/search', { query: { ...params, p: page, ps: pageSize } })
return res.data!.components
})
}

@StartActiveSpan()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export const projectSelect = {
value: true,
},
},
owner: {
select: {
email: true,
},
},
} satisfies Prisma.ProjectSelect

export type ProjectWithDetails = Prisma.ProjectGetPayload<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function makeProjectWithDetails(overrides: Partial<ProjectWithDetails> =
slug: faker.internet.domainWord(),
repositories: [],
plugins: [],
owner: { email: faker.internet.email() },
...overrides,
} satisfies ProjectWithDetails
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export const PLUGIN_NAME = 'sonarqube'
export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default'

// SonarQube search API page size (server caps `ps` at 500)
export const SONARQUBE_PAGE_SIZE = 100
// Defensive upper bound on fetched pages (page size 100 -> 100k items) to stop a misbehaving endpoint from looping forever
export const SONARQUBE_MAX_PAGES = 1000

// SonarQube global permission names
export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const

Expand Down
54 changes: 29 additions & 25 deletions apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ describe('sonarqubeService', () => {
addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined),
addPermissionGroup: vi.fn().mockResolvedValue(undefined),
addPermissionUser: vi.fn().mockResolvedValue(undefined),
searchUsers: vi.fn().mockResolvedValue(makeEmptyUsersResponse()),
searchUsers: vi.fn().mockImplementation(async function* () { yield* makeEmptyUsersResponse().users }),
createUser: vi.fn().mockResolvedValue(undefined),
deactivateUser: vi.fn().mockResolvedValue(undefined),
revokeUserToken: vi.fn().mockResolvedValue(undefined),
searchProject: vi.fn().mockResolvedValue(makeEmptyProjectsResponse()),
searchProject: vi.fn().mockImplementation(async function* () { yield* makeEmptyProjectsResponse().components }),
createProject: vi.fn().mockResolvedValue(undefined),
deleteProject: vi.fn().mockResolvedValue(undefined),
})
Expand Down Expand Up @@ -168,7 +168,7 @@ describe('sonarqubeService', () => {
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 })] })
client.searchUsers.mockImplementation(async function* () { yield makeSonarqubeUser({ login: project.slug }) })
vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } }))

await service.handleUpsert(project)
Expand All @@ -181,28 +181,26 @@ describe('sonarqubeService', () => {
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 })] })
client.searchUsers.mockImplementation(async function* () { yield 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' }))
expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.not.objectContaining({ SONAR_PASSWORD: expect.anything() }))
expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: expect.any(String) }))
})

it('should delete sonarqube projects for removed repositories', async () => {
const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] })
const keptKey = generateProjectKey(project.slug, 'kept')
const removedKey = generateProjectKey(project.slug, 'removed')
client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug }))
client.searchProject.mockResolvedValue({
paging: makeSonarqubePaging({ total: 2 }),
components: [
{ key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' },
{ key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' },
],
client.searchProject.mockImplementation(async function* () {
yield { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }
yield { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }
})
client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] })
client.searchUsers.mockImplementation(async function* () { yield makeSonarqubeUser({ login: project.slug }) })
vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } }))

await service.handleUpsert(project)
Expand All @@ -214,16 +212,13 @@ describe('sonarqubeService', () => {
it('should not delete sonarqube projects whose key was not generated by the console', async () => {
const project = makeProjectWithDetails({ slug: 'my', repositories: [] })
client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug }))
client.searchProject.mockResolvedValue({
paging: makeSonarqubePaging({ total: 2 }),
components: [
// manually created project, hash suffix does not match generateProjectKey
{ key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' },
// belongs to project "my-app" (repo "x"), not to project "my"
{ key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' },
],
client.searchProject.mockImplementation(async function* () {
// manually created project, hash suffix does not match generateProjectKey
yield { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }
// belongs to project "my-app" (repo "x"), not to project "my"
yield { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }
})
client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] })
client.searchUsers.mockImplementation(async function* () { yield makeSonarqubeUser({ login: project.slug }) })
vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } }))

await service.handleUpsert(project)
Expand All @@ -249,11 +244,10 @@ describe('sonarqubeService', () => {
it('should delete sonarqube projects, anonymize user and remove vault entry', async () => {
const project = makeProjectWithDetails({ slug: 'doomed' })
const doomedKey = generateProjectKey('doomed', 'repo')
client.searchProject.mockResolvedValue({
paging: makeSonarqubePaging({ total: 1 }),
components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }],
client.searchProject.mockImplementation(async function* () {
yield { key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }
})
client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] })
client.searchUsers.mockImplementation(async function* () { yield makeSonarqubeUser({ login: 'doomed' }) })

await service.handleDelete(project)

Expand All @@ -270,6 +264,16 @@ describe('sonarqubeService', () => {
expect(client.deactivateUser).not.toHaveBeenCalled()
expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user')
})

it('should use owner email when creating user', async () => {
const project = makeProjectWithDetails({ slug: 'with-owner', owner: { email: 'owner@example.com' } as any })
client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug }))
client.searchUsers.mockImplementation(async function* () {})

await service.handleUpsert(project)

expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ email: 'owner@example.com', login: 'with-owner' }))
})
})

describe('handleCron', () => {
Expand Down
Loading