diff --git a/README.md b/README.md index f12506b96e..b32739a60b 100644 --- a/README.md +++ b/README.md @@ -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 : 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 index f00d19d011..1041a52646 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -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' @@ -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) }) }) @@ -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) }) }) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts index e4ed2b86ab..61998ad578 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -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 @@ -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(fetchPage: (page: number, pageSize: number) => Promise): AsyncGenerator { + 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('user_groups/search', { query: params }).then(res => res.data!) @@ -246,8 +274,11 @@ export class SonarqubeClientService { } @StartActiveSpan() - searchUsers(params: SearchUsersParams) { - return this.http.fetch('users/search', { query: params }).then(res => res.data!) + async* searchUsers(params: SearchUsersParams): AsyncGenerator { + yield* this.paginate(async (page, pageSize) => { + const res = await this.http.fetch('users/search', { query: { ...params, p: page, ps: pageSize } }) + return res.data!.users + }) } @StartActiveSpan() @@ -271,8 +302,11 @@ export class SonarqubeClientService { } @StartActiveSpan() - searchProject(params: SearchProjectParams) { - return this.http.fetch('projects/search', { query: params }).then(res => res.data!) + async* searchProject(params: SearchProjectParams): AsyncGenerator { + yield* this.paginate(async (page, pageSize) => { + const res = await this.http.fetch('projects/search', { query: { ...params, p: page, ps: pageSize } }) + return res.data!.components + }) } @StartActiveSpan() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts index bcb3143972..26e824a586 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -18,6 +18,11 @@ export const projectSelect = { value: true, }, }, + owner: { + select: { + email: true, + }, + }, } satisfies Prisma.ProjectSelect export type ProjectWithDetails = Prisma.ProjectGetPayload<{ diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts index 0b7e8c0bbc..b7906ab62a 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -30,6 +30,7 @@ export function makeProjectWithDetails(overrides: Partial = slug: faker.internet.domainWord(), repositories: [], plugins: [], + owner: { email: faker.internet.email() }, ...overrides, } satisfies ProjectWithDetails } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index 9618e9341e..0935f737c6 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -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 diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index 4b69751100..629ae7e30f 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -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), }) @@ -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) @@ -181,13 +181,14 @@ 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 () => { @@ -195,14 +196,11 @@ describe('sonarqubeService', () => { 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) @@ -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) @@ -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) @@ -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', () => { diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index daab56119a..d4bd529975 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -9,6 +9,7 @@ import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' +import { getAll } from '../../utils/iterable.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -143,7 +144,7 @@ export class SonarqubeService implements OnModuleInit { span?.setAttribute('project.slug', project.slug) const rolePaths = await this.getProjectRoleGroupPaths(project) await Promise.all([ - this.ensureUser(project.slug, project.slug), + this.ensureUser(project), this.ensureProjectSonarGroups(rolePaths), this.ensureProjectRepositories(project, rolePaths), ]) @@ -154,14 +155,14 @@ export class SonarqubeService implements OnModuleInit { 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 sonarProjects = await getAll(this.findProjectsForSlug(project.slug)) + const keys = sonarProjects.map(sp => sp.key) + await Promise.all(keys.map(async (key) => { + await this.client.deleteProject({ project: key }) + this.logger.verbose(`Deleted SonarQube repository (key=${key})`) })) + span?.setAttribute('sonarqube.projects.count', keys.length) + this.logger.log(`Deleted ${keys.length} SonarQube repositories for project ${project.slug}`) const user = await this.findUser(project.slug) if (user) { @@ -197,28 +198,28 @@ export class SonarqubeService implements OnModuleInit { } @StartActiveSpan() - private async ensureUser(username: string, projectSlug: string): Promise { - const existingSecret = await this.vault.readSonarqubeUser(projectSlug) - const user = await this.findUser(username) + private async ensureUser(project: ProjectWithDetails): Promise { + const existingSecret = await this.vault.readSonarqubeUser(project.slug) + const user = await this.findUser(project.slug) let newSecret: SonarqubeUserSecret | undefined if (!user) { - this.logger.log(`Creating SonarQube user (login=${username})`) + this.logger.log(`Creating SonarQube user (login=${project.slug}, email=${project.owner.email})`) 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 } + await this.client.createUser({ email: project.owner.email, local: 'true', login: project.slug, name: project.slug, password }) + const token = await this.rotateToken(project.slug) + newSecret = { SONAR_USERNAME: project.slug, SONAR_PASSWORD: password, SONAR_TOKEN: token } } else if (existingSecret) { - this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${project.slug})`) } 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 } + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${project.slug})`) + const token = await this.rotateToken(project.slug) + newSecret = { SONAR_USERNAME: project.slug, SONAR_TOKEN: token } } if (newSecret) { - await this.vault.writeSonarqubeUser(projectSlug, newSecret) - this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + await this.vault.writeSonarqubeUser(project.slug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${project.slug})`) } } @@ -242,7 +243,7 @@ export class SonarqubeService implements OnModuleInit { const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ this.getReadonlyGroupPath(), this.getSecurityGroupPath(), - this.findProjectsForSlug(project.slug), + getAll(this.findProjectsForSlug(project.slug)), ]) const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) @@ -380,29 +381,16 @@ export class SonarqubeService implements OnModuleInit { } 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++ + for await (const user of this.client.searchUsers({ q: login })) { + if (user.login === login) return user } + return undefined } - 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 + private async* findProjectsForSlug(projectSlug: string): AsyncGenerator { + for await (const project of this.client.searchProject({ q: projectSlug })) { + yield* filterProjectsOwningSlug([project], projectSlug) + } } } 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 55a816bd02..f497f5bc7c 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -72,10 +72,15 @@ export interface VaultIdentityGroupResponse { export interface SonarqubeUserSecret { SONAR_USERNAME: string - SONAR_PASSWORD: string + SONAR_PASSWORD?: string SONAR_TOKEN: string } +export interface MirrorUserSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + export interface VaultMetadata { created_time: string custom_metadata: Record | null @@ -204,7 +209,7 @@ export class VaultClientService { } @StartActiveSpan() - async readTechnReadOnlyCreds(projectSlug: string): Promise { + async readTechnReadOnlyCreds(projectSlug: string): Promise | null> { const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() span?.setAttribute('project.slug', projectSlug) @@ -216,7 +221,7 @@ export class VaultClientService { } @StartActiveSpan() - async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { + async writeTechReadOnlyCreds(projectSlug: string, creds: MirrorUserSecret): Promise { const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() span?.setAttribute('project.slug', projectSlug) diff --git a/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/apps/server-nestjs/test/sonarqube.e2e-spec.ts index 120fbd9d87..21a287aa8f 100644 --- a/apps/server-nestjs/test/sonarqube.e2e-spec.ts +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -19,6 +19,7 @@ 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' import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' const canRunSonarqubeE2E = Boolean(process.env.E2E) @@ -143,13 +144,13 @@ describeWithSonarqube('SonarqubeService (e2e)', () => { } // Robot/CI user should exist - const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) - expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + const usersResult = await getAll(sonarqubeClient.searchUsers({ q: testProjectSlug })) + expect(usersResult.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) + const projectsResult = await getAll(sonarqubeClient.searchProject({ q: testProjectSlug })) + expect(projectsResult.some(p => p.key === projectKey)).toBe(true) // Vault credentials should be written with correct username and token const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) @@ -168,8 +169,8 @@ describeWithSonarqube('SonarqubeService (e2e)', () => { // 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) + const projectsResult = await getAll(sonarqubeClient.searchProject({ q: testProjectSlug })) + expect(projectsResult.some(p => p.key === projectKey)).toBe(false) // Vault credentials should be removed const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug)