diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts index 7bb63a5097..bfb356ed9d 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -12,6 +12,7 @@ import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { NexusClientService } from './nexus-client.service' import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusError } from './nexus-http-client.service' import { makeProjectWithDetails } from './nexus-testing.utils' import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, @@ -228,4 +229,78 @@ describe('nexusService', () => { privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), })) }) + + // --- external-call error-path parity: 409 / transient 5xx / cleanup --- + // Legacy contracts: plugins/nexus/src/maven.ts (hosted create validates only [201]; + // group/privilege create validates [201,400]) and plugins/nexus/src/utils.ts (deleteIfExists + // swallows 404). Current NexusService uses GET-first idempotency and forwards 4xx/5xx once. + + it('handleUpsert updates an existing maven hosted repo instead of recreating it (idempotent, avoids 409)', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + client.getRepositoriesMavenHosted.mockResolvedValue({ + name: `${project.slug}-repository-release`, + online: true, + storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' }, + cleanup: { policyNames: [] }, + component: { proprietaryComponents: true }, + maven: { versionPolicy: 'RELEASE', layoutPolicy: 'STRICT', contentDisposition: 'INLINE' }, + }) + + await service.handleUpsert(project) + + expect(client.updateRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.createRepositoriesMavenHosted).not.toHaveBeenCalled() + }) + + it('handleUpsert propagates a 409 conflict from repo creation as a KO result', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + client.getRepositoriesMavenHosted.mockResolvedValue(null) + client.createRepositoriesMavenHosted.mockRejectedValue( + new NexusError('HttpError', 'Request failed: POST repositories/maven/hosted responded 409 Conflict', { + status: 409, + method: 'POST', + path: 'repositories/maven/hosted', + }), + ) + + const result = await service.handleUpsert(project) + // Legacy contract: plugins/nexus/src/maven.ts:51 validates only [201] for hosted repo + // creation, so a 409 surfaces as an error there too — current behaviour matches. + expect(result.nexus.status).toBe('KO') + }) + + it('handleUpsert propagates a transient 5xx (503) from a client call as KO without retrying', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + client.getRepositoriesMavenHosted.mockRejectedValue( + new NexusError('HttpError', 'Request failed: GET repositories/maven/hosted/x responded 503 Service Unavailable', { + status: 503, + method: 'GET', + path: 'repositories/maven/hosted/x', + }), + ) + + const result = await service.handleUpsert(project) + // No retry logic exists in NexusHttpClientService.fetch; the 5xx is forwarded once. + expect(result.nexus.status).toBe('KO') + }) + + it('handleDelete propagates a 5xx from repository deletion as KO (404 is swallowed by the client, 5xx is not)', async () => { + const project = makeProjectWithDetails() + client.deleteRepositoriesByName.mockRejectedValue( + new NexusError('HttpError', 'Request failed: DELETE repositories/x responded 500 Internal Server Error', { + status: 500, + method: 'DELETE', + path: 'repositories/x', + }), + ) + + const result = await service.handleDelete(project) + expect(result.nexus.status).toBe('KO') + }) }) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index ea2c620017..2617800484 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -317,4 +317,59 @@ describe('registryService', () => { expect(client.deleteProjectByName).not.toHaveBeenCalled() }) }) + + describe('external-call error paths (409 / transient 5xx / cleanup)', () => { + // Legacy contracts: plugins/harbor/src/project.ts:32 createProject GETs first with + // validateStatus:()=>true and :60 deleteProject treats 404 as already-gone. + // Current RegistryService mirrors this and forwards 4xx/5xx once (no retry). + + it('handleUpsert does not recreate an existing Harbor project (idempotent, avoids 409)', async () => { + const project = makeProjectWithDetails() + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })) + + await service.handleUpsert(project) + + expect(client.createProject).not.toHaveBeenCalled() + }) + + it('handleUpsert propagates a 409 conflict from project creation as a KO result', async () => { + const project = makeProjectWithDetails() + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + client.createProject.mockResolvedValueOnce({ status: 409, data: null }) + + const result = await service.handleUpsert(project) + // Legacy contract: plugins/harbor/src/project.ts:32 GETs first, so a 409 only occurs in a + // race; the legacy createProject surfaces it as an error too. Current behaviour matches. + expect(result.harbor.status).toBe('KO') + }) + + it('handleUpsert propagates a transient 5xx (502) from project creation as KO without retrying', async () => { + const project = makeProjectWithDetails() + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + client.createProject.mockResolvedValueOnce({ status: 502, data: null }) + + const result = await service.handleUpsert(project) + // No retry logic exists in RegistryHttpClientService.fetch; 5xx forwarded once. + expect(result.harbor.status).toBe('KO') + }) + + it('handleDelete treats a 404 on project deletion as already-gone (idempotent, returns OK)', async () => { + const project = makeProjectWithDetails() + client.getProjectByName.mockResolvedValueOnce(makeOkResponse({ project_id: 123, metadata: {} })) + client.deleteProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + + const result = await service.handleDelete(project) + // Mirrors legacy deleteProject (project.ts:60) which swallows 404 on the already-gone resource. + expect(result.harbor.status).toBe('OK') + }) + + it('handleDelete returns KO when deleting the Harbor project fails with a 5xx', async () => { + const project = makeProjectWithDetails() + client.getProjectByName.mockResolvedValueOnce(makeOkResponse({ project_id: 123, metadata: {} })) + client.deleteProjectByName.mockResolvedValueOnce({ status: HttpStatus.INTERNAL_SERVER_ERROR, data: null }) + + const result = await service.handleDelete(project) + expect(result.harbor.status).toBe('KO') + }) + }) }) 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 b040a58b46..da1f9db5ce 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { generateProjectKey } from '../../utils/crypto.utils' +import { GitlabClientService } from '../gitlab/gitlab-client.service' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeError } from './sonarqube-http-client.service' import { makeEmptyGroupsResponse, makeEmptyProjectsResponse, @@ -20,7 +22,6 @@ import { } from './sonarqube-testing.utils' import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' import { SonarqubeService } from './sonarqube.service' -import { GitlabClientService } from '../gitlab/gitlab-client.service' describe('sonarqubeService', () => { let service: SonarqubeService @@ -218,7 +219,7 @@ describe('sonarqubeService', () => { }) it('should reconcile the email of an existing robot account stamped with the owner real email (#2510 mitigation)', async () => { - const project = makeProjectWithDetails({ slug: 'with-owner', owner: { email: 'owner@example.com' } as any }) + const project = makeProjectWithDetails({ slug: 'with-owner', owner: { email: 'owner@example.com' } }) vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: 'with-owner', SONAR_PASSWORD: 'old', SONAR_TOKEN: 'old' } })) client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) client.searchUsers.mockImplementation(async function* () { @@ -249,7 +250,7 @@ describe('sonarqubeService', () => { }) it('should update both email and password when an existing robot account has a stale email and the vault secret is missing', async () => { - const project = makeProjectWithDetails({ slug: 'stale', owner: { email: 'owner@example.com' } as any }) + const project = makeProjectWithDetails({ slug: 'stale', owner: { email: 'owner@example.com' } }) vault.readSonarqubeUser.mockResolvedValue(null) client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) client.searchUsers.mockImplementation(async function* () { @@ -342,7 +343,7 @@ describe('sonarqubeService', () => { }) it('should use a per-project cloud-pi-native.fr email (never the owner real email) when creating user', async () => { - const project = makeProjectWithDetails({ slug: 'with-owner', owner: { email: 'owner@example.com' } as any }) + const project = makeProjectWithDetails({ slug: 'with-owner', owner: { email: 'owner@example.com' } }) client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) client.searchUsers.mockImplementation(async function* () {}) @@ -359,6 +360,77 @@ describe('sonarqubeService', () => { }) }) + describe('external-call error paths (409 / transient 5xx / cleanup)', () => { + // Legacy contracts: plugins/sonarqube/src/project.ts:75 createProject has no 409 handling + // and the legacy upsert hook (functions.ts:115) returns WARNING/KO on error; delete relies on + // find-then-delete. Current SonarqubeService mirrors this and forwards 4xx/5xx once. + + it('handleUpsert does not recreate an existing SonarQube project (idempotent, avoids 409)', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + const key = generateProjectKey(project.slug, 'repo') + client.searchProject.mockImplementation(async function* () { + yield { key, name: `${project.slug}-repo`, qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' } + }) + + await service.handleUpsert(project) + + expect(client.createProject).not.toHaveBeenCalled() + }) + + it('handleUpsert propagates a 409 conflict from project creation as a KO result', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.createProject.mockRejectedValue( + new SonarqubeError('ClientError', 'SonarQube API responded with status 409', { + status: 409, + method: 'POST', + path: 'projects/create', + }), + ) + + const result = await service.handleUpsert(project) + // Legacy contract: plugins/sonarqube/src/project.ts:75 createProject has no 409 handling; + // the legacy upsert hook returns KO on such an error. Current behaviour matches. + expect(result.sonarqube.status).toBe('KO') + }) + + it('handleUpsert propagates a transient 5xx (503) from a client call as KO without retrying', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.createUser.mockRejectedValue( + new SonarqubeError('ServerError', 'SonarQube API responded with status 503', { + status: 503, + method: 'POST', + path: 'users/create', + }), + ) + + const result = await service.handleUpsert(project) + // No retry logic exists in SonarqubeHttpClientService.fetch; 5xx forwarded once. + expect(result.sonarqube.status).toBe('KO') + }) + + it('handleDelete returns KO when deleting an existing SonarQube project fails with a 5xx', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') + client.searchProject.mockImplementation(async function* () { + yield { key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' } + }) + client.searchUsers.mockImplementation(async function* () {}) + client.deleteProject.mockRejectedValue( + new SonarqubeError('ServerError', 'SonarQube API responded with status 503', { + status: 503, + method: 'POST', + path: 'projects/delete', + }), + ) + + const result = await service.handleDelete(project) + expect(result.sonarqube.status).toBe('KO') + }) + }) + describe('handleCron', () => { it('should reconcile all projects and run init', async () => { const projects = [