From 0d572bf28a3a962829dff19f3c557ae664d00974 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 25 Aug 2026 21:58:00 +0200 Subject: [PATCH 1/4] test(server-nestjs): lock nexus/sonarqube/registry external error-path parity Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I7a048db2689f75bd0632dd1e3c23aff16a6a6964 --- .../src/modules/nexus/nexus.service.spec.ts | 73 +++++++++++++++++++ .../modules/registry/registry.service.spec.ts | 55 ++++++++++++++ .../sonarqube/sonarqube.service.spec.ts | 72 ++++++++++++++++++ 3 files changed, 200 insertions(+) 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..bf77d157b8 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -11,6 +11,7 @@ import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { NexusClientService } from './nexus-client.service' +import { NexusError } from './nexus-http-client.service' import { NexusDatastoreService } from './nexus-datastore.service' import { makeProjectWithDetails } from './nexus-testing.utils' import { @@ -228,4 +229,76 @@ 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 }, + component: { proprietaryComponents: true }, + } as any) + + 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..f05cf513af 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -8,6 +8,7 @@ import { generateProjectKey } from '../../utils/crypto.utils' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeError } from './sonarqube-http-client.service' import { SonarqubeDatastoreService } from './sonarqube-datastore.service' import { makeEmptyGroupsResponse, @@ -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 = [ From 8300614f723c90d68494c5b51a1a21bf408f979c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 28 Aug 2026 17:03:20 +0200 Subject: [PATCH 2/4] fix(server-nestjs): sort imports in parity specs Signed-off-by: William Phetsinorath Change-Id: Iaf48f76267c3288ed1801b7f70327eed6a6a6964 --- apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts | 2 +- .../src/modules/sonarqube/sonarqube.service.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 bf77d157b8..a789574e4b 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -11,8 +11,8 @@ import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { NexusClientService } from './nexus-client.service' -import { NexusError } from './nexus-http-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, 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 f05cf513af..b2cfcf4918 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -5,11 +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 { SonarqubeError } from './sonarqube-http-client.service' import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeError } from './sonarqube-http-client.service' import { makeEmptyGroupsResponse, makeEmptyProjectsResponse, @@ -21,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 From 9c6a447dab75e6dfb4c412970c1ddbc69405935c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 31 Aug 2026 11:25:06 +0200 Subject: [PATCH 3/4] refactor(test): drop as any casts in parity specs Replace NexusMavenHostedRepository as any with a fully-typed repository object and remove redundant owner as any casts in sonarqube fixtures, per server-nestjs vitest convention. Signed-off-by: William Phetsinorath Change-Id: Ibdf4763351f9871d8e8f912f1201dbb26a6a6964 --- apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts | 6 ++++-- .../src/modules/sonarqube/sonarqube.service.spec.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 a789574e4b..bfb356ed9d 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -242,9 +242,11 @@ describe('nexusService', () => { client.getRepositoriesMavenHosted.mockResolvedValue({ name: `${project.slug}-repository-release`, online: true, - storage: { blobStoreName: 'default', strictContentTypeValidation: true }, + storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' }, + cleanup: { policyNames: [] }, component: { proprietaryComponents: true }, - } as any) + maven: { versionPolicy: 'RELEASE', layoutPolicy: 'STRICT', contentDisposition: 'INLINE' }, + }) await service.handleUpsert(project) 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 b2cfcf4918..da1f9db5ce 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -219,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* () { @@ -250,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* () { @@ -343,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* () {}) From 29399eab21ab4fc4d5086aba61da9d2f71731882 Mon Sep 17 00:00:00 2001 From: Shikanime Deva Date: Thu, 10 Sep 2026 17:10:41 +0200 Subject: [PATCH 4/4] chore(test): retrigger CI after unrelated gitlab mirror-token flake