diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts index 297533b572..cd7c321465 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts @@ -13,9 +13,17 @@ export function makeAdminTokenSelect(requirements?: AuthRequirements): Prisma.Ad return { id: true, name: true, - ...(includeAdminRoleIds ? { owner: { select: { adminRoleIds: true } } } : {}), - ...(includeUserType ? { owner: { select: { type: true } } } : {}), - ...(includeAdminRoleIds && includeUserType ? { owner: { select: { adminRoleIds: true, type: true } } } : {}), + status: true, + expirationDate: true, + permissions: true, + owner: { + select: { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + ...(includeAdminRoleIds && includeUserType ? { adminRoleIds: true, type: true } : {}), + }, + }, } satisfies Prisma.AdminTokenSelect } diff --git a/apps/server-nestjs/test/admin-role.e2e-spec.ts b/apps/server-nestjs/test/admin-role.e2e-spec.ts new file mode 100644 index 0000000000..d9062f50fe --- /dev/null +++ b/apps/server-nestjs/test/admin-role.e2e-spec.ts @@ -0,0 +1,126 @@ +import type { INestApplication } from '@nestjs/common' +import type { TestingModule } from '@nestjs/testing' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AdminRoleModule } from '../src/modules/admin-role/admin-role.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunAdminRoleE2E + = Boolean(process.env.E2E) + +const describeWithAdminRole = describe.runIf(canRunAdminRoleE2E) + +describeWithAdminRole('AdminRole HTTP (e2e)', () => { + let app: INestApplication + let baseUrl: string + let prisma: PrismaService + let tokenValue: string + + let roleId: string + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AdminRoleModule], + }).compile() + + await moduleRef.init() + prisma = moduleRef.get(PrismaService) + + app = moduleRef.createNestApplication() + await app.listen(0) + const address = app.getHttpServer().address() + if (address === null || typeof address === 'string') throw new Error('HTTP server did not report a port') + baseUrl = `http://localhost:${address.port}` + + tokenValue = faker.string.alphanumeric(48) + await prisma.user.create({ + data: { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'AdminRoleToken', + type: 'human', + adminTokens: { + create: { + name: 'e2e-admin-role', + permissions: BigInt('0b10010'), + hash: createHash('sha256').update(tokenValue).digest('hex'), + }, + }, + }, + }) + }) + + afterAll(async () => { + if (roleId) { + await prisma.adminRole.deleteMany({ where: { id: roleId } }) + } + await prisma.user.deleteMany({ where: { firstName: 'E2E', lastName: 'AdminRoleToken' } }) + await app.close() + }) + + it('rejects a create without a name with 400', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/roles`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-dso-token': tokenValue }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(400) + }) + + it('creates a role with 201 and lists it back', async () => { + const name = `e2e-${faker.string.alphanumeric({ length: 8, casing: 'lower' })}` + const response = await fetch(`${baseUrl}/api/v1/admin/roles`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-dso-token': tokenValue }, + body: JSON.stringify({ name }), + }) + + expect(response.status).toBe(201) + const created: { id: string, name: string } = await response.json() + roleId = created.id + expect(created.name).toBe(name) + + const listResponse = await fetch(`${baseUrl}/api/v1/admin/roles`, { + headers: { 'x-dso-token': tokenValue }, + }) + expect(listResponse.status).toBe(200) + const list: { id: string }[] = await listResponse.json() + expect(list.some(role => role.id === roleId)).toBe(true) + }) + + it('rejects a patch with a negative position with 400', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/roles`, { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-dso-token': tokenValue }, + body: JSON.stringify([{ id: faker.string.uuid(), position: -1 }]), + }) + + expect(response.status).toBe(400) + }) + + it('rejects a delete with a non-uuid roleId with 400', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/roles/not-an-uuid`, { + method: 'DELETE', + headers: { 'x-dso-token': tokenValue }, + }) + + expect(response.status).toBe(400) + }) + + it('deletes a role with 204', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/roles/${roleId}`, { + method: 'DELETE', + headers: { 'x-dso-token': tokenValue }, + }) + + expect(response.status).toBe(204) + roleId = '' + }) +}) diff --git a/apps/server-nestjs/test/admin-token.e2e-spec.ts b/apps/server-nestjs/test/admin-token.e2e-spec.ts index ead110615a..b5c9ec6459 100644 --- a/apps/server-nestjs/test/admin-token.e2e-spec.ts +++ b/apps/server-nestjs/test/admin-token.e2e-spec.ts @@ -1,50 +1,85 @@ import type { INestApplication } from '@nestjs/common' import type { TestingModule } from '@nestjs/testing' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { mock } from 'vitest-mock-extended' -import { AdminTokenController } from '../src/modules/admin-token/admin-token.controller' -import { AdminTokenService } from '../src/modules/admin-token/admin-token.service' -import { UserGuard } from '../src/modules/infrastructure/permission/user/user.guard' +import { baseConfigFactory } from '../src/config/base.config' +import { AdminTokenModule } from '../src/modules/admin-token/admin-token.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' -describe('AdminToken HTTP validation', () => { +const canRunAdminTokenE2E + = Boolean(process.env.E2E) + +const describeWithAdminToken = describe.runIf(canRunAdminTokenE2E) + +describeWithAdminToken('AdminToken HTTP (e2e)', () => { let app: INestApplication let baseUrl: string - - let service: ReturnType> + let prisma: PrismaService + let tokenValue: string beforeAll(async () => { - service = mock() - service.list.mockResolvedValue([]) const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [AdminTokenController], - providers: [{ provide: AdminTokenService, useValue: service }], - }) - .overrideGuard(UserGuard) - .useValue({ canActivate: () => true }) - .compile() + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AdminTokenModule], + }).compile() + + await moduleRef.init() + prisma = moduleRef.get(PrismaService) app = moduleRef.createNestApplication() await app.listen(0) const address = app.getHttpServer().address() if (address === null || typeof address === 'string') throw new Error('HTTP server did not report a port') baseUrl = `http://localhost:${address.port}` + + tokenValue = faker.string.alphanumeric(48) + await prisma.user.create({ + data: { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'AdminTokenSpec', + type: 'human', + adminTokens: { + create: { + name: 'e2e-admin-token', + permissions: BigInt('0b110000000000000000'), + hash: createHash('sha256').update(tokenValue).digest('hex'), + }, + }, + }, + }) }) afterAll(async () => { + await prisma.user.deleteMany({ where: { firstName: 'E2E', lastName: 'AdminTokenSpec' } }) await app.close() }) it('accepts GET without withRevoked', async () => { - const response = await fetch(`${baseUrl}/api/v1/admin/tokens`) + const response = await fetch(`${baseUrl}/api/v1/admin/tokens`, { + headers: { 'x-dso-token': tokenValue }, + }) expect(response.status).toBe(200) }) + it('rejects a delete with a non-uuid tokenId with 400', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/tokens/not-an-uuid`, { + method: 'DELETE', + headers: { 'x-dso-token': tokenValue }, + }) + + expect(response.status).toBe(400) + }) + it('maps a too-close expiration date to a legacy-compatible 400', async () => { const response = await fetch(`${baseUrl}/api/v1/admin/tokens`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-dso-token': tokenValue }, body: JSON.stringify({ name: 'legacy-parity', permissions: '4', expirationDate: new Date().toISOString() }), }) diff --git a/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/apps/server-nestjs/test/project-secrets.e2e-spec.ts index 037cb7e1e0..353ba03811 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -1,10 +1,12 @@ import type { TestingModule } from '@nestjs/testing' +import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { baseConfigFactory } from '../src/config/base.config' import { harborConfigFactory } from '../src/config/harbor.config' +import { nexusConfigFactory } from '../src/config/nexus.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' @@ -29,6 +31,7 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { let vaultClient: VaultClientService let projectsRootDir: string let harborUrl: string + let nexusUrl: string let ownerId: string let projectId: string @@ -54,6 +57,7 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { vaultClient = moduleRef.get(VaultClientService) projectsRootDir = moduleRef.get(baseConfigFactory.KEY).projectsRootDir harborUrl = moduleRef.get(harborConfigFactory.KEY).url + nexusUrl = moduleRef.get(nexusConfigFactory.KEY).url ownerId = faker.string.uuid() projectId = faker.string.uuid() @@ -90,6 +94,12 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { prodMemory: 0, everyonePerms: 0n, lastSuccessProvisionningVersion: null, + plugins: { + create: [ + { pluginName: 'nexus', key: 'activateNpmRepo', value: ENABLED }, + { pluginName: 'nexus', key: 'activateMavenRepo', value: ENABLED }, + ], + }, }, }) }) @@ -107,9 +117,19 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { vi.unstubAllEnvs() }) - it('returns an empty secret map when no Vault secrets exist', async () => { + it('returns the synthesized Nexus URLs and the synthetic Vault group when no Vault secrets exist', async () => { const secrets = await service.get(projectId) - expect(secrets).toEqual({}) + expect(secrets).toEqual({ + NEXUS: { + MAVEN_REPO_RELEASE: new URL(`${projectSlug}-repository-release`, nexusUrl).toString(), + MAVEN_REPO_SNAPSHOT: new URL(`${projectSlug}-repository-snapshot`, nexusUrl).toString(), + NPM_REPO: new URL(`${projectSlug}-npm`, nexusUrl).toString(), + }, + VAULT: { + '.spec.mount': projectSlug, + '.spec.vaultAuthRef': 'vault-auth', + }, + }) }) describe('when Vault secrets exist', () => { @@ -119,10 +139,6 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { key1: 'value1', key2: 42, }, `${projectPath}/GITLAB`), - vaultClient.write({ - key3: false, - key4: null, - }, `${projectPath}/NEXUS`), vaultClient.write({ REGISTRY_ROBOT_SECRET: 'robot', }, `${projectPath}/REGISTRY`), @@ -144,9 +160,11 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { key1: 'value1', key2: '42', }) + // NEXUS synthesizes repo URLs from config and per-project repo activation expect(secrets.NEXUS).toEqual({ - key3: 'false', - key4: '', + MAVEN_REPO_RELEASE: new URL(`${projectSlug}-repository-release`, nexusUrl).toString(), + MAVEN_REPO_SNAPSHOT: new URL(`${projectSlug}-repository-snapshot`, nexusUrl).toString(), + NPM_REPO: new URL(`${projectSlug}-npm`, nexusUrl).toString(), }) const harborUrlObj = new URL(`${projectSlug}/`, harborUrl) expect(secrets.REGISTRY).toEqual({ @@ -154,7 +172,6 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', () => { 'Registry base path': `${harborUrlObj.host}${harborUrlObj.pathname}`, }) expect(secrets.VAULT).toEqual({ - VAULT_ROLE: 'role', '.spec.mount': projectSlug, '.spec.vaultAuthRef': 'vault-auth', }) diff --git a/apps/server-nestjs/test/project-services.e2e-spec.ts b/apps/server-nestjs/test/project-services.e2e-spec.ts index 4a8b41f222..95df8901a6 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -16,10 +16,10 @@ import { ProjectServicesModule } from '../src/modules/project-services/project-s import { ProjectServicesService } from '../src/modules/project-services/project-services.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunServicesE2E = Boolean(process.env.E2E) -const describeWithServices = describe.runIf(canRunServicesE2E) +const canRunProjectServicesE2E = Boolean(process.env.E2E) +const describeWithProjectServices = describe.runIf(canRunProjectServicesE2E) -describeWithServices('ProjectServicesService (e2e)', () => { +describeWithProjectServices('ProjectServicesService (e2e)', () => { let moduleRef: TestingModule let prisma: PrismaService let service: ProjectServicesService @@ -28,6 +28,9 @@ describeWithServices('ProjectServicesService (e2e)', () => { let projectSlug: string beforeAll(async () => { + vi.stubEnv('USE_GITLAB', 'true') + vi.stubEnv('USE_NEXUS', 'true') + moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], }).compile()