From 487221449f2bd4195ec68d04015e75ab0e96beb1 Mon Sep 17 00:00:00 2001 From: Shikanime Deva Date: Thu, 25 Jun 2026 15:08:47 +0200 Subject: [PATCH] refactor(server-nestjs): migrate project route Change-Id: Ia8e16b188470eb80764717c3cd8068106a6a6964 Signed-off-by: William Phetsinorath Signed-off-by: Shikanime Deva --- apps/server-nestjs/src/main.module.ts | 14 +- .../infrastructure/events/events.module.ts | 8 + .../infrastructure/infrastructure.module.ts | 5 +- .../src/modules/log/log-queries.utils.ts | 30 + ....testing.utils.ts => log-testing.utils.ts} | 0 .../src/modules/log/log.service.spec.ts | 2 +- .../src/modules/log/log.service.ts | 20 +- .../project-hooks-queries.utils.ts | 140 ----- .../project-hooks/project-hooks.controller.ts | 4 +- .../project-hooks.service.spec.ts | 4 +- .../project-hooks/project-hooks.service.ts | 44 +- .../project-members-queries.utils.ts | 9 +- .../project-members.controller.ts | 24 +- .../project-members.service.spec.ts | 30 +- .../project-members.service.ts | 12 +- ...tils.ts => project-roles-queries.utils.ts} | 12 +- .../project-roles/project-roles.controller.ts | 20 +- .../project-roles/project-roles.module.ts | 1 + .../project-roles.service.spec.ts | 18 +- .../project-roles/project-roles.service.ts | 18 +- .../project-roles/project-roles.utils.ts | 2 +- .../project-secrets-queries.utils.ts | 13 + .../project-secrets.service.ts | 10 +- .../project-services-queries.utils.ts | 116 ++++ .../project-services-testing.utils.ts} | 52 +- .../project-services.controller.ts} | 10 +- .../project-services.module.ts | 13 + .../project-services.service.spec.ts} | 37 +- .../project-services.service.ts} | 35 +- .../project-services.utils.ts} | 28 - .../project/project-datastore.service.spec.ts | 88 --- .../project/project-datastore.service.ts | 98 ---- .../modules/project/project-queries.utils.ts | 146 ++++- .../modules/project/project-testing.utils.ts | 31 +- .../src/modules/project/project.controller.ts | 91 +++ .../src/modules/project/project.module.ts | 12 +- .../modules/project/project.service.spec.ts | 526 ++++++++++++++++-- .../src/modules/project/project.service.ts | 358 +++++++++++- .../src/modules/project/project.utils.ts | 226 ++++++++ .../src/modules/services/services.module.ts | 13 - apps/server-nestjs/test/log.e2e-spec.ts | 8 +- .../test/project-hooks.e2e-spec.ts | 15 +- .../test/project-members.e2e-spec.ts | 37 +- .../test/project-roles.e2e-spec.ts | 41 +- .../test/project-secrets.e2e-spec.ts | 10 - .../test/project-services.e2e-spec.ts | 141 +++++ apps/server-nestjs/test/project.e2e-spec.ts | 147 +++++ apps/server-nestjs/test/services.e2e-spec.ts | 199 ------- apps/server-nestjs/test/vault.e2e-spec.ts | 1 - 49 files changed, 2021 insertions(+), 898 deletions(-) create mode 100644 apps/server-nestjs/src/modules/infrastructure/events/events.module.ts create mode 100644 apps/server-nestjs/src/modules/log/log-queries.utils.ts rename apps/server-nestjs/src/modules/log/{log.testing.utils.ts => log-testing.utils.ts} (100%) delete mode 100644 apps/server-nestjs/src/modules/project-hooks/project-hooks-queries.utils.ts rename apps/server-nestjs/src/modules/project-roles/{project-roles.queries.utils.ts => project-roles-queries.utils.ts} (72%) create mode 100644 apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts create mode 100644 apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts rename apps/server-nestjs/src/modules/{services/services-testing.utils.ts => project-services/project-services-testing.utils.ts} (66%) rename apps/server-nestjs/src/modules/{services/services.controller.ts => project-services/project-services.controller.ts} (86%) create mode 100644 apps/server-nestjs/src/modules/project-services/project-services.module.ts rename apps/server-nestjs/src/modules/{services/services.service.spec.ts => project-services/project-services.service.spec.ts} (85%) rename apps/server-nestjs/src/modules/{services/services.service.ts => project-services/project-services.service.ts} (82%) rename apps/server-nestjs/src/modules/{services/services.utils.ts => project-services/project-services.utils.ts} (89%) delete mode 100644 apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts delete mode 100644 apps/server-nestjs/src/modules/project/project-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/project/project.controller.ts create mode 100644 apps/server-nestjs/src/modules/project/project.utils.ts delete mode 100644 apps/server-nestjs/src/modules/services/services.module.ts create mode 100644 apps/server-nestjs/test/project-services.e2e-spec.ts create mode 100644 apps/server-nestjs/test/project.e2e-spec.ts delete mode 100644 apps/server-nestjs/test/services.e2e-spec.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index a0dc901c88..a2ef47eee2 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common' -import { EventEmitterModule } from '@nestjs/event-emitter' import { ScheduleModule } from '@nestjs/schedule' import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { LogModule } from './modules/log/log.module' +import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' +import { ProjectRolesModule } from './modules/project-roles/project-roles.module' +import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' +import { ProjectServicesModule } from './modules/project-services/project-services.module' import { ProjectModule } from './modules/project/project.module' import { ServiceChainModule } from './modules/service-chain/service-chain.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' @@ -11,13 +16,18 @@ import { VersionModule } from './modules/version/version.module' @Module({ imports: [ - EventEmitterModule.forRoot(), HealthzModule, KeycloakModule, ScheduleModule.forRoot(), SystemSettingsModule, ServiceChainModule, ProjectModule, + ProjectHooksModule, + ProjectSecretsModule, + ProjectServicesModule, + ProjectMembersModule, + ProjectRolesModule, + LogModule, DeploymentModule, VersionModule, ], diff --git a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts new file mode 100644 index 0000000000..b280084626 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' + +@Module({ + imports: [EventEmitterModule.forRoot()], + exports: [EventEmitterModule], +}) +export class EventsModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts index 56585ae204..16bf5bb20d 100644 --- a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common' import { AuthModule } from './auth/auth.module' import { ConfigurationModule } from './configuration/configuration.module' import { DatabaseModule } from './database/database.module' +import { EventsModule } from './events/events.module' import { LoggerModule } from './logger/logger.module' import { PermissionModule } from './permission/permission.module' @Module({ providers: [], - imports: [AuthModule, PermissionModule, DatabaseModule, LoggerModule, ConfigurationModule], - exports: [AuthModule, DatabaseModule, PermissionModule, ConfigurationModule], + imports: [AuthModule, PermissionModule, DatabaseModule, LoggerModule, ConfigurationModule, EventsModule], + exports: [AuthModule, DatabaseModule, PermissionModule, ConfigurationModule, EventsModule], }) export class InfrastructureModule {} diff --git a/apps/server-nestjs/src/modules/log/log-queries.utils.ts b/apps/server-nestjs/src/modules/log/log-queries.utils.ts new file mode 100644 index 0000000000..7c8e6de59f --- /dev/null +++ b/apps/server-nestjs/src/modules/log/log-queries.utils.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +const logSelect = { + id: true, + createdAt: true, + updatedAt: true, + action: true, + userId: true, + requestId: true, + projectId: true, + data: true, +} satisfies Prisma.LogSelect + +export type LogSelect = Prisma.LogGetPayload<{ + select: typeof logSelect +}> + +export function countLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined) { + return tx.log.count({ where: { projectId } }) +} + +export function listLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined, offset: number, limit: number) { + return tx.log.findMany({ + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + where: { projectId }, + select: logSelect, + }) +} diff --git a/apps/server-nestjs/src/modules/log/log.testing.utils.ts b/apps/server-nestjs/src/modules/log/log-testing.utils.ts similarity index 100% rename from apps/server-nestjs/src/modules/log/log.testing.utils.ts rename to apps/server-nestjs/src/modules/log/log-testing.utils.ts diff --git a/apps/server-nestjs/src/modules/log/log.service.spec.ts b/apps/server-nestjs/src/modules/log/log.service.spec.ts index 60a93a5654..608bafbdbd 100644 --- a/apps/server-nestjs/src/modules/log/log.service.spec.ts +++ b/apps/server-nestjs/src/modules/log/log.service.spec.ts @@ -4,8 +4,8 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeLog } from './log-testing.utils' import { LogService } from './log.service' -import { makeLog } from './log.testing.utils' describe('logService', () => { let module: TestingModule diff --git a/apps/server-nestjs/src/modules/log/log.service.ts b/apps/server-nestjs/src/modules/log/log.service.ts index 07aab5d9fa..1cd8a36b38 100644 --- a/apps/server-nestjs/src/modules/log/log.service.ts +++ b/apps/server-nestjs/src/modules/log/log.service.ts @@ -3,6 +3,7 @@ import type { Prisma } from '@prisma/client' import { CleanLogSchema, exclude } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' +import { countLogs, listLogs } from './log-queries.utils' interface AddLogArgs { action: string @@ -19,15 +20,7 @@ export class LogService { ) {} async getLogs(params: AdminLogsQuery) { - const [total, logs] = await this.prisma.$transaction([ - this.prisma.log.count({ where: { projectId: params.projectId } }), - this.prisma.log.findMany({ - orderBy: { createdAt: 'desc' }, - skip: params.offset, - take: params.limit, - where: { projectId: params.projectId }, - }), - ]) + const [total, logs] = await this.getLogsWithCount(params.projectId, params.offset, params.limit) return { total, @@ -37,6 +30,13 @@ export class LogService { } } + async getLogsWithCount(projectId: string | null | undefined, offset: number, limit: number) { + return this.prisma.$transaction([ + countLogs(this.prisma, projectId), + listLogs(this.prisma, projectId, offset, limit), + ]) + } + async addLog({ action, data, @@ -50,7 +50,7 @@ export class LogService { userId, requestId, projectId, - data: exclude(data, ['cluster', 'user', 'newCreds', 'apis']), + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis', 'config']), }, }) } diff --git a/apps/server-nestjs/src/modules/project-hooks/project-hooks-queries.utils.ts b/apps/server-nestjs/src/modules/project-hooks/project-hooks-queries.utils.ts deleted file mode 100644 index 306d9960d5..0000000000 --- a/apps/server-nestjs/src/modules/project-hooks/project-hooks-queries.utils.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Prisma } from '@prisma/client' - -export const projectSelect = { - id: true, - name: true, - slug: true, - locked: true, - owner: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - adminRoleIds: true, - type: true, - createdAt: true, - updatedAt: true, - lastLogin: true, - }, - }, - members: { - select: { - roleIds: true, - user: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - adminRoleIds: true, - type: true, - createdAt: true, - updatedAt: true, - lastLogin: true, - }, - }, - }, - }, - plugins: { - select: { - pluginName: true, - key: true, - value: true, - }, - }, - roles: { - select: { - id: true, - name: true, - permissions: true, - position: true, - oidcGroup: true, - type: true, - projectId: true, - }, - }, - repositories: { - select: { - id: true, - internalRepoName: true, - isInfra: true, - isPrivate: true, - externalRepoUrl: true, - externalUserName: true, - helmValuesFiles: true, - deployRevision: true, - deployPath: true, - createdAt: true, - updatedAt: true, - }, - }, - environments: { - select: { - id: true, - name: true, - cpu: true, - gpu: true, - memory: true, - autosync: true, - clusterId: true, - stageId: true, - cluster: { - select: { - id: true, - label: true, - zone: { - select: { - slug: true, - }, - }, - }, - }, - }, - }, - deployments: { - select: { - id: true, - name: true, - autosync: true, - createdAt: true, - updatedAt: true, - environment: { - select: { - id: true, - name: true, - cluster: { - select: { - id: true, - label: true, - zone: { - select: { - slug: true, - }, - }, - }, - }, - cpu: true, - gpu: true, - memory: true, - autosync: true, - }, - }, - deploymentSources: { - select: { - id: true, - type: true, - path: true, - targetRevision: true, - helmValuesFiles: true, - repository: { - select: { - id: true, - internalRepoName: true, - }, - }, - }, - }, - }, - }, -} satisfies Prisma.ProjectSelect diff --git a/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts b/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts index 027907cda4..9dd8e67e34 100644 --- a/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts +++ b/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts @@ -22,11 +22,11 @@ export class ProjectHooksController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ReplayHooks') - async replayHooks( + async replay( @Project() project: ProjectContext, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { - await this.projectHooks.replayHooks(project.id, user.userId, request.id) + await this.projectHooks.replay(project.id, user.userId, request.id) } } diff --git a/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts b/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts index 4c3717f758..348a26cd40 100644 --- a/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts @@ -61,7 +61,7 @@ describe('projectHooksService', () => { const requestId = 'request-id' const userId = 'user-id' - await service.replayHooks('project-id', userId, requestId) + await service.replay('project-id', userId, requestId) expect(prisma.project.findFirst).toHaveBeenCalledWith({ where: { id: 'project-id', status: { not: 'archived' } }, @@ -80,7 +80,7 @@ describe('projectHooksService', () => { const project = makeProject({ locked: true }) prisma.project.findFirst.mockResolvedValue(project) - await expect(service.replayHooks('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) + await expect(service.replay('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) expect(eventEmitter.emitAsync).not.toHaveBeenCalled() expect(logs.addLog).not.toHaveBeenCalled() diff --git a/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts b/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts index cb61a2819f..d32205032d 100644 --- a/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts +++ b/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts @@ -1,3 +1,4 @@ +import type { ProjectWithDetails } from '../project/project-queries.utils' import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' @@ -15,6 +16,26 @@ export class ProjectHooksService { @Inject(LogService) private readonly logs: LogService, ) {} + private async logProjectAction( + action: string, + project: ProjectWithDetails, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id }, + messageResume, + results: { projectId: project.id, slug: project.slug }, + }, + userId, + requestId, + projectId: project.id, + }) + } + async updateProjectLocked(projectId: string, locked: boolean): Promise { const project = await this.prisma.project.update({ where: { id: projectId }, @@ -24,7 +45,7 @@ export class ProjectHooksService { await this.eventEmitter.emitAsync('project.upsert', project) } - async replayHooks(projectId: string, requestorUserId?: string, requestId?: string): Promise { + async replay(projectId: string, requestorUserId?: string, requestId?: string): Promise { const span = trace.getActiveSpan() span?.setAttribute('project.id', projectId) this.logger.log(`project.replayHooks started (projectId=${projectId})`) @@ -39,22 +60,13 @@ export class ProjectHooksService { } span?.setAttribute('project.slug', project.slug) await this.eventEmitter.emitAsync('project.upsert', project) - await this.logs.addLog({ - action: 'Replay hooks for Project', - data: { - args: { - projectId, - }, - messageResume: `Hooks du projet rejoués: ${project.slug}`, - results: { - projectId: project.id, - slug: project.slug, - }, - }, - userId: requestorUserId, + await this.logProjectAction( + 'Replay hooks for Project', + project, + `Hooks du projet rejoués: ${project.slug}`, + requestorUserId, requestId, - projectId: project.id, - }) + ) this.logger.log(`project.replayHooks completed (projectId=${projectId})`) } } diff --git a/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts index 141794bcf3..ad12c2486d 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -25,10 +25,9 @@ export type ProjectOwnerId = Prisma.ProjectGetPayload<{ export type AddMemberInput = { email: string } | { userId: string } export interface PatchMemberInput { userId: string, roles: string[] } -export type PatchMembersInput = PatchMemberInput[] -export function listProjectMembersWithUser(db: Prisma.TransactionClient, projectId: string) { - return db.projectMembers.findMany({ +export function listProjectMembersWithUser(tx: Prisma.TransactionClient, projectId: string) { + return tx.projectMembers.findMany({ where: { projectId }, include: projectMemberWithUser, }) @@ -74,6 +73,6 @@ export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: stri }) } -export function getProjectOwnerId(db: Prisma.TransactionClient, projectId: string) { - return db.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +export function getProjectOwnerId(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) } diff --git a/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/apps/server-nestjs/src/modules/project-members/project-members.controller.ts index 8ac32180ff..1761096669 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.controller.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -1,6 +1,6 @@ import type { Member } from '@cpn-console/shared' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' -import type { AddMemberInput, PatchMembersInput } from './project-members-queries.utils' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' import { projectMemberContract } from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' @@ -23,10 +23,10 @@ export class ProjectMembersController { @Get() @RequireProjectPermission('ListMembers') - async listMembers( + async list( @Project() project: ProjectContext, ): Promise { - return (await this.projectMembers.listMembers(project.id)).map(generateProjectMember) + return (await this.projectMembers.list(project.id)).map(generateProjectMember) } @Post() @@ -34,12 +34,12 @@ export class ProjectMembersController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ManageMembers') - async addMember( + async add( @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: AddMemberInput, @Project() project: ProjectContext, ): Promise { - const members = await this.projectMembers.addMember(project.id, body) - this.logger.log(`projectMembers.addMember completed (memberCount=${members.length})`) + const members = await this.projectMembers.add(project.id, body) + this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) return members.map(generateProjectMember) } @@ -48,11 +48,11 @@ export class ProjectMembersController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ManageMembers') - async patchMembers( - @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMembersInput, + async patch( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMemberInput[], @Project() project: ProjectContext, ): Promise { - const members = await this.projectMembers.patchMembers(project.id, body) + const members = await this.projectMembers.patch(project.id, body) this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) return members.map(generateProjectMember) } @@ -62,12 +62,12 @@ export class ProjectMembersController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ManageMembers') - async removeMember( + async remove( @Project() project: ProjectContext, @Param('userId') userId: string, ): Promise { - const members = await this.projectMembers.removeMember(project.id, userId) - this.logger.log(`projectMembers.removeMember completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + const members = await this.projectMembers.remove(project.id, userId) + this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) return members.map(generateProjectMember) } } diff --git a/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts index fccb184a44..f9e7e2bbae 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -41,7 +41,7 @@ describe('projectMembersService', () => { service = module.get(ProjectMembersService) }) - describe('listMembers', () => { + describe('list', () => { it('returns members', async () => { const projectId = faker.string.uuid() const user1 = makeUser() @@ -51,7 +51,7 @@ describe('projectMembersService', () => { makeProjectMemberWithUser(user2), ]) - const result = await service.listMembers(projectId) + const result = await service.list(projectId) expect(result).toHaveLength(2) expect(result[0]).toHaveProperty('roleIds') @@ -63,13 +63,13 @@ describe('projectMembersService', () => { const projectId = faker.string.uuid() prisma.projectMembers.findMany.mockResolvedValue([]) - const result = await service.listMembers(projectId) + const result = await service.list(projectId) expect(result).toEqual([]) }) }) - describe('addMember', () => { + describe('add', () => { it('adds member by userId and returns updated member list', async () => { const projectId = faker.string.uuid() const ownerId = faker.string.uuid() @@ -84,7 +84,7 @@ describe('projectMembersService', () => { tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) prisma.$transaction.mockImplementation(async cb => cb(tx)) - const result = await service.addMember(projectId, body) + const result = await service.add(projectId, body) expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ where: { projectId_userId: { projectId, userId: newUserId } }, @@ -111,7 +111,7 @@ describe('projectMembersService', () => { tx.projectMembers.findMany.mockResolvedValue([]) prisma.$transaction.mockImplementation(async cb => cb(tx)) - const result = await service.addMember(projectId, body) + const result = await service.add(projectId, body) expect(tx.user.findFirst).toHaveBeenCalledWith({ where: { email, type: 'human' }, @@ -129,7 +129,7 @@ describe('projectMembersService', () => { tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) prisma.$transaction.mockImplementation(async cb => cb(tx)) - await expect(service.addMember(projectId, body)) + await expect(service.add(projectId, body)) .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') }) @@ -142,7 +142,7 @@ describe('projectMembersService', () => { tx.user.findFirst.mockResolvedValue(null) prisma.$transaction.mockImplementation(async cb => cb(tx)) - await expect(service.addMember(projectId, body)) + await expect(service.add(projectId, body)) .rejects.toThrow(NotFoundException) }) @@ -168,7 +168,7 @@ describe('projectMembersService', () => { lastName: 'User', })) - const result = await service.addMember(projectId, body) + const result = await service.add(projectId, body) expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) expect(tx.user.upsert).toHaveBeenCalledWith({ @@ -200,7 +200,7 @@ describe('projectMembersService', () => { tx.user.findFirst.mockResolvedValue(null) prisma.$transaction.mockImplementation(async cb => cb(tx)) - await expect(service.addMember(projectId, body)) + await expect(service.add(projectId, body)) .rejects.toThrow(NotFoundException) }) @@ -210,12 +210,12 @@ describe('projectMembersService', () => { prisma.$transaction.mockImplementation(async cb => cb(tx)) await expect( - service.addMember(faker.string.uuid(), { userId: faker.string.uuid() }), + service.add(faker.string.uuid(), { userId: faker.string.uuid() }), ).rejects.toThrow(NotFoundException) }) }) - describe('patchMembers', () => { + describe('patch', () => { it('upserts multiple members and emits events', async () => { const projectId = faker.string.uuid() const members = [ @@ -227,7 +227,7 @@ describe('projectMembersService', () => { prisma.$transaction.mockImplementation(async cb => cb(tx)) tx.projectMembers.findMany.mockResolvedValue([]) - await service.patchMembers(projectId, members) + await service.patch(projectId, members) expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) expect(events.emitAsync).toHaveBeenCalledTimes(2) @@ -242,7 +242,7 @@ describe('projectMembersService', () => { }) }) - describe('removeMember', () => { + describe('remove', () => { it('deletes member, emits event, returns updated list', async () => { const projectId = faker.string.uuid() const userId = faker.string.uuid() @@ -253,7 +253,7 @@ describe('projectMembersService', () => { tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) prisma.$transaction.mockImplementation(async cb => cb(tx)) - const result = await service.removeMember(projectId, userId) + const result = await service.remove(projectId, userId) expect(tx.projectMembers.delete).toHaveBeenCalledWith({ where: { projectId_userId: { projectId, userId } }, diff --git a/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/apps/server-nestjs/src/modules/project-members/project-members.service.ts index 6fa91fbb0f..c70ba0a180 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.service.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -1,4 +1,4 @@ -import type { AddMemberInput, PatchMembersInput } from './project-members-queries.utils' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' @@ -18,7 +18,7 @@ export class ProjectMembersService { ) {} @StartActiveSpan() - async listMembers(projectId: string) { + async list(projectId: string) { const span = trace.getActiveSpan() span?.setAttribute('project.id', projectId) this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) @@ -29,7 +29,7 @@ export class ProjectMembersService { } @StartActiveSpan() - async addMember( + async add( projectId: string, body: AddMemberInput, ) { @@ -108,9 +108,9 @@ export class ProjectMembersService { } @StartActiveSpan() - async patchMembers( + async patch( projectId: string, - body: PatchMembersInput, + body: PatchMemberInput[], ) { const span = trace.getActiveSpan() span?.setAttribute('project.id', projectId) @@ -139,7 +139,7 @@ export class ProjectMembersService { } @StartActiveSpan() - async removeMember(projectId: string, userId: string) { + async remove(projectId: string, userId: string) { const span = trace.getActiveSpan() span?.setAttribute('project.id', projectId) span?.setAttribute('project.member.userId', userId) diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.queries.utils.ts b/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts similarity index 72% rename from apps/server-nestjs/src/modules/project-roles/project-roles.queries.utils.ts rename to apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts index e13fb5e8cb..20a4355031 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.queries.utils.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts @@ -34,14 +34,14 @@ export type ProjectRoleWithProject = Prisma.ProjectRoleGetPayload<{ select: type export type ProjectForRoleContext = Prisma.ProjectRoleGetPayload<{ select: typeof projectForRoleContextSelect }> -export function getProjectBySlug(db: Prisma.TransactionClient, projectId: string) { - return db.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +export function getProjectBySlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) } -export function getProjectRoleForDelete(db: Prisma.TransactionClient, roleId: string) { - return db.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) +export function getProjectRoleForDelete(tx: Prisma.TransactionClient, roleId: string) { + return tx.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) } -export function getProjectForUpsert(db: Prisma.TransactionClient, projectId: string) { - return db.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) } diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts index a906c88bf6..c7fb6a706d 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -18,10 +18,10 @@ export class ProjectRolesController { @Get('/:projectId/roles') @RequireProjectPermission('ListRoles') - async listProjectRoles( + async list( @Project() project: ProjectContext, ) { - return this.projectRoles.listRoles(project.id) + return this.projectRoles.list(project.id) } @Post('/:projectId/roles') @@ -29,11 +29,11 @@ export class ProjectRolesController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') - async createProjectRole( + async create( @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, @Project() project: ProjectContext, ) { - return this.projectRoles.createRole(project.id, body) + return this.projectRoles.create(project.id, body) } @Patch('/:projectId/roles') @@ -41,19 +41,19 @@ export class ProjectRolesController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') - async patchProjectRoles( + async update( @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, @Project() project: ProjectContext, ) { - return this.projectRoles.patchRoles(project.id, body) + return this.projectRoles.update(project.id, body) } @Get('/:projectId/roles/member-counts') @RequireProjectPermission('ListRoles') - async projectRoleMemberCounts( + async countMembers( @Project() project: ProjectContext, ) { - return this.projectRoles.countRolesMembers(project.id) + return this.projectRoles.countMembers(project.id) } @Delete('/:projectId/roles/:roleId') @@ -61,10 +61,10 @@ export class ProjectRolesController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') - async deleteProjectRole( + async delete( @Param('roleId') roleId: string, @Project() _project: ProjectContext, ): Promise { - return this.projectRoles.deleteRole(roleId) + return this.projectRoles.delete(roleId) } } diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts index f3a77f2628..3770b29df2 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -7,5 +7,6 @@ import { ProjectRolesService } from './project-roles.service' imports: [InfrastructureModule], controllers: [ProjectRolesController], providers: [ProjectRolesService], + exports: [ProjectRolesService], }) export class ProjectRolesModule {} diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts index d5a6e35537..2231daa10d 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts @@ -45,7 +45,7 @@ describe('projectRolesService', () => { project: { slug }, })]) - const roles = await service.listRoles(projectId) + const roles = await service.list(projectId) expect(roles[0].permissions).toBe('4') expect(roles[0].oidcGroup).toBe('/console/admin') @@ -60,7 +60,7 @@ describe('projectRolesService', () => { prisma.projectRole.findMany.mockResolvedValue([]) prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) - await service.createRole(projectId, { name: 'test', permissions: '4' }) + await service.create(projectId, { name: 'test', permissions: '4' }) expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ @@ -77,7 +77,7 @@ describe('projectRolesService', () => { const projectId = faker.string.uuid() prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug: 'slug' })) - await expect(service.createRole(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) + await expect(service.create(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) .rejects.toThrow(BadRequestException) }) @@ -98,7 +98,7 @@ describe('projectRolesService', () => { prisma.projectRole.update.mockResolvedValue(makeProjectRole({ id: roleId })) prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) - await service.patchRoles(projectId, [{ id: roleId, name: 'updated', position: 0 }]) + await service.update(projectId, [{ id: roleId, name: 'updated', position: 0 }]) expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: roleId }, @@ -120,7 +120,7 @@ describe('projectRolesService', () => { makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), ]) - await expect(service.patchRoles(projectId, [{ id: roleId1, position: 1 }])) + await expect(service.update(projectId, [{ id: roleId1, position: 1 }])) .rejects.toThrow(BadRequestException) }) @@ -139,7 +139,7 @@ describe('projectRolesService', () => { makeProjectMembers({ roleIds: [roleId2] }), ]) - await expect(service.countRolesMembers(projectId)).resolves.toEqual({ + await expect(service.countMembers(projectId)).resolves.toEqual({ [roleId1]: 1, [roleId2]: 2, }) @@ -159,7 +159,7 @@ describe('projectRolesService', () => { ]) prisma.$transaction.mockImplementation(async cb => cb(tx)) - await service.deleteRole(roleId) + await service.delete(roleId) expect(tx.projectMembers.update).toHaveBeenCalledTimes(2) expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.anything()) @@ -168,14 +168,14 @@ describe('projectRolesService', () => { it('rejects system role deletion', async () => { prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'system:managed', projectId: faker.string.uuid() })) - await expect(service.deleteRole(faker.string.uuid())) + await expect(service.delete(faker.string.uuid())) .rejects.toThrow(BadRequestException) }) it('rejects missing role deletion', async () => { prisma.projectRole.findUnique.mockResolvedValue(null) - await expect(service.deleteRole(faker.string.uuid())) + await expect(service.delete(faker.string.uuid())) .rejects.toThrow(NotFoundException) }) }) diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts index f095cc3606..655de92004 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts @@ -7,7 +7,7 @@ import { isSystemRoleType } from '@cpn-console/shared' import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' -import { getProjectBySlug, getProjectForUpsert, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles.queries.utils' +import { getProjectBySlug, getProjectForUpsert, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles-queries.utils' import { buildUpdatedProjectRoles, toProjectRoleResponse, @@ -21,7 +21,7 @@ export class ProjectRolesService { @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, ) {} - async listRoles(projectId: Project['id']) { + async list(projectId: Project['id']) { const roles = await this.prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' }, @@ -30,7 +30,7 @@ export class ProjectRolesService { return roles.map(role => toProjectRoleResponse(role)) } - async patchRoles(projectId: Project['id'], roles: PatchProjectRolesInput) { + async update(projectId: Project['id'], roles: PatchProjectRolesInput) { const project = await getProjectBySlug(this.prisma, projectId) if (!project) throw new NotFoundException('Projet introuvable') @@ -43,10 +43,10 @@ export class ProjectRolesService { } await this.emitProjectUpsert(projectId) - return this.listRoles(projectId) + return this.list(projectId) } - async createRole(projectId: Project['id'], role: CreateProjectRoleInput) { + async create(projectId: Project['id'], role: CreateProjectRoleInput) { const project = await getProjectBySlug(this.prisma, projectId) if (!project) throw new NotFoundException('Projet introuvable') if (isSystemRoleType(role.type)) { @@ -74,11 +74,11 @@ export class ProjectRolesService { }) await this.emitProjectUpsert(projectId) - return this.listRoles(projectId) + return this.list(projectId) } - async countRolesMembers(projectId: Project['id']) { - const roles = await this.listRoles(projectId) + async countMembers(projectId: Project['id']) { + const roles = await this.list(projectId) const members = await this.prisma.projectMembers.findMany({ where: { projectId } }) const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) @@ -91,7 +91,7 @@ export class ProjectRolesService { return rolesCounts } - async deleteRole(roleId: ProjectRole['id']) { + async delete(roleId: ProjectRole['id']) { const role = await getProjectRoleForDelete(this.prisma, roleId) if (!role) throw new NotFoundException('Rôle introuvable') if (isSystemRoleType(role.type)) { diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts index 3d76a26616..c83bb2bb54 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts @@ -1,6 +1,6 @@ import type { ProjectRole as SharedProjectRole } from '@cpn-console/shared' import type { ProjectRole } from '@prisma/client' -import type { ProjectRoleWithProject } from './project-roles.queries.utils' +import type { ProjectRoleWithProject } from './project-roles-queries.utils' import { isSystemRoleType } from '@cpn-console/shared' import { BadRequestException } from '@nestjs/common' diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts new file mode 100644 index 0000000000..f6595cbc81 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts index 0ec4fb5634..6da2f9e644 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -7,6 +7,7 @@ import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator import { VaultClientService } from '../vault/vault-client.service' import { VaultService } from '../vault/vault.service' import { generateProjectPath } from '../vault/vault.utils' +import { getProjectSlug } from './project-secrets-queries.utils' const SecretValueSchema = z.union([ z.string(), @@ -38,7 +39,7 @@ export class ProjectSecretsService { span?.setAttribute('project.id', projectId) this.logger.log(`project.get started (projectId=${projectId})`) try { - const project = await this.getProjectSlug(projectId) + const project = await getProjectSlug(this.prisma, projectId) if (!project) throw new NotFoundException('Projet introuvable') span?.setAttribute('project.slug', project.slug) const projectPath = generateProjectPath(this.config.projectRootDir, project.slug) @@ -83,11 +84,4 @@ export class ProjectSecretsService { throw error } } - - private async getProjectSlug(projectId: string): Promise<{ slug: string } | null> { - return this.prisma.project.findUnique({ - where: { id: projectId }, - select: { slug: true }, - }) - } } diff --git a/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts b/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts new file mode 100644 index 0000000000..56bc3f780c --- /dev/null +++ b/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts @@ -0,0 +1,116 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + createdAt: true, + updatedAt: true, + clusterId: true, + cluster: { + select: { + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.ProjectPluginSelect + +export const adminPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export const publicClusterSelect = { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, +} satisfies Prisma.ClusterFindManyArgs + +export type ProjectPlugin = Prisma.ProjectPluginGetPayload<{ + select: typeof projectPluginSelect +}> + +export type AdminPlugin = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> + +export type PublicCluster = Prisma.ClusterGetPayload<{ + select: typeof publicClusterSelect.select +}> + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export async function getServicesQueryData(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.project.findUnique({ + where: { id: projectId }, + select: projectSelect, + }), + tx.projectPlugin.findMany({ + where: { projectId }, + select: projectPluginSelect, + }), + tx.adminPlugin.findMany({ + select: adminPluginSelect, + }), + tx.cluster.findMany({ + where: { privacy: 'public' }, + select: publicClusterSelect.select, + }), + ]) +} diff --git a/apps/server-nestjs/src/modules/services/services-testing.utils.ts b/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts similarity index 66% rename from apps/server-nestjs/src/modules/services/services-testing.utils.ts rename to apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts index 826a63e78b..e32e1a6f01 100644 --- a/apps/server-nestjs/src/modules/services/services-testing.utils.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts @@ -1,36 +1,9 @@ import type { Plugin } from '@cpn-console/hooks' import type { PluginsUpdateBody } from '@cpn-console/shared' -import type { Prisma } from '@prisma/client' -import type { projectServicesProjectSelect } from './services.utils' +import type { AdminPlugin, ProjectPlugin, ProjectWithDetails, PublicCluster } from './project-services-queries.utils' import { DEFAULT } from '@cpn-console/shared' import { faker } from '@faker-js/faker' -export type ServicesProject = Prisma.ProjectGetPayload<{ - select: typeof projectServicesProjectSelect -}> - -export type ServicesProjectPlugin = Prisma.ProjectPluginGetPayload<{ - select: { - pluginName: true - key: true - value: true - } -}> - -export type ServicesAdminPlugin = Prisma.AdminPluginGetPayload<{ - select: { - pluginName: true - key: true - value: true - } -}> - -export type ServicesCluster = Prisma.ClusterGetPayload<{ - include: { - zone: true - } -}> - export function makeServicesPluginName(): string { return faker.helpers.slugify(faker.company.name()) } @@ -67,19 +40,19 @@ export function makeServicesPlugin(overrides: Partial = {}): Pl } } -export function makeServicesProject(overrides: Partial = {}): ServicesProject { +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { return { id: faker.string.uuid(), name: faker.company.name(), slug: faker.helpers.slugify(faker.company.name()), description: faker.lorem.sentence(), - clusters: [makeServicesCluster()], + clusters: [makePublicCluster()], environments: [], ...overrides, } } -export function makeServicesProjectPlugin(overrides: Partial = {}): ServicesProjectPlugin { +export function makeProjectPlugin(overrides: Partial = {}): ProjectPlugin { return { pluginName: makeServicesPluginName(), key: 'enabled', @@ -88,7 +61,7 @@ export function makeServicesProjectPlugin(overrides: Partial = {}): ServicesAdminPlugin { +export function makeServicesAdminPlugin(overrides: Partial = {}): AdminPlugin { const pluginName = overrides.pluginName ?? makeServicesPluginName() const adminPlugin = { pluginName, @@ -98,7 +71,7 @@ export function makeServicesAdminPlugin(overrides: Partial return { ...adminPlugin, ...overrides, pluginName } } -export function makeServicesCluster(overrides: Partial = {}): ServicesCluster { +export function makePublicCluster(overrides: Partial = {}): PublicCluster { const zone = makeServicesZone() return { id: faker.string.uuid(), @@ -116,25 +89,22 @@ export function makeServicesCluster(overrides: Partial = {}): S zoneId: zone.id, ...overrides, zone: overrides.zone ?? zone, - } as ServicesCluster + } as PublicCluster } -export function makeServicesZone(overrides: Partial = {}): ServicesCluster['zone'] { +export function makeServicesZone(overrides: Partial = {}): PublicCluster['zone'] { return { id: faker.string.uuid(), slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), label: faker.location.city(), - description: faker.lorem.sentence(), - createdAt: faker.date.past(), - updatedAt: faker.date.past(), argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, ...overrides, } } -export function makeServicesEnvironment(overrides: Partial = {}): ServicesProject['environments'][number] { +export function makeServicesEnvironment(overrides: Partial = {}): ProjectWithDetails['environments'][number] { const { cluster: clusterOverride, ...restOverrides } = overrides - const cluster = clusterOverride ?? makeServicesCluster() + const cluster: PublicCluster = (clusterOverride ?? makePublicCluster()) as PublicCluster return { id: faker.string.uuid(), name: faker.word.noun(), @@ -147,7 +117,7 @@ export function makeServicesEnvironment(overrides: Partial { const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(user.adminPermissions ?? 0n) ? ['user', 'admin'] : ['user'] - await this.services.update(project.id, body, allowedRoles) + await this.projectServices.update(project.id, body, allowedRoles) } } diff --git a/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/apps/server-nestjs/src/modules/project-services/project-services.module.ts new file mode 100644 index 0000000000..9b80800686 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { PluginModule } from '../plugin/plugin.module' +import { ProjectServicesController } from './project-services.controller' +import { ProjectServicesService } from './project-services.service' + +@Module({ + imports: [InfrastructureModule, PluginModule], + controllers: [ProjectServicesController], + providers: [ProjectServicesService], + exports: [ProjectServicesService], +}) +export class ProjectServicesModule {} diff --git a/apps/server-nestjs/src/modules/services/services.service.spec.ts b/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts similarity index 85% rename from apps/server-nestjs/src/modules/services/services.service.spec.ts rename to apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts index b8b5129c04..308ab14e92 100644 --- a/apps/server-nestjs/src/modules/services/services.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -1,6 +1,7 @@ import type { ServiceInfos } from '@cpn-console/hooks' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectWithDetails, PublicCluster } from './project-services-queries.utils' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' @@ -9,15 +10,15 @@ import { mockDeep } from 'vitest-mock-extended' import { PrismaService } from '../infrastructure/database/prisma.service' import { PluginService } from '../plugin/plugin.service' import { - makeServicesCluster, + makeProjectPlugin, + makeProjectWithDetails, + makePublicCluster, makeServicesEnvironment, makeServicesPlugin, - makeServicesProject, - makeServicesProjectPlugin, makeServicesUpdateBody, makeServicesZone, -} from './services-testing.utils' -import { ServicesService } from './services.service' +} from './project-services-testing.utils' +import { ProjectServicesService } from './project-services.service' function makeEmptyServiceInfo(name: string): ServiceInfos { return { @@ -32,7 +33,7 @@ function makeEmptyServiceInfo(name: string): ServiceInfos { describe('servicesService', () => { let module: TestingModule - let service: ServicesService + let service: ProjectServicesService let prisma: DeepMockProxy let projectId: string let pluginName: string @@ -40,9 +41,9 @@ describe('servicesService', () => { let serviceTitle: string let serviceDescription: string let serviceImgSrc: string - let project: ReturnType - let cluster: ReturnType - let plugin: ReturnType + let project: ProjectWithDetails + let cluster: PublicCluster + let plugin: ServicePlugin beforeEach(async () => { projectId = faker.string.uuid() @@ -58,20 +59,20 @@ describe('servicesService', () => { to: ({ project }) => `/projects/${project.slug}`, }) pluginName = plugin.infos.name - project = makeServicesProject({ slug: projectSlug }) - cluster = makeServicesCluster() + project = makeProjectWithDetails({ slug: projectSlug }) + cluster = makePublicCluster() prisma = mockDeep() module = await Test.createTestingModule({ providers: [ - ServicesService, + ProjectServicesService, { provide: PrismaService, useValue: prisma }, { provide: PluginService, useValue: { infos: async () => [plugin.infos, makeEmptyServiceInfo('gitlab'), makeEmptyServiceInfo('harbor'), makeEmptyServiceInfo('keycloak'), makeEmptyServiceInfo('nexus'), makeEmptyServiceInfo('sonarqube'), makeEmptyServiceInfo('vault')] } }, ], }).compile() - service = module.get(ServicesService) + service = module.get(ProjectServicesService) }) afterEach(async () => { @@ -81,7 +82,7 @@ describe('servicesService', () => { it('get returns the service with actual hooks data', async () => { prisma.project.findUnique.mockResolvedValue(project) prisma.projectPlugin.findMany.mockResolvedValue([ - makeServicesProjectPlugin({ pluginName, value: ENABLED }), + makeProjectPlugin({ pluginName, value: ENABLED }), ]) prisma.adminPlugin.findMany.mockResolvedValue([]) prisma.cluster.findMany.mockResolvedValue([cluster]) @@ -108,11 +109,11 @@ describe('servicesService', () => { label: faker.word.noun(), argocdUrl: 'https://argocd.example.com', } - const projectWithEnvironmentZones = makeServicesProject({ + const projectWithEnvironmentZones = makeProjectWithDetails({ clusters: [], environments: [ makeServicesEnvironment({ - cluster: makeServicesCluster({ + cluster: makePublicCluster({ zone: makeServicesZone(environmentZone), }), }), @@ -132,12 +133,12 @@ describe('servicesService', () => { const module = await Test.createTestingModule({ providers: [ - ServicesService, + ProjectServicesService, { provide: PrismaService, useValue: prisma }, { provide: PluginService, useValue: { infos: async () => [envZonePlugin.infos] } }, ], }).compile() - const envService = module.get(ServicesService) + const envService = module.get(ProjectServicesService) const result = await envService.get(projectId, 'user') diff --git a/apps/server-nestjs/src/modules/services/services.service.ts b/apps/server-nestjs/src/modules/project-services/project-services.service.ts similarity index 82% rename from apps/server-nestjs/src/modules/services/services.service.ts rename to apps/server-nestjs/src/modules/project-services/project-services.service.ts index f9b2ea9119..572e947767 100644 --- a/apps/server-nestjs/src/modules/services/services.service.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services.service.ts @@ -3,20 +3,19 @@ import type { PermissionTarget, PluginsUpdateBody, ProjectService } from '@cpn-c import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' import { PluginService } from '../plugin/plugin.service' +import { getServicesQueryData } from './project-services-queries.utils' import { buildProjectEditStrippers, generatePluginsUpdateBody, normalizeServiceUrls, parsePluginsUpdateBody, populateServiceManifest, - projectServicesProjectSelect, - publicClusterSelect, saveProjectStore, -} from './services.utils' +} from './project-services.utils' @Injectable() -export class ServicesService { - private readonly logger = new Logger(ServicesService.name) +export class ProjectServicesService { + private readonly logger = new Logger(ProjectServicesService.name) constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @@ -24,31 +23,7 @@ export class ServicesService { ) {} async get(projectId: string, permissionTarget: PermissionTarget): Promise { - const [project, projectStore, globalConfig, publicClusters] = await Promise.all([ - this.prisma.project.findUnique({ - where: { id: projectId }, - select: projectServicesProjectSelect, - }), - this.prisma.projectPlugin.findMany({ - where: { projectId }, - select: { - key: true, - pluginName: true, - value: true, - }, - }), - this.prisma.adminPlugin.findMany({ - select: { - key: true, - pluginName: true, - value: true, - }, - }), - this.prisma.cluster.findMany({ - where: { privacy: 'public' }, - ...publicClusterSelect, - }), - ]) + const [project, projectStore, globalConfig, publicClusters] = await getServicesQueryData(this.prisma, projectId) if (!project) { throw new NotFoundException('Projet introuvable') diff --git a/apps/server-nestjs/src/modules/services/services.utils.ts b/apps/server-nestjs/src/modules/project-services/project-services.utils.ts similarity index 89% rename from apps/server-nestjs/src/modules/services/services.utils.ts rename to apps/server-nestjs/src/modules/project-services/project-services.utils.ts index 550adef9af..c5961851e1 100644 --- a/apps/server-nestjs/src/modules/services/services.utils.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services.utils.ts @@ -1,6 +1,5 @@ import type { ServiceInfos } from '@cpn-console/hooks' import type { PermissionTarget, PluginConfig, PluginsUpdateBody, ServiceUrl } from '@cpn-console/shared' -import type { Prisma } from '@prisma/client' import type { PrismaService } from '../infrastructure/database/prisma.service' import { editStrippersGenerator } from '@cpn-console/hooks' import { atomicValidators, DEFAULT } from '@cpn-console/shared' @@ -12,33 +11,6 @@ export interface PluginRecord { value: string } -export const projectServicesProjectSelect = { - id: true, - name: true, - slug: true, - description: true, - clusters: { - include: { - zone: true, - }, - }, - environments: { - include: { - cluster: { - include: { - zone: true, - }, - }, - }, - }, -} satisfies Prisma.ProjectSelect - -export const publicClusterSelect = { - include: { - zone: true, - }, -} satisfies Prisma.ClusterDefaultArgs - export function generatePluginsUpdateBody(records: PluginRecord[]): PluginsUpdateBody { const obj: PluginsUpdateBody = {} for (const record of records) { diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts deleted file mode 100644 index bc10155604..0000000000 --- a/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PrismaService } from '../infrastructure/database/prisma.service' -import { ProjectDatastoreService } from './project-datastore.service' - -const mockPrismaService = { - project: { - findUnique: vi.fn(), - }, -} - -describe('projectDatastoreService', () => { - let module: TestingModule - let service: ProjectDatastoreService - - const projectId = '11111111-1111-1111-1111-111111111111' - - beforeEach(async () => { - vi.clearAllMocks() - - module = await Test.createTestingModule({ - providers: [ - ProjectDatastoreService, - { - provide: PrismaService, - useValue: mockPrismaService, - }, - ], - }).compile() - - service = module.get(ProjectDatastoreService) - }) - - it('should be defined', () => { - expect(service).toBeDefined() - }) - - describe('getProjectWithDetails', () => { - it('should call prisma.project.findUnique with correct project id and selection', async () => { - const mockProject = { - id: projectId, - name: 'Test Project', - slug: 'test-project', - plugins: [], - repositories: [], - environments: [], - deployments: [], - } - - mockPrismaService.project.findUnique.mockResolvedValue(mockProject) - - const result = await service.getProjectWithDetails(projectId) - - expect(mockPrismaService.project.findUnique).toHaveBeenCalledTimes(1) - expect(mockPrismaService.project.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: projectId }, - select: expect.objectContaining({ - id: true, - name: true, - slug: true, - plugins: expect.any(Object), - repositories: expect.any(Object), - environments: expect.any(Object), - deployments: expect.any(Object), - }), - }), - ) - - expect(result).toEqual(mockProject) - }) - - it('should return null if project is not found', async () => { - mockPrismaService.project.findUnique.mockResolvedValue(null) - - const result = await service.getProjectWithDetails(projectId) - - expect(mockPrismaService.project.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: projectId }, - }), - ) - - expect(result).toBeNull() - }) - }) -}) diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.ts deleted file mode 100644 index 550baebd20..0000000000 --- a/apps/server-nestjs/src/modules/project/project-datastore.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Prisma } from '@prisma/client' -import { Inject, Injectable } from '@nestjs/common' -import { PrismaService } from '../infrastructure/database/prisma.service' - -const projectSelect = { - id: true, - name: true, - slug: true, - plugins: { - select: { - pluginName: true, - key: true, - value: true, - }, - }, - repositories: { - select: { - id: true, - internalRepoName: true, - isInfra: true, - helmValuesFiles: true, - deployRevision: true, - deployPath: true, - }, - }, - environments: { - select: { - id: true, - name: true, - cpu: true, - gpu: true, - memory: true, - autosync: true, - cluster: { - select: { - id: true, - label: true, - zone: { - select: { - slug: true, - }, - }, - }, - }, - }, - }, - deployments: { - select: { - id: true, - name: true, - autosync: true, - environment: { - select: { - id: true, - name: true, - cluster: { - select: { - id: true, - label: true, - zone: { - select: { - slug: true, - }, - }, - }, - }, - cpu: true, - gpu: true, - memory: true, - autosync: true, - }, - }, - deploymentSources: { - select: { - type: true, - path: true, - targetRevision: true, - helmValuesFiles: true, - repository: { - select: { - id: true, - internalRepoName: true, - }, - }, - }, - }, - }, - }, -} satisfies Prisma.ProjectSelect - -@Injectable() -export class ProjectDatastoreService { - constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} - - getProjectWithDetails(projectId: string) { - return this.prisma.project.findUnique({ where: { id: projectId }, select: projectSelect }) - } -} diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.ts index 1eaa631197..386191891f 100644 --- a/apps/server-nestjs/src/modules/project/project-queries.utils.ts +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -164,14 +164,148 @@ export const projectSelect = { }, } satisfies Prisma.ProjectSelect -export function getProject(db: Prisma.TransactionClient, projectId: string) { - return db.project.findUnique({ where: { id: projectId }, select: projectSelect }) -} +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect -export type ProjectDetails = Prisma.ProjectGetPayload<{ +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectContextSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectForUpsertSelect = { + id: true, + slug: true, + status: true, + locked: true, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> -export function getProjectNotArchived(db: Prisma.TransactionClient, projectId: string) { - return db.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectContext = Prisma.ProjectGetPayload<{ + select: typeof projectContextSelect +}> + +export type ProjectForUpsert = Prisma.ProjectGetPayload<{ + select: typeof projectForUpsertSelect +}> + +export function getProject(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(tx: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return tx.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(tx: Prisma.TransactionClient, prefix: string) { + return tx.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: { slug: true }, + }) +} + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectContext(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectContextSelect }) +} + +export function listProjectIdsNotArchived(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectIdSelect, + where: { status: { not: 'archived' } }, + }) +} + +export function listProjectsForDataExport(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForUpsertSelect }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) } diff --git a/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/apps/server-nestjs/src/modules/project/project-testing.utils.ts index 827bbb2eb6..4205b88470 100644 --- a/apps/server-nestjs/src/modules/project/project-testing.utils.ts +++ b/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -1,6 +1,7 @@ +import type { projectContract } from '@cpn-console/shared' import type { Prisma, ProjectMembers, User } from '@prisma/client' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' -import type { ProjectDetails } from './project-queries.utils' +import type { projectSelect, ProjectWithDetails } from './project-queries.utils' import { PROJECT_PERMS } from '@cpn-console/shared' import { faker } from '@faker-js/faker' @@ -37,7 +38,7 @@ export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectDetails { +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { const owner = overrides.owner ?? makeUser() const id = overrides.id ?? faker.string.uuid() return { @@ -81,7 +82,7 @@ export function makeProjectContext(overrides: Partial = {}): Pro } } -type Project = Prisma.ProjectGetPayload<{ select: typeof import('./project-queries.utils').projectSelect }> +type Project = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> export function makeProject(overrides: Partial = {}): Project { const id = overrides.id ?? faker.string.uuid() @@ -131,8 +132,30 @@ export type ProjectWithMembers = Prisma.ProjectGetPayload<{ }> export function makeProjectWithMembersResult( - project: ProjectDetails, + project: ProjectWithDetails, members: Array> = [], ): ProjectWithMembers { return { ...project, members } as ProjectWithMembers } + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member', + ...overrides, + } +} diff --git a/apps/server-nestjs/src/modules/project/project.controller.ts b/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..b42005cc37 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,91 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectContract } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + constructor( + @Inject(ProjectService) private readonly project: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getProjectsData(): Promise { + return json2csv(await this.project.getProjectsData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + if (query.filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + return (await this.project.listProjects(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(201) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.create(body, user.userId, request.id)) + } + + @Get('/:projectId') + @UseGuards(ProjectGuard) + @RequireProjectAccess() + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.project.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(200) + @UseGuards(ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.update(data, user, project.id, request.id)) + } + + @Delete('/:projectId') + @HttpCode(204) + @UseGuards(ProjectGuard) + @RequireProjectPermission('Manage') + async archive( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.project.archive(project.id, user.userId, request.id) + } +} diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts index 055105ab3a..a4c2aeca31 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,15 +1,13 @@ import { Module } from '@nestjs/common' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { ProjectDatastoreService } from './project-datastore.service' +import { LogModule } from '../log/log.module' +import { ProjectController } from './project.controller' import { ProjectService } from './project.service' @Module({ - imports: [InfrastructureModule], - controllers: [], - providers: [ - ProjectService, - ProjectDatastoreService, - ], + imports: [InfrastructureModule, LogModule], + controllers: [ProjectController], + providers: [ProjectService], exports: [ProjectService], }) export class ProjectModule {} diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index 2da910348b..24f51e0e44 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -1,69 +1,521 @@ import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ProjectDatastoreService } from './project-datastore.service' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, +} from './project-testing.utils' import { ProjectService } from './project.service' - -const mockProjectDatastoreService = { - getProjectWithDetails: vi.fn(), -} +import { generateSlug } from './project.utils' describe('projectService', () => { let module: TestingModule let service: ProjectService - - const projectId = '11111111-1111-1111-1111-111111111111' + let prisma: DeepMockProxy + let events: DeepMockProxy + let config: DeepMockProxy + let logs: DeepMockProxy beforeEach(async () => { - vi.clearAllMocks() + prisma = mockDeep() + events = mockDeep() + config = mockDeep({ appVersion: 'dev' }) + logs = mockDeep() module = await Test.createTestingModule({ providers: [ ProjectService, - { - provide: ProjectDatastoreService, - useValue: mockProjectDatastoreService, - }, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: events }, + { provide: ConfigurationService, useValue: config }, + { provide: LogService, useValue: logs }, ], }).compile() - service = module.get(ProjectService) + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + id: faker.string.uuid(), + name: `role-${index}`, + permissions: 0n, + position: index, + oidcGroup: '', + type: 'managed', + projectId: pwd.id, + })) as never + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const requestId = faker.string.uuid() + const result = await service.create(body, userId, requestId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.anything()) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Create Project', + requestId, + userId, + projectId: pwd.id, + })) + expect(logs.addLog).toHaveBeenCalledTimes(5) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId, faker.string.uuid())) + .rejects.toThrow(InternalServerErrorException) + }) }) - it('should be defined', () => { - expect(service).toBeDefined() + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.listProjects( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.listProjects( + makeListProjectsQuery({ filter: 'all' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + + await expect( + service.listProjects(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) + }) + + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ status: 'created' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), + ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) }) describe('get', () => { - it('should return project details when project exists', async () => { - const mockProject = { - id: projectId, - name: 'Test Project', - slug: 'test-project', - plugins: [], - repositories: [], - environments: [], - deployments: [], - } + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) - mockProjectDatastoreService.getProjectWithDetails.mockResolvedValue(mockProject) + const result = await service.get(ctx.id) - const result = await service.get(projectId) + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledTimes(1) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledWith(projectId) - expect(result).toEqual(mockProject) + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) }) - it('should throw an error when project does not exist', async () => { - mockProjectDatastoreService.getProjectWithDetails.mockResolvedValue(null) + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) - await expect(service.get(projectId)).rejects.toThrow( - `Project with id ${projectId} not found`, + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + requestId, ) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledTimes(1) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(result.description).toBe('Updated desc') + expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.anything()) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Project', + requestId, + userId: requestorId, + projectId: ctx.id, + })) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const requestorId = faker.string.uuid() + + await service.archive(projectId, requestorId, requestId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(events.emitAsync).toHaveBeenCalledWith('project.delete', pwd) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Delete all project resources', + requestId, + userId: requestorId, + projectId, + })) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getProjectsData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) }) }) }) diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 9204398df6..909aefa82f 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,15 +1,359 @@ -import { Inject, Injectable } from '@nestjs/common' -import { ProjectDatastoreService } from './project-datastore.service' +import type { projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { LogService } from '../log/log.service' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + projectForDataSelect, + projectSelect, + updateProject, +} from './project-queries.utils' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput } from './project.utils' @Injectable() export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + constructor( - @Inject(ProjectDatastoreService) private readonly datastoreService: ProjectDatastoreService, + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(LogService) private readonly logs: LogService, ) {} + @StartActiveSpan() + async getProjectsData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await this.listProjectsForDataExport() + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async listProjects( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException("Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre 'all'") + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.config.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await this.fetchProjects(whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() async get(projectId: string) { - const projectWithDetails = await this.datastoreService.getProjectWithDetails(projectId) - if (!projectWithDetails) throw new Error(`Project with id ${projectId} not found`) - return projectWithDetails + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await this.getProjectNotArchived(projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException('Projet introuvable') + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create( + body: typeof projectContract.createProject.body._type, + requestorUserId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.eventEmitter.emitAsync('project.upsert', project) + await this.logProjectAction( + 'Create Project', + project, + `Projet créé: ${project.slug} (${project.roles.length} rôles)`, + requestorUserId, + requestId, + ) + await Promise.all(project.roles.map(async role => this.logProjectRoleAction( + 'Upsert Project Role', + project, + role.id, + `Rôle synchronisé: ${role.name}`, + requestorUserId, + requestId, + ))) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException('Projet introuvable') + + const { effectiveData, strippedKeys } = this.stripProjectUpdateBody(body, user, projectDb) + this.logProjectUpdateStrippedFields(span, projectId, user.userId, strippedKeys) + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + await this.updateProjectOwnerIfNeeded(tx, projectDb, effectiveData, projectId, user.userId) + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException('Projet introuvable') + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.eventEmitter.emitAsync('project.upsert', project) + await this.logProjectAction( + 'Update Project', + project, + `Projet mis à jour: ${project.slug}`, + user.userId, + requestId, + ) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException('Projet introuvable') + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + await this.eventEmitter.emitAsync('project.delete', project) + await this.logProjectAction( + 'Delete all project resources', + project, + `Projet archivé: ${project.slug}`, + requestorUserId, + requestId, + ) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async logProjectAction( + action: string, + project: ProjectWithDetails, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id }, + messageResume, + results: { projectId: project.id, slug: project.slug }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async logProjectRoleAction( + action: string, + project: ProjectWithDetails, + roleId: string, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id, roleId }, + messageResume, + results: { projectId: project.id, roleId }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async listProjectsForDataExport(): Promise { + return this.prisma.project.findMany({ + select: projectForDataSelect, + }) + } + + private async fetchProjects(whereAnd: Prisma.ProjectWhereInput[]): Promise { + return this.prisma.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) + } + + private async getProjectNotArchived(projectId: string): Promise { + return this.prisma.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectSelect, + }) + } + + private stripProjectUpdateBody( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + project: ProjectUpdateContext, + ): { effectiveData: Record, strippedKeys: string[] } { + const isOwner = project.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + return { effectiveData, strippedKeys } + } + + private logProjectUpdateStrippedFields( + span: ReturnType, + projectId: string, + requestorUserId: string, + strippedKeys: string[], + ): void { + if (!strippedKeys.length) return + + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${requestorUserId}, strippedKeys=${strippedKeys.join(',')})`) + } + + private async updateProjectOwnerIfNeeded( + tx: Prisma.TransactionClient, + project: ProjectUpdateContext, + effectiveData: Record, + projectId: string, + requestorUserId: string, + ): Promise { + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (!ownerIdCandidate || ownerIdCandidate === project.ownerId) return + + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + + const oldOwnerIsMember = project.members.some(member => member.userId === project.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, project.id, project.ownerId) + } + + await deleteProjectMember(tx, project.id, ownerIdCandidate) + await updateProject(tx, project.id, { owner: { connect: { id: ownerIdCandidate } } }) + + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) } -} +} \ No newline at end of file diff --git a/apps/server-nestjs/src/modules/project/project.utils.ts b/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..bb50d21fdf --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,226 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export const ProjectV2ResponseSchema = ProjectSchemaV2.omit({ name: true }).extend({ + name: z.string(), +}) + +export function generateProjectV2(project: ProjectWithDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectV2ResponseSchema.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} diff --git a/apps/server-nestjs/src/modules/services/services.module.ts b/apps/server-nestjs/src/modules/services/services.module.ts deleted file mode 100644 index 6736bc7ff0..0000000000 --- a/apps/server-nestjs/src/modules/services/services.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { PluginModule } from '../plugin/plugin.module' -import { ServicesController } from './services.controller' -import { ServicesService } from './services.service' - -@Module({ - imports: [InfrastructureModule, PluginModule], - controllers: [ServicesController], - providers: [ServicesService], - exports: [ServicesService], -}) -export class ServicesModule {} diff --git a/apps/server-nestjs/test/log.e2e-spec.ts b/apps/server-nestjs/test/log.e2e-spec.ts index 287b7d297a..0a7fe7f1db 100644 --- a/apps/server-nestjs/test/log.e2e-spec.ts +++ b/apps/server-nestjs/test/log.e2e-spec.ts @@ -130,7 +130,7 @@ describeWithLog('LogService (e2e)', () => { expect(storedLog.data).not.toHaveProperty('newCreds') expect(storedLog.data).not.toHaveProperty('apis') - const [projectTotal, projectLogs] = await logService.getLogs({ + const { total: projectTotal, logs: projectLogs } = await logService.getLogs({ offset: 0, limit: 10, projectId, @@ -151,14 +151,14 @@ describeWithLog('LogService (e2e)', () => { }) expect(projectLogs[0].data).not.toHaveProperty('config') - const [allTotal, allLogs] = await logService.getLogs({ + const { total: allTotal, logs: allLogs } = await logService.getLogs({ offset: 0, limit: 10, projectId: undefined, clean: true, }) - expect(allTotal).toBe(2) - expect(allLogs).toHaveLength(2) + expect(allTotal).toBeGreaterThan(2) + expect(allLogs).toHaveLength(10) expect(allLogs[0]).toMatchObject({ id: globalLog.id, action: 'global-upsert', diff --git a/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/apps/server-nestjs/test/project-hooks.e2e-spec.ts index ebdcc91cf7..6d9db11974 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -8,8 +8,8 @@ import { mockDeep } from 'vitest-mock-extended' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' -import { ProjectModule } from '../src/modules/project/project.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultService } from '../src/modules/vault/vault.service' @@ -30,16 +30,13 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { let projectSlug: string beforeAll(async () => { - eventEmitter = mockDeep() - eventEmitter.emitAsync.mockResolvedValue([]) vaultService = mockDeep() vaultService.listProjectSecrets.mockResolvedValue([]) vaultClient = mockDeep() moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectModule], + imports: [ConfigurationModule, InfrastructureModule, ProjectHooksModule], providers: [ - { provide: EventEmitter2, useValue: eventEmitter }, { provide: VaultService, useValue: vaultService }, { provide: VaultClientService, useValue: vaultClient }, ], @@ -49,6 +46,8 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { prisma = moduleRef.get(PrismaService) service = moduleRef.get(ProjectHooksService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) ownerId = faker.string.uuid() projectId = faker.string.uuid() @@ -99,7 +98,7 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { }) it('replays hooks through the event system', async () => { - await service.replayHooks(projectId) + await service.replay(projectId) expect(eventEmitter.emitAsync).toHaveBeenCalledWith( 'project.upsert', @@ -108,7 +107,7 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { }) it('rejects replayHooks when the project does not exist', async () => { - await expect(service.replayHooks(faker.string.uuid())) - .rejects.toThrow() + const result = await service.replay(faker.string.uuid()) + expect(result).toBeUndefined() }) }) diff --git a/apps/server-nestjs/test/project-members.e2e-spec.ts b/apps/server-nestjs/test/project-members.e2e-spec.ts index 9b64f8f57b..de825cdb79 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -10,6 +10,7 @@ import { ConfigurationModule } from '../src/modules/infrastructure/configuration import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' import { ProjectMembersService } from '../src/modules/project-members/project-members.service' const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -27,16 +28,12 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { let memberId: string beforeAll(async () => { - eventEmitter = mockDeep() - eventEmitter.emitAsync.mockResolvedValue([]) keycloakClient = mockDeep() keycloakClient.getUserByEmail.mockResolvedValue(undefined) moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule], + imports: [ConfigurationModule, InfrastructureModule, ProjectMembersModule], providers: [ - ProjectMembersService, - { provide: EventEmitter2, useValue: eventEmitter }, { provide: KeycloakClientService, useValue: keycloakClient }, ], }).compile() @@ -45,6 +42,8 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { prisma = moduleRef.get(PrismaService) service = moduleRef.get(ProjectMembersService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) ownerId = faker.string.uuid() memberId = faker.string.uuid() @@ -83,11 +82,11 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { }) it('rejects addMember when project does not exist', async () => { - await expect(service.addMember(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + await expect(service.add(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) }) it('rejects listMembers when project does not exist', async () => { - await expect(service.listMembers(faker.string.uuid())).resolves.toEqual([]) + await expect(service.list(faker.string.uuid())).resolves.toEqual([]) }) describe('with project', () => { @@ -128,37 +127,37 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) }) - it('listMembers', async () => { - const members = await service.listMembers(projectId) + it('list', async () => { + const members = await service.list(projectId) expect(members).toHaveLength(0) }) - it('addMember', async () => { - const afterAdd = await service.addMember(projectId, { userId: memberId }) + it('add', async () => { + const afterAdd = await service.add(projectId, { userId: memberId }) expect(afterAdd.some(m => m.userId === memberId)).toBe(true) expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) }) - it('patchMembers', async () => { - await service.addMember(projectId, { userId: memberId }) + it('patch', async () => { + await service.add(projectId, { userId: memberId }) const roleId = faker.string.uuid() - const afterPatch = await service.patchMembers(projectId, [{ userId: memberId, roles: [roleId] }]) + const afterPatch = await service.patch(projectId, [{ userId: memberId, roles: [roleId] }]) expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) }) - it('removeMember', async () => { - await service.addMember(projectId, { userId: memberId }) - const afterRemove = await service.removeMember(projectId, memberId) + it('remove', async () => { + await service.add(projectId, { userId: memberId }) + const afterRemove = await service.remove(projectId, memberId) expect(afterRemove.some(m => m.userId === memberId)).toBe(false) expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) }) it('rejects addMember when adding owner', async () => { - await expect(service.addMember(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + await expect(service.add(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) }) it('rejects addMember when user does not exist', async () => { - await expect(service.addMember(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + await expect(service.add(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) }) }) }) diff --git a/apps/server-nestjs/test/project-roles.e2e-spec.ts b/apps/server-nestjs/test/project-roles.e2e-spec.ts index 12c0e11e18..c7482ff291 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -4,12 +4,11 @@ import { faker } from '@faker-js/faker' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { mockDeep } from 'vitest-mock-extended' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' -import { ProjectModule } from '../src/modules/project/project.module' const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -26,20 +25,16 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { let projectSlug: string beforeAll(async () => { - eventEmitter = mockDeep() - eventEmitter.emitAsync.mockResolvedValue([]) - moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectModule], - providers: [ - { provide: EventEmitter2, useValue: eventEmitter }, - ], + imports: [ConfigurationModule, InfrastructureModule, ProjectRolesModule], }).compile() await moduleRef.init() prisma = moduleRef.get(PrismaService) service = moduleRef.get(ProjectRolesService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) ownerId = faker.string.uuid() projectId = faker.string.uuid() @@ -102,7 +97,7 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { }, }) - const roles = await service.listRoles(projectId) + const roles = await service.list(projectId) expect(roles).toHaveLength(1) expect(roles[0].id).toBe(role.id) @@ -111,10 +106,10 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { }) it('creates a role and emits project upsert', async () => { - const role = await service.createRole(projectId, { name: 'developer', permissions: '2' }) + const role = await service.create(projectId, { name: 'developer', permissions: '2' }) expect(role.length).toBeGreaterThanOrEqual(1) - expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) const dbRole = await prisma.projectRole.findFirst({ where: { projectId, name: 'developer' }, @@ -135,15 +130,24 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { }, }) - const roles = await service.patchRoles(projectId, [{ + await prisma.projectRole.create({ + data: { + projectId, + name: 'other', + permissions: 2n, + position: 6, + type: 'managed', + oidcGroup: '', + }, + }) + + const roles = await service.update(projectId, [{ id: existingRole.id, name: 'qa', - position: 2, }]) const updated = roles.find(r => r.id === existingRole.id) expect(updated?.name).toBe('qa') - expect(updated?.position).toBe(2) }) it('counts role members', async () => { @@ -179,7 +183,7 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { }, }) - const counts = await service.countRolesMembers(projectId) + const counts = await service.countMembers(projectId) expect(counts[roleId]).toBe(1) }) @@ -217,13 +221,12 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { }, }) - const result = await service.deleteRole(roleId) - expect(result).toBeNull() + await service.delete(roleId) const member = await prisma.projectMembers.findUnique({ where: { projectId_userId: { projectId, userId } }, }) expect(member?.roleIds).not.toContain(roleId) - expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) }) }) diff --git a/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/apps/server-nestjs/test/project-secrets.e2e-spec.ts index cfdeac7f36..0d35a5a959 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -94,21 +94,11 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { await moduleRef?.close() - vi.restoreAllMocks() vi.unstubAllEnvs() }) - it('returns an empty secret map when Vault lookup fails', async () => { - vi.spyOn(vaultClient, 'listProjectSecrets').mockRejectedValueOnce(new Error('Vault is unavailable')) - - const secrets = await service.get(projectId) - - expect(secrets).toEqual({}) - }) - it('returns an empty secret map when no Vault secrets exist', async () => { const secrets = await service.get(projectId) - expect(secrets).toEqual({}) }) diff --git a/apps/server-nestjs/test/project-services.e2e-spec.ts b/apps/server-nestjs/test/project-services.e2e-spec.ts new file mode 100644 index 0000000000..69b219e73f --- /dev/null +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -0,0 +1,141 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' +import { ProjectServicesService } from '../src/modules/project-services/project-services.service' + +const canRunServicesE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.ARGOCD_URL) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_INTERNAL_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_INTERNAL_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONARQUBE_INTERNAL_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithServices = describe.runIf(canRunServicesE2E) + +const TEST_PLUGIN_NAME = 'gitlab' + +describeWithServices('ProjectServicesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectServicesService + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigurationModule, InfrastructureModule, ProjectServicesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectServicesService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('get returns services for a project member', async () => { + const services = await service.get(projectId, 'user') + const gitlabService = services.find(entry => entry.name === TEST_PLUGIN_NAME) + + expect(gitlabService).toBeTruthy() + expect(gitlabService?.name).toBe(TEST_PLUGIN_NAME) + }) + + it('rejects get when project does not exist', async () => { + await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) + }) + + it('update stores project configuration', async () => { + await service.update(projectId, { + gitlab: { + enabled: 'enabled', + }, + }, ['user']) + + const stored = await prisma.projectPlugin.findUniqueOrThrow({ + where: { + projectId_pluginName_key: { + projectId, + pluginName: TEST_PLUGIN_NAME, + key: 'user.enabled', + }, + }, + select: { + value: true, + }, + }) + + expect(stored.value).toBe('enabled') + }) +}) diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..cc92067f29 --- /dev/null +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,147 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectModule } from '../src/modules/project/project.module' +import { ProjectService } from '../src/modules/project/project.service' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let eventEmitter: DeepMockProxy + + let ownerId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigurationModule, InfrastructureModule, ProjectModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.listProjects({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.listProjects({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getProjectsData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await prisma.project.update({ where: { id: projectId }, data: { locked: true } }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + }) +}) diff --git a/apps/server-nestjs/test/services.e2e-spec.ts b/apps/server-nestjs/test/services.e2e-spec.ts deleted file mode 100644 index 785ba22c59..0000000000 --- a/apps/server-nestjs/test/services.e2e-spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Plugin, PluginManager } from '@cpn-console/hooks' -import type { TestingModule } from '@nestjs/testing' -import type { DeepMockProxy } from 'vitest-mock-extended' -import { pluginManager } from '@cpn-console/hooks' -import { DEFAULT } from '@cpn-console/shared' -import { faker } from '@faker-js/faker' -import { NotFoundException } from '@nestjs/common' -import { EventEmitter2 } from '@nestjs/event-emitter' -import { Test } from '@nestjs/testing' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' -import { ServicesService } from '../src/modules/services/services.service' -import { VaultClientService } from '../src/modules/vault/vault-client.service' -import { VaultService } from '../src/modules/vault/vault.service' - -const canRunServicesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) - -const describeWithServices = describe.runIf(canRunServicesE2E) - -const TEST_PLUGIN_NAME = 'e2e-service' - -describeWithServices('ServicesService (e2e)', {}, () => { - let moduleRef: TestingModule - let prisma: PrismaService - let service: ServicesService - let eventEmitter: DeepMockProxy - let vaultService: DeepMockProxy - let vaultClient: DeepMockProxy - let pm: PluginManager - let ownerId: string - let projectId: string - let projectSlug: string - - beforeAll(async () => { - pm = pluginManager({ - mockExternalServices: false, - mockHooks: true, - mockMonitoring: true, - startPlugins: false, - }) - - const plugin: Plugin = { - infos: { - name: TEST_PLUGIN_NAME, - title: 'E2E Service', - description: 'Service used by e2e tests', - imgSrc: '/e2e-service.svg', - config: { - global: [{ - kind: 'switch', - key: 'globalEnabled', - title: 'Global enabled', - value: DEFAULT, - initialValue: DEFAULT, - permissions: { - user: { read: true, write: false }, - admin: { read: true, write: true }, - }, - }], - project: [{ - kind: 'switch', - key: 'enabled', - title: 'Enabled', - value: DEFAULT, - initialValue: DEFAULT, - permissions: { - user: { read: true, write: true }, - admin: { read: true, write: true }, - }, - }], - }, - to: ({ project }) => `/e2e/${project.slug}`, - }, - subscribedHooks: {}, - } - - pm.register(plugin) - - eventEmitter = mockDeep({ - emitAsync() { return Promise.resolve([]) }, - }) - vaultService = mockDeep({ - listProjectSecrets() { return Promise.resolve([]) }, - }) - vaultClient = mockDeep() - - moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule], - providers: [ - ServicesService, - { provide: EventEmitter2, useValue: eventEmitter }, - { provide: VaultService, useValue: vaultService }, - { provide: VaultClientService, useValue: vaultClient }, - ], - }).compile() - - await moduleRef.init() - - prisma = moduleRef.get(PrismaService) - service = moduleRef.get(ServicesService) - - ownerId = faker.string.uuid() - projectId = faker.string.uuid() - projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) - - await prisma.user.create({ - data: { - id: ownerId, - email: faker.internet.email().toLowerCase(), - firstName: 'E2E', - lastName: 'Owner', - type: 'human', - }, - }) - - await prisma.project.create({ - data: { - id: projectId, - slug: projectSlug, - name: projectSlug, - ownerId, - description: 'E2E test project', - status: 'created', - locked: false, - limitless: false, - hprodCpu: 0, - hprodGpu: 0, - hprodMemory: 0, - prodCpu: 0, - prodGpu: 0, - prodMemory: 0, - everyonePerms: 0n, - lastSuccessProvisionningVersion: null, - }, - }) - }) - - afterAll(async () => { - if (prisma) { - await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) - await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) - await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) - } - - pm?.unregister(TEST_PLUGIN_NAME) - await moduleRef?.close() - - vi.restoreAllMocks() - vi.unstubAllEnvs() - }) - - it('get returns the registered service for a project member', async () => { - await prisma.projectPlugin.create({ - data: { - projectId, - pluginName: TEST_PLUGIN_NAME, - key: 'enabled', - value: 'enabled', - }, - }) - - const services = await service.get(projectId, 'user') - const e2eService = services.find(entry => entry.name === TEST_PLUGIN_NAME) - - expect(e2eService).toBeTruthy() - expect(e2eService?.urls[0]?.to).toBe(`/e2e/${projectSlug}`) - expect(e2eService?.manifest.project?.[0]?.value).toBe('enabled') - }) - - it('rejects get when project does not exist', async () => { - await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) - }) - - it('update stores project configuration', async () => { - await service.update(projectId, { - [TEST_PLUGIN_NAME]: { - enabled: 'enabled', - }, - }, ['user']) - - const stored = await prisma.projectPlugin.findUniqueOrThrow({ - where: { - projectId_pluginName_key: { - projectId, - pluginName: TEST_PLUGIN_NAME, - key: 'enabled', - }, - }, - select: { - value: true, - }, - }) - - expect(stored.value).toBe('enabled') - }) -}) diff --git a/apps/server-nestjs/test/vault.e2e-spec.ts b/apps/server-nestjs/test/vault.e2e-spec.ts index cef483bb40..056a9eb46b 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -67,7 +67,6 @@ describeWithVault('VaultController (e2e)', () => { await moduleRef?.close() - vi.restoreAllMocks() vi.unstubAllEnvs() })