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
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,61 @@ describe('nexusClientService', () => {

await service.updateSecurityUsersChangePassword('u1', 'pw123')
})

it('should re-fetch the existing role when ensureSecurityRoles hits a 409', async () => {
const role = { id: 'proj-role-id', name: 'proj-role-id', description: 'desc', privileges: ['nx-app'] }
server.use(
http.post(`${nexusUrl}/service/rest/v1/security/roles`, () =>
HttpResponse.json({ errorMessage: 'Role already exists' }, { status: HttpStatus.CONFLICT })),
http.get(`${nexusUrl}/service/rest/v1/security/roles/:id`, () => HttpResponse.json(role)),
)

await expect(service.ensureSecurityRoles(role)).resolves.toEqual(role)
})

it('should rethrow non-collision errors from ensureSecurityRoles without re-fetching', async () => {
let fetches = 0
server.use(
http.post(`${nexusUrl}/service/rest/v1/security/roles`, () => {
fetches++
return HttpResponse.json({ errorMessage: 'Internal error' }, { status: HttpStatus.INTERNAL_SERVER_ERROR })
}),
)

await expect(service.ensureSecurityRoles({ id: 'r', name: 'r', description: 'desc', privileges: [] }))
.rejects.toThrow('responded 500')
expect(fetches).toBe(1)
})

it('should re-fetch the existing repository when ensureRepositoriesMavenHosted hits a 400 already-exists', async () => {
const repo = {
name: 'proj-hosted',
online: true,
storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' },
component: { proprietaryComponents: true },
maven: { versionPolicy: 'MIXED', layoutPolicy: 'STRICT', contentDisposition: 'ATTACHMENT' },
}
server.use(
http.post(`${nexusUrl}/service/rest/v1/repositories/maven/hosted`, () =>
new HttpResponse(null, { status: HttpStatus.BAD_REQUEST, statusText: 'Repository already exists' })),
http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, () => HttpResponse.json(repo)),
)

await expect(service.ensureRepositoriesMavenHosted(repo)).resolves.toEqual(repo)
})

it('should rethrow a 400 without an already-exists message from ensureRepositoriesMavenHosted', async () => {
server.use(
http.post(`${nexusUrl}/service/rest/v1/repositories/maven/hosted`, () =>
new HttpResponse(null, { status: HttpStatus.BAD_REQUEST, statusText: 'Bad Request' })),
)

await expect(service.ensureRepositoriesMavenHosted({
name: 'proj-hosted',
online: true,
storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' },
component: { proprietaryComponents: true },
maven: { versionPolicy: 'MIXED', layoutPolicy: 'STRICT', contentDisposition: 'ATTACHMENT' },
})).rejects.toThrow('responded 400')
})
})
86 changes: 70 additions & 16 deletions apps/server-nestjs/src/modules/nexus/nexus-client.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'
import { HttpStatus, Inject, Injectable } from '@nestjs/common'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { NexusHttpClientService } from './nexus-http-client.service'
import { NexusError, NexusHttpClientService } from './nexus-http-client.service'
import { isNexusNotFound } from './nexus.utils'

interface NexusRepositoryStorage {
Expand Down Expand Up @@ -121,8 +121,14 @@ export class NexusClientService {
}

@StartActiveSpan()
async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) {
await this.http.fetch('repositories/maven/hosted', { method: 'POST', body })
async ensureRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest): Promise<NexusMavenHostedRepository | undefined> {
try {
await this.http.fetch('repositories/maven/hosted', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
return await this.getRepositoriesMavenHosted(body.name) ?? undefined
}
}

@StartActiveSpan()
Expand All @@ -131,8 +137,14 @@ export class NexusClientService {
}

@StartActiveSpan()
async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) {
await this.http.fetch('repositories/maven/group', { method: 'POST', body })
async ensureRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest): Promise<NexusMavenGroupRepository | undefined> {
try {
await this.http.fetch('repositories/maven/group', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
return await this.getRepositoriesMavenGroup(body.name) ?? undefined
}
}

@StartActiveSpan()
Expand Down Expand Up @@ -163,8 +175,14 @@ export class NexusClientService {
}

@StartActiveSpan()
async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) {
await this.http.fetch('repositories/npm/hosted', { method: 'POST', body })
async ensureRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest): Promise<NexusNpmHostedRepository | undefined> {
try {
await this.http.fetch('repositories/npm/hosted', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
return await this.getRepositoriesNpmHosted(body.name) ?? undefined
}
}

@StartActiveSpan()
Expand All @@ -184,8 +202,14 @@ export class NexusClientService {
}

@StartActiveSpan()
async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) {
await this.http.fetch('repositories/npm/group', { method: 'POST', body })
async ensureRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest): Promise<NexusNpmGroupRepository | undefined> {
try {
await this.http.fetch('repositories/npm/group', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
return await this.getRepositoriesNpmGroup(body.name) ?? undefined
}
}

@StartActiveSpan()
Expand All @@ -205,8 +229,14 @@ export class NexusClientService {
}

@StartActiveSpan()
async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) {
await this.http.fetch('security/privileges/repository-view', { method: 'POST', body })
async ensureSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest): Promise<NexusPrivilege | undefined> {
try {
await this.http.fetch('security/privileges/repository-view', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
return await this.getSecurityPrivileges(body.name) ?? undefined
}
}

@StartActiveSpan()
Expand Down Expand Up @@ -236,8 +266,14 @@ export class NexusClientService {
}

@StartActiveSpan()
async createSecurityRoles(body: NexusRoleCreateRequest) {
await this.http.fetch('security/roles', { method: 'POST', body })
async ensureSecurityRoles(body: NexusRoleCreateRequest): Promise<NexusRole | undefined> {
try {
await this.http.fetch('security/roles', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
return await this.getSecurityRoles(body.id) ?? undefined
}
}

@StartActiveSpan()
Expand Down Expand Up @@ -272,8 +308,15 @@ export class NexusClientService {
}

@StartActiveSpan()
async createSecurityUsers(body: NexusUserCreateRequest) {
await this.http.fetch('security/users', { method: 'POST', body })
async ensureSecurityUsers(body: NexusUserCreateRequest): Promise<{ userId: string } | undefined> {
try {
await this.http.fetch('security/users', { method: 'POST', body })
return undefined
} catch (error) {
if (!this.isAlreadyExistsError(error)) throw error
const users = await this.getSecurityUsers(body.userId)
return users.find(user => user.userId === body.userId)
}
}

@StartActiveSpan()
Expand All @@ -295,4 +338,15 @@ export class NexusClientService {
throw error
}
}

/**
* A concurrent reconciliation may have created the resource between the
* caller's GET and this POST; treat that collision as "already exists" and
* let the caller reconcile via its update branch instead of failing the sync.
*/
private isAlreadyExistsError(error: unknown): error is NexusError {
if (!(error instanceof NexusError)) return false
if (error.status === HttpStatus.CONFLICT) return true
return error.status !== undefined && error.status >= 400 && error.status < 500 && /already|exists/i.test(error.message)
}
}
14 changes: 7 additions & 7 deletions apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('nexusService', () => {

await service.handleUpsert(project)

expect(client.createRepositoriesMavenHosted).toHaveBeenCalled()
expect(client.ensureRepositoriesMavenHosted).toHaveBeenCalled()
expect(client.deleteRepositoriesByName).toHaveBeenCalled()
expect(vault.write).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -113,7 +113,7 @@ describe('nexusService', () => {

await service.handleCron()

expect(client.createSecurityUsers).toHaveBeenCalledTimes(2)
expect(client.ensureSecurityUsers).toHaveBeenCalledTimes(2)
})

it('reuses existing vault password at the new path and does not rotate', async () => {
Expand All @@ -134,7 +134,7 @@ describe('nexusService', () => {
await service.handleUpsert(project)

expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled()
expect(client.createSecurityUsers).not.toHaveBeenCalled()
expect(client.ensureSecurityUsers).not.toHaveBeenCalled()
expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({
NEXUS_USERNAME: project.slug,
NEXUS_PASSWORD: 'existing',
Expand Down Expand Up @@ -162,7 +162,7 @@ describe('nexusService', () => {
await service.handleUpsert(project)

expect(client.updateSecurityUsersChangePassword).toHaveBeenCalledWith(project.slug, expect.any(String))
expect(client.createSecurityUsers).not.toHaveBeenCalled()
expect(client.ensureSecurityUsers).not.toHaveBeenCalled()
expect(vault.write).toHaveBeenCalledWith(
expect.objectContaining({
NEXUS_USERNAME: project.slug,
Expand Down Expand Up @@ -196,13 +196,13 @@ describe('nexusService', () => {
})

datastore.getAllProjects.mockResolvedValue([project, staleProject])
client.createSecurityRoles.mockImplementation(async (body) => {
client.ensureSecurityRoles.mockImplementation(async (body) => {
if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request')
})

await expect(service.handleUpsert(project)).resolves.not.toThrow()

expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' }))
expect(client.ensureSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' }))
})

it('dedupes project group roles by role id and keeps the highest privileges', async () => {
Expand All @@ -223,7 +223,7 @@ describe('nexusService', () => {

await service.handleUpsert(project)

expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({
expect(client.ensureSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({
id: `${project.slug}-console-devops`,
privileges: expect.arrayContaining([`${project.slug}-privilege-group`]),
}))
Expand Down
16 changes: 8 additions & 8 deletions apps/server-nestjs/src/modules/nexus/nexus.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export class NexusService {
private async upsertPrivilege(body: NexusPrivilege) {
const existing = await this.client.getSecurityPrivileges(body.name)
if (!existing) {
await this.client.createSecurityPrivilegesRepositoryView(body)
await this.client.ensureSecurityPrivilegesRepositoryView(body)
return
}
await this.client.updateSecurityPrivilegesRepositoryView(body.name, body)
Expand All @@ -194,7 +194,7 @@ export class NexusService {
},
}
if (!existing) {
await this.client.createRepositoriesMavenHosted(body)
await this.client.ensureRepositoriesMavenHosted(body)
return
}
await this.client.updateRepositoriesMavenHosted(repoName, body)
Expand All @@ -213,7 +213,7 @@ export class NexusService {
component: { proprietaryComponents: true },
}
if (!existing) {
await this.client.createRepositoriesNpmHosted(body)
await this.client.ensureRepositoriesNpmHosted(body)
return
}
await this.client.updateRepositoriesNpmHosted(repoName, body)
Expand All @@ -233,7 +233,7 @@ export class NexusService {
},
}
if (!existing) {
await this.client.postRepositoriesNpmGroup(body)
await this.client.ensureRepositoriesNpmGroup(body)
return
}
await this.client.putRepositoriesNpmGroup(repoName, body)
Expand Down Expand Up @@ -317,7 +317,7 @@ export class NexusService {
},
}
if (!existing) {
await this.client.createRepositoriesMavenGroup(body)
await this.client.ensureRepositoriesMavenGroup(body)
return
}
await this.client.updateRepositoriesMavenGroup(repoName, body)
Expand Down Expand Up @@ -416,7 +416,7 @@ export class NexusService {
const roleId = `${project.slug}-ID`
const role = await this.client.getSecurityRoles(roleId)
if (!role) {
await this.client.createSecurityRoles({
await this.client.ensureSecurityRoles({
id: roleId,
name: `${project.slug}-role`,
description: 'desc',
Expand Down Expand Up @@ -452,7 +452,7 @@ export class NexusService {
await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword)
}
} else {
await this.client.createSecurityUsers({
await this.client.ensureSecurityUsers({
userId: project.slug,
firstName: project.owner.firstName,
lastName: project.owner.lastName,
Expand All @@ -472,7 +472,7 @@ export class NexusService {
private async ensureSecurityRole(id: string, privileges: string[]) {
const role = await this.client.getSecurityRoles(id)
if (!role) {
await this.client.createSecurityRoles({
await this.client.ensureSecurityRoles({
id,
name: id,
description: 'desc',
Expand Down
Loading