Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"@eslint/eslintrc": "catalog:tools",
"@eslint/js": "catalog:tools",
"@faker-js/faker": "catalog:test",
"@msw/data": "catalog:test",
"@nestjs/cli": "catalog:build",
"@nestjs/schematics": "catalog:build",
"@nestjs/testing": "catalog:build",
Expand Down
59 changes: 30 additions & 29 deletions apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
import type { ConfigType } from '@nestjs/config'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { HttpStatus } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { nexusConfigFactory } from '../../config/nexus.config'
import { NexusClientService } from './nexus-client.service'
import { NexusHttpClientService } from './nexus-http-client.service'

const nexusUrl = 'https://nexus.internal'
import { makeNexusDb, makeNexusHandlers, NEXUS_INTERNAL_URL } from './nexus-testing.utils'

const server = setupServer()
const nexusAdminPassword = faker.internet.password()
const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}`

describe('nexusClientService', () => {
let service: NexusClientService
let config: DeepMockProxy<ConfigType<typeof nexusConfigFactory>>
let db: ReturnType<typeof makeNexusDb>

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

beforeEach(async () => {
config = mockDeep<ConfigType<typeof nexusConfigFactory>>({
internalUrl: nexusUrl,
db = makeNexusDb()
server.use(...makeNexusHandlers(db))

const config = mockDeep<ConfigType<typeof nexusConfigFactory>>({
internalUrl: NEXUS_INTERNAL_URL,
admin: 'admin',
adminPassword: nexusAdminPassword,
})
Expand All @@ -51,30 +49,33 @@ describe('nexusClientService', () => {
expect(service).toBeDefined()
})

it('should return null on 404 (getRepositoriesMavenHosted)', async () => {
server.use(
http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => {
expect(request.headers.get('authorization')).toBe(basicAuth)
return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND })
}),
)

it('should return null on missing repository (getRepositoriesMavenHosted)', async () => {
await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull()
})

it('should send basic auth and plain text body on change-password', async () => {
server.use(
http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => {
expect(request.method).toBe('PUT')
expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`)
expect(params.userId).toBe('u1')
expect(request.headers.get('authorization')).toBe(basicAuth)
expect(request.headers.get('content-type')).toContain('text/plain')
expect(await request.text()).toBe('pw123')
return new HttpResponse(null, { status: HttpStatus.NO_CONTENT })
}),
)
it('should read and create repositories through the fake nexus', async () => {
await db.mavenHosted.create({
name: 'maven-existing',
online: true,
storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' },
component: { proprietaryComponents: false },
maven: { versionPolicy: 'RELEASE', layoutPolicy: 'PERMISSIVE', contentDisposition: 'ATTACHMENT' },
})

await expect(service.getRepositoriesMavenHosted('maven-existing')).resolves.toMatchObject({ name: 'maven-existing' })

await service.createRepositoriesNpmHosted({
name: 'npm-hosted-new',
online: true,
storage: { blobStoreName: 'default', strictContentTypeValidation: true, writePolicy: 'ALLOW' },
cleanup: { policyNames: [] },
component: { proprietaryComponents: false },
})

await expect(service.getRepositoriesNpmHosted('npm-hosted-new')).resolves.toMatchObject({ name: 'npm-hosted-new' })
})

it('should accept a change-password call', async () => {
await service.updateSecurityUsersChangePassword('u1', 'pw123')
})
})
213 changes: 213 additions & 0 deletions apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { HttpHandler } from 'msw'
import type { ProjectWithDetails } from './nexus-datastore.service'
import { faker } from '@faker-js/faker'
import { Collection } from '@msw/data'
import { http, HttpResponse } from 'msw'
import { z } from 'zod'

export const NEXUS_INTERNAL_URL = 'https://nexus.internal'

export function makeProjectWithDetails(overrides: Partial<ProjectWithDetails> = {}): ProjectWithDetails {
return {
Expand All @@ -13,3 +19,210 @@ export function makeProjectWithDetails(overrides: Partial<ProjectWithDetails> =
...overrides,
} satisfies ProjectWithDetails
}

const repositorySchema = z.object({
name: z.string(),
online: z.boolean(),
storage: z.object({
blobStoreName: z.string(),
strictContentTypeValidation: z.boolean(),
writePolicy: z.string(),
}),
cleanup: z.object({ policyNames: z.array(z.string()) }).optional(),
component: z.object({ proprietaryComponents: z.boolean() }).optional(),
maven: z.object({
versionPolicy: z.string(),
layoutPolicy: z.string(),
contentDisposition: z.string(),
}).optional(),
group: z.object({ memberNames: z.array(z.string()) }).optional(),
})

const privilegeSchema = z.object({
name: z.string(),
description: z.string(),
actions: z.array(z.string()),
format: z.string(),
repository: z.string(),
type: z.string().optional(),
})

const roleSchema = z.object({
id: z.string(),
name: z.string(),
privileges: z.array(z.string()),
source: z.string().optional(),
roles: z.array(z.string()).optional(),
description: z.string().optional(),
})

const userSchema = z.object({
userId: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
emailAddress: z.string().optional(),
status: z.string().optional(),
roles: z.array(z.string()).optional(),
})

/**
* In-memory fake of the Nexus API surface used by NexusClientService.
* Seed the collections, pass them to makeNexusHandlers, then assert
* against the same collections in the spec.
*/
export function makeNexusDb() {
return {
mavenHosted: new Collection({ schema: repositorySchema }),
mavenGroup: new Collection({ schema: repositorySchema }),
npmHosted: new Collection({ schema: repositorySchema }),
npmGroup: new Collection({ schema: repositorySchema }),
privileges: new Collection({ schema: privilegeSchema }),
roles: new Collection({ schema: roleSchema }),
users: new Collection({ schema: userSchema }),
}
}

type NexusDb = ReturnType<typeof makeNexusDb>

async function getOr404(collection: Collection<any>, name: string) {
const data = await collection.findFirst((q: any) => q.where({ name }))
if (!data) return HttpResponse.json({}, { status: 404 })
return HttpResponse.json(data)
}

function makeNexusRepositoriesHandlers(db: NexusDb): HttpHandler[] {
const url = `${NEXUS_INTERNAL_URL}/service/rest/v1`

const upsertRepository = async (collection: Collection<any>, name: string | undefined, data: any) => {
const record = await collection.findFirst((q: any) => q.where({ name }))
if (record) await collection.update(record, { data: () => data })
else await collection.create(data)
return new HttpResponse(null, { status: 204 })
}

return [
http.get(`${url}/repositories/maven/hosted/:name`, async ({ params }) => getOr404(db.mavenHosted, String(params.name))),
http.post(`${url}/repositories/maven/hosted`, async ({ request }) => {
await db.mavenHosted.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.put(`${url}/repositories/maven/hosted/:name`, async ({ request, params }) =>
upsertRepository(db.mavenHosted, params.name as string, await request.json() as any)),
http.get(`${url}/repositories/maven/group/:name`, async ({ params }) => getOr404(db.mavenGroup, String(params.name))),
http.post(`${url}/repositories/maven/group`, async ({ request }) => {
await db.mavenGroup.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.put(`${url}/repositories/maven/group/:name`, async ({ request, params }) =>
upsertRepository(db.mavenGroup, params.name as string, await request.json() as any)),
http.get(`${url}/repositories/npm/hosted/:name`, async ({ params }) => getOr404(db.npmHosted, String(params.name))),
http.post(`${url}/repositories/npm/hosted`, async ({ request }) => {
await db.npmHosted.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.put(`${url}/repositories/npm/hosted/:name`, async ({ request, params }) =>
upsertRepository(db.npmHosted, params.name as string, await request.json() as any)),
http.get(`${url}/repositories/npm/group/:name`, async ({ params }) => getOr404(db.npmGroup, String(params.name))),
http.post(`${url}/repositories/npm/group`, async ({ request }) => {
await db.npmGroup.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.put(`${url}/repositories/npm/group/:name`, async ({ request, params }) =>
upsertRepository(db.npmGroup, params.name as string, await request.json() as any)),
http.delete(`${url}/repositories/:name`, async ({ params }) => {
await Promise.all([
db.mavenHosted.deleteMany((q: any) => q.where({ name: params.name })),
db.mavenGroup.deleteMany((q: any) => q.where({ name: params.name })),
db.npmHosted.deleteMany((q: any) => q.where({ name: params.name })),
db.npmGroup.deleteMany((q: any) => q.where({ name: params.name })),
])
return new HttpResponse(null, { status: 204 })
}),
]
}

function makeNexusPrivilegesHandlers(db: NexusDb): HttpHandler[] {
const url = `${NEXUS_INTERNAL_URL}/service/rest/v1`

const upsertPrivilege = async (name: string, data: any) => {
const record = await db.privileges.findFirst((q: any) => q.where({ name }))
if (record) await db.privileges.update(record, { data: () => data })
else await db.privileges.create(data)
return new HttpResponse(null, { status: 204 })
}

return [
http.get(`${url}/security/privileges/:name`, async ({ params }) => getOr404(db.privileges, String(params.name))),
http.post(`${url}/security/privileges/repository-view`, async ({ request }) => {
await db.privileges.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.put(`${url}/security/privileges/repository-view/:name`, async ({ request, params }) =>
upsertPrivilege(params.name as string, await request.json() as any)),
http.delete(`${url}/security/privileges/:name`, async ({ params }) => {
await db.privileges.deleteMany((q: any) => q.where({ name: params.name }))
return new HttpResponse(null, { status: 204 })
}),
]
}

function makeNexusRolesHandlers(db: NexusDb): HttpHandler[] {
const url = `${NEXUS_INTERNAL_URL}/service/rest/v1`

const upsertRole = async (id: string, data: any) => {
const record = await db.roles.findFirst((q: any) => q.where({ id }))
if (record) await db.roles.update(record, { data: () => data })
else await db.roles.create(data)
return new HttpResponse(null, { status: 204 })
}

return [
http.get(`${url}/security/roles/:id`, async ({ params }) => {
const data = await db.roles.findFirst((q: any) => q.where({ id: params.id }))
if (!data) return HttpResponse.json({}, { status: 404 })
return HttpResponse.json(data)
}),
http.post(`${url}/security/roles`, async ({ request }) => {
await db.roles.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.put(`${url}/security/roles/:id`, async ({ request, params }) =>
upsertRole(params.id as string, await request.json() as any)),
http.delete(`${url}/security/roles/:id`, async ({ params }) => {
await db.roles.deleteMany((q: any) => q.where({ id: params.id }))
return new HttpResponse(null, { status: 204 })
}),
]
}

function makeNexusSecurityHandlers(db: NexusDb): HttpHandler[] {
const url = `${NEXUS_INTERNAL_URL}/service/rest/v1`

return [
http.get(`${url}/security/users`, async ({ request }) => {
const userId = new URL(request.url).searchParams.get('userId')
const users = userId
? await db.users.findMany((q: any) => q.where({ userId }))
: await db.users.findMany()
return HttpResponse.json(users)
}),
http.put(`${url}/security/users/:userId/change-password`, () => new HttpResponse(null, { status: 204 })),
http.post(`${url}/security/users`, async ({ request }) => {
await db.users.create(await request.json() as any)
return new HttpResponse(null, { status: 204 })
}),
http.delete(`${url}/security/users/:userId`, async ({ params }) => {
await db.users.deleteMany((q: any) => q.where({ userId: params.userId }))
return new HttpResponse(null, { status: 204 })
}),
]
}

export function makeNexusHandlers(db: NexusDb): HttpHandler[] {
return [
...makeNexusRepositoriesHandlers(db),
...makeNexusPrivilegesHandlers(db),
...makeNexusRolesHandlers(db),
...makeNexusSecurityHandlers(db),
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('projectService', () => {
prisma.$transaction.mockImplementation(async cb => cb(tx))

const pwd = makeProjectWithDetails({ slug: expectedSlug })
pwd.roles = Array.from({ length: 4 }, (_, index) => ({
pwd.roles = Array.from({ length: 5 }, (_, index) => ({
id: faker.string.uuid(),
name: `role-${index}`,
permissions: 0n,
Expand All @@ -100,7 +100,7 @@ describe('projectService', () => {
userId,
requestId,
})
expect(logs.addLog).toHaveBeenCalledTimes(4)
expect(logs.addLog).toHaveBeenCalledTimes(5)
expect(result).toBeDefined()
expect(result.slug).toBe(expectedSlug)
})
Expand Down
15 changes: 12 additions & 3 deletions apps/server-nestjs/src/modules/project/project.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,28 @@ export function generateProjectCreateInput(
oidcGroup: `/${slug}/console/devops`,
type: 'system:managed',
},
{
name: 'Sécurité',
permissions: PROJECT_PERMS.SEE_SECRETS
| PROJECT_PERMS.LIST_ENVIRONMENTS
| PROJECT_PERMS.LIST_REPOSITORIES,
position: 2,
oidcGroup: `/${slug}/console/security`,
type: 'system:managed',
},
{
name: 'Développeur',
permissions: PROJECT_PERMS.MANAGE_REPOSITORIES
| PROJECT_PERMS.LIST_ENVIRONMENTS
| PROJECT_PERMS.LIST_REPOSITORIES,
position: 2,
position: 3,
oidcGroup: `/${slug}/console/developer`,
type: 'system:managed',
},
{
name: 'Lecture seule',
permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
position: 3,
position: 4,
oidcGroup: `/${slug}/console/readonly`,
type: 'system:managed',
},
Expand Down Expand Up @@ -223,4 +232,4 @@ export function parseProjectUpdateInput(effectiveData: Record<string, unknown>):

function parseCsvEnumList<T extends readonly string[]>(toMatch: T, inputs: string): T[number][] {
return inputs.split(',').filter(i => toMatch.includes(i))
}
}
Loading
Loading