Skip to content
Draft
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
75 changes: 75 additions & 0 deletions apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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* () {})

Expand All @@ -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 = [
Expand Down