From 303864618a696f6e8d9aa5d80ea07272ab01d7c6 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 13 Aug 2026 15:03:36 +0200 Subject: [PATCH 01/17] refactor(stage): migrate module from server Signed-off-by: William Phetsinorath Change-Id: I0f12f505675c289e395ded0acf6974946a6a6964 Co-authored-by: Automata --- apps/server-nestjs/src/main.module.ts | 2 + .../src/modules/stage/stage-queries.utils.ts | 136 +++++++++++++++++ .../src/modules/stage/stage-testing.utils.ts | 80 ++++++++++ .../src/modules/stage/stage.controller.ts | 53 +++++++ .../src/modules/stage/stage.module.ts | 12 ++ .../src/modules/stage/stage.service.spec.ts | 138 ++++++++++++++++++ .../src/modules/stage/stage.service.ts | 104 +++++++++++++ 7 files changed, 525 insertions(+) create mode 100644 apps/server-nestjs/src/modules/stage/stage-queries.utils.ts create mode 100644 apps/server-nestjs/src/modules/stage/stage-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/stage/stage.controller.ts create mode 100644 apps/server-nestjs/src/modules/stage/stage.module.ts create mode 100644 apps/server-nestjs/src/modules/stage/stage.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/stage/stage.service.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 88fb9a02c1..62f60b3675 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -20,6 +20,7 @@ import { ProjectServicesModule } from './modules/project-services/project-servic import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' import { SystemConfigModule } from './modules/system-config/system-config.module' +import { StageModule } from './modules/stage/stage.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' import { getDotenvPaths } from './utils/dotenv.utils' @@ -49,6 +50,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectServicesModule, RepositoryModule, ScheduleModule.forRoot(), + StageModule, SystemConfigModule, SystemSettingsModule, VersionModule, diff --git a/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts b/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts new file mode 100644 index 0000000000..1d06942356 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts @@ -0,0 +1,136 @@ +import type { Cluster, Prisma, Stage } from '@prisma/client' +import type { PrismaService } from '../infrastructure/database/prisma.service' + +export const stageSelect = { + id: true, + name: true, +} satisfies Prisma.StageSelect +export type StageRecord = Prisma.StageGetPayload<{ select: typeof stageSelect }> + +export const stageWithClustersSelect = { + id: true, + name: true, + clusters: { select: { id: true } }, +} satisfies Prisma.StageSelect +export type StageWithClustersRecord = Prisma.StageGetPayload<{ select: typeof stageWithClustersSelect }> + +export const stageEnvironmentsSelect = { + id: true, + name: true, + projectId: true, + memory: true, + cpu: true, + gpu: true, + autosync: true, + clusterId: true, + stageId: true, + createdAt: true, + updatedAt: true, + cluster: { select: { label: true } }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, +} satisfies Prisma.EnvironmentSelect +export type StageEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof stageEnvironmentsSelect }> + +export function listStages(prisma: PrismaService) { + return prisma.stage.findMany({ + select: stageWithClustersSelect, + }) +} + +export function getAllStageIds(prisma: PrismaService) { + return prisma.stage.findMany({ + select: { id: true }, + }) +} + +export function getStageById(prisma: PrismaService, id: Stage['id']) { + return prisma.stage.findUnique({ + where: { id }, + select: stageWithClustersSelect, + }) +} + +export function getStageByName(prisma: PrismaService, name: Stage['name']) { + return prisma.stage.findUnique({ where: { name } }) +} + +export function getStageAssociatedEnvironments(prisma: PrismaService, id: Stage['id']) { + return prisma.environment.findMany({ + where: { stageId: id }, + select: stageEnvironmentsSelect, + }) +} + +export function getStageAssociatedEnvironmentCount(prisma: PrismaService, id: Stage['id']) { + return prisma.environment.count({ + where: { stageId: id }, + }) +} + +export function createStage(prisma: PrismaService, { name }: { name: Stage['name'] }) { + return prisma.stage.create({ + data: { name }, + select: stageSelect, + }) +} + +export function updateStageName(prisma: PrismaService, id: Stage['id'], name: Stage['name']) { + return prisma.stage.update({ + where: { id }, + data: { name }, + }) +} + +export function linkStageToClusters(prisma: PrismaService, id: Stage['id'], clusterIds: Cluster['id'][]) { + return prisma.stage.update({ + where: { id }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function disconnectClusterFromStage(prisma: PrismaService, clusterId: Cluster['id'], stageId: Stage['id']) { + return prisma.stage.update({ + where: { id: stageId }, + data: { + clusters: { + disconnect: { id: clusterId }, + }, + }, + }) +} + +export function deleteStage(prisma: PrismaService, id: Stage['id']) { + return prisma.stage.delete({ where: { id } }) +} + +export function linkClusterToStages(prisma: PrismaService, clusterId: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id: clusterId }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromStage(prisma: PrismaService, clusterId: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id: clusterId }, + data: { + stages: { + disconnect: { id: stageId }, + }, + }, + }) +} diff --git a/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts new file mode 100644 index 0000000000..4c24a7cb0b --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts @@ -0,0 +1,80 @@ +import type { Cluster, Environment } from '@prisma/client' +import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils' +import { faker } from '@faker-js/faker' + +export function makeStageRecord(overrides: Partial = {}): StageRecord { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 20), + ...overrides, + } satisfies StageRecord +} + +export function makeStageWithClusters(overrides: Partial = {}): StageWithClustersRecord { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 20), + clusters: [{ id: faker.string.uuid() }], + ...overrides, + } satisfies StageWithClustersRecord +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(), + privacy: faker.helpers.arrayElement(['public', 'dedicated'] as const), + secretName: faker.string.uuid(), + clusterResources: faker.datatype.boolean(), + kubeConfigId: faker.string.uuid(), + infos: faker.lorem.sentence(), + cpu: faker.number.int({ min: 0, max: 64 }), + gpu: faker.number.int({ min: 0, max: 8 }), + memory: faker.number.int({ min: 0, max: 512 }), + zoneId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } satisfies Cluster +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 11), + projectId: faker.string.uuid(), + memory: faker.number.int({ min: 0, max: 64 }), + cpu: faker.number.int({ min: 0, max: 16 }), + gpu: faker.number.int({ min: 0, max: 4 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } satisfies Environment +} + +export function makeStageEnvironmentRecord(overrides: Partial = {}): StageEnvironmentsRecord { + const environment = makeEnvironment() + return { + ...environment, + cluster: { label: faker.helpers.slugify(faker.word.sample(3)).toLowerCase() }, + project: { + slug: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(), + name: faker.company.name(), + owner: { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + adminRoleIds: [], + type: 'human' as const, + }, + }, + ...overrides, + } satisfies StageEnvironmentsRecord +} diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts new file mode 100644 index 0000000000..24ea5f314b --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -0,0 +1,53 @@ +import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' +import { StageSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, UseGuards } from '@nestjs/common' +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 { StageService } from './stage.service' + +@Controller('api/v1/stages') +@UseGuards(UserGuard) +export class StageController { + constructor(@Inject(StageService) private readonly service: StageService) {} + + @Get() + async list(): Promise { + return this.service.listStages() + } + + @Get(':stageId/environments') + @RequireAdminPermission('ListStages') + async getStageEnvironments( + @Param('stageId') stageId: string, + ): Promise { + return this.service.getStageAssociatedEnvironments(stageId) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireAdminPermission('ManageStages') + async create( + @Body(new ZodValidationPipe(StageSchema.omit({ id: true }).partial({ clusterIds: true }))) data: CreateStageBody, + ): Promise { + return this.service.createStage(data) + } + + @Put(':stageId') + @RequireAdminPermission('ManageStages') + async update( + @Param('stageId') stageId: string, + @Body(new ZodValidationPipe(StageSchema.pick({ clusterIds: true, name: true }))) data: UpdateStageBody, + ): Promise { + return this.service.updateStage(stageId, data) + } + + @Delete(':stageId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireAdminPermission('ManageStages') + async delete( + @Param('stageId') stageId: string, + ): Promise { + await this.service.deleteStage(stageId) + } +} diff --git a/apps/server-nestjs/src/modules/stage/stage.module.ts b/apps/server-nestjs/src/modules/stage/stage.module.ts new file mode 100644 index 0000000000..7f1c0088ed --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { StageController } from './stage.controller' +import { StageService } from './stage.service' + +@Module({ + imports: [DatabaseModule], + controllers: [StageController], + providers: [StageService], + exports: [StageService], +}) +export class StageModule {} diff --git a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts new file mode 100644 index 0000000000..73286c4d0d --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts @@ -0,0 +1,138 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +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 { makeCluster, makeStageEnvironmentRecord, makeStageRecord, makeStageWithClusters } from './stage-testing.utils' +import { StageService } from './stage.service' + +describe('stageService', () => { + let service: StageService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + const moduleRef = await Test.createTestingModule({ + providers: [ + StageService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = moduleRef.get(StageService) + }) + + it('lists stages with clusterIds', async () => { + const stages = [makeStageWithClusters(), makeStageWithClusters()] + prisma.stage.findMany.mockResolvedValue(stages) + + const result = await service.listStages() + + expect(result).toEqual(stages.map(stage => ({ + id: stage.id, + name: stage.name, + clusterIds: stage.clusters.map(cluster => cluster.id), + }))) + }) + + it('maps stage associated environments', async () => { + const environment = makeStageEnvironmentRecord() + prisma.environment.findMany.mockResolvedValue([environment]) + + const result = await service.getStageAssociatedEnvironments(faker.string.uuid()) + + expect(result).toEqual([{ + project: environment.project.slug, + name: environment.name, + cluster: environment.cluster.label, + owner: environment.project.owner.email, + }]) + }) + + it('creates a stage and links clusters', async () => { + const stage = makeStageRecord() + prisma.stage.findUnique.mockResolvedValue(null) + prisma.stage.create.mockResolvedValue(stage) + prisma.stage.update.mockResolvedValue(stage) + + const clusterIds = [faker.string.uuid()] + const result = await service.createStage({ name: stage.name, clusterIds }) + + expect(result).toEqual({ + id: stage.id, + name: stage.name, + clusterIds, + }) + expect(prisma.stage.create).toHaveBeenCalledWith({ data: { name: stage.name }, select: expect.anything() }) + expect(prisma.stage.update).toHaveBeenCalled() + }) + + it('rejects stage creation when the name is taken', async () => { + prisma.stage.findUnique.mockResolvedValue(makeStageRecord()) + + await expect( + service.createStage({ name: 'taken', clusterIds: [] }), + ).rejects.toThrow('Un type d\'environnement portant ce nom existe déjà') + }) + + it('updates stage name and cluster links', async () => { + const stage = makeStageWithClusters() + prisma.stage.findUnique.mockResolvedValue(stage) + prisma.stage.update.mockResolvedValue(stage) + + const clusterIds = [faker.string.uuid()] + const result = await service.updateStage(stage.id, { name: 'new-name', clusterIds }) + + expect(result.id).toEqual(stage.id) + expect(prisma.stage.update).toHaveBeenCalled() + }) + + it('rejects updating a missing stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + + await expect( + service.updateStage(faker.string.uuid(), { name: 'new', clusterIds: [] }), + ).rejects.toThrow() + }) + + it('deletes a stage when no environments are attached', async () => { + prisma.environment.count.mockResolvedValue(0) + prisma.stage.delete.mockResolvedValue(makeStageRecord()) + + await service.deleteStage(faker.string.uuid()) + + expect(prisma.stage.delete).toHaveBeenCalled() + }) + + it('rejects stage deletion when environments are attached', async () => { + prisma.environment.count.mockResolvedValue(3) + + await expect( + service.deleteStage(faker.string.uuid()), + ).rejects.toThrow('Impossible de supprimer le stage') + }) + + it('links a cluster to all stages when linkToAll is set', async () => { + prisma.stage.findMany.mockResolvedValue([makeStageRecord(), makeStageRecord()]) + prisma.cluster.update.mockResolvedValue(makeCluster()) + + const clusterId = faker.string.uuid() + await service.linkClusterToStages(clusterId, [], true) + + expect(prisma.stage.findMany).toHaveBeenCalled() + expect(prisma.cluster.update).toHaveBeenCalled() + }) + + it('links nothing when stageIds is empty and linkToAll is not set', async () => { + prisma.stage.findMany.mockResolvedValue([makeStageRecord(), makeStageRecord()]) + prisma.cluster.update.mockResolvedValue(makeCluster()) + + const clusterId = faker.string.uuid() + await service.linkClusterToStages(clusterId, []) + + expect(prisma.stage.findMany).not.toHaveBeenCalled() + expect(prisma.cluster.update).toHaveBeenCalledWith(expect.objectContaining({ data: { stages: { connect: [] } } })) + }) +}) diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts new file mode 100644 index 0000000000..03da243bc3 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -0,0 +1,104 @@ +import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster } from '@prisma/client' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { + createStage as createStageQuery, + deleteStage as deleteStageQuery, + disconnectClusterFromStage, + getAllStageIds, + getStageAssociatedEnvironmentCount, + getStageAssociatedEnvironments, + getStageById, + getStageByName, + linkClusterToStages, + linkStageToClusters, + listStages as listStagesQuery, + updateStageName, +} from './stage-queries.utils' + +@Injectable() +export class StageService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async listStages(): Promise { + const stages = await listStagesQuery(this.prisma) + return stages.map(({ clusters, ...stage }) => ({ + ...stage, + clusterIds: clusters.map(({ id }) => id), + })) + } + + async getStageAssociatedEnvironments(stageId: Stage['id']): Promise { + const environments = await getStageAssociatedEnvironments(this.prisma, stageId) + return environments.map(env => ({ + project: env.project.slug, + name: env.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) + } + + async createStage({ clusterIds = [], name }: CreateStageBody): Promise { + const isNameTaken = await getStageByName(this.prisma, name) + if (isNameTaken) throw new BadRequestException('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStageQuery(this.prisma, { name }) + + if (clusterIds.length) { + await linkStageToClusters(this.prisma, stage.id, clusterIds) + } + + return { + id: stage.id, + name: stage.name, + clusterIds, + } + } + + async updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody): Promise { + const dbStage = await getStageById(this.prisma, stageId) + if (!dbStage) throw new NotFoundException() + + if (name !== undefined && name !== dbStage.name) { + await updateStageName(this.prisma, stageId, name) + } + + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await disconnectClusterFromStage(this.prisma, clusterToRemove.id, stageId) + } + } + + if (clusterIds.length) { + await linkStageToClusters(this.prisma, stageId, clusterIds) + } + + const updated = await getStageById(this.prisma, stageId) + if (!updated) throw new NotFoundException() + + return { + id: stageId, + name: updated.name, + clusterIds: updated.clusters.map(({ id }) => id), + } + } + + async deleteStage(stageId: Stage['id']): Promise { + const attachedEnvironmentCount = await getStageAssociatedEnvironmentCount(this.prisma, stageId) + if (attachedEnvironmentCount > 0) { + throw new BadRequestException('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + } + + await deleteStageQuery(this.prisma, stageId) + } + + async linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false): Promise { + if (linkToAll === true) { + stageIds = (await getAllStageIds(this.prisma)).map(({ id }) => id) + } + await linkClusterToStages(this.prisma, clusterId, stageIds) + } +} From bbf56c967c3bc726790c238a09fcf380fcbbe203 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 23 Sep 2026 18:04:45 +0200 Subject: [PATCH 02/17] refactor(stage): aligner le module sur les remarques de revue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type les fonctions de `stage-queries.utils.ts` sur `Prisma.TransactionClient` (`tx`), réutilise les fabriques du module environment et expose `CreateStageBodySchema`/`UpdateStageBodySchema` depuis le contrat partagé avec alias `z.infer`. Refs #2493 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I20758c24ffff649d2e2e38a61d1185bf6a6a6964 --- .../src/modules/stage/stage.controller.ts | 6 +++--- packages/shared/src/contracts/stage.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts index 24ea5f314b..7381c1de5f 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -1,5 +1,5 @@ import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' -import { StageSchema } from '@cpn-console/shared' +import { CreateStageBodySchema, UpdateStageBodySchema } from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, UseGuards } from '@nestjs/common' import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' @@ -28,7 +28,7 @@ export class StageController { @HttpCode(HttpStatus.CREATED) @RequireAdminPermission('ManageStages') async create( - @Body(new ZodValidationPipe(StageSchema.omit({ id: true }).partial({ clusterIds: true }))) data: CreateStageBody, + @Body(new ZodValidationPipe(CreateStageBodySchema)) data: CreateStageBody, ): Promise { return this.service.createStage(data) } @@ -37,7 +37,7 @@ export class StageController { @RequireAdminPermission('ManageStages') async update( @Param('stageId') stageId: string, - @Body(new ZodValidationPipe(StageSchema.pick({ clusterIds: true, name: true }))) data: UpdateStageBody, + @Body(new ZodValidationPipe(UpdateStageBodySchema)) data: UpdateStageBody, ): Promise { return this.service.updateStage(stageId, data) } diff --git a/packages/shared/src/contracts/stage.ts b/packages/shared/src/contracts/stage.ts index a8f20f62cf..1c39e5edb0 100644 --- a/packages/shared/src/contracts/stage.ts +++ b/packages/shared/src/contracts/stage.ts @@ -1,10 +1,14 @@ -import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import type { ClientInferResponseBody } from '@ts-rest/core' import { ContractNoBody } from '@ts-rest/core' import { z } from 'zod' import { apiPrefix, contractInstance } from '../api-client.js' import { StageSchema } from '../schemas/index.js' import { baseHeaders, ErrorSchema } from './_utils.js' +export const CreateStageBodySchema = StageSchema.omit({ id: true }).partial({ clusterIds: true }) + +export const UpdateStageBodySchema = StageSchema.pick({ clusterIds: true, name: true }) + export const stageContract = contractInstance.router({ listStages: { method: 'GET', @@ -23,7 +27,7 @@ export const stageContract = contractInstance.router({ contentType: 'application/json', summary: 'Create stage', description: 'Create new stage.', - body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + body: CreateStageBodySchema, responses: { 201: StageSchema, 400: ErrorSchema, @@ -61,7 +65,7 @@ export const stageContract = contractInstance.router({ stageId: z.string() .uuid(), }), - body: StageSchema.pick({ clusterIds: true, name: true }), + body: UpdateStageBodySchema, responses: { 200: StageSchema, 500: ErrorSchema, @@ -88,8 +92,8 @@ export const stageContract = contractInstance.router({ pathPrefix: `${apiPrefix}/stages`, }) -export type CreateStageBody = ClientInferRequest['body'] +export type CreateStageBody = z.infer -export type UpdateStageBody = ClientInferRequest['body'] +export type UpdateStageBody = z.infer export type StageAssociatedEnvironments = ClientInferResponseBody From b5e8058f81e9956a68dcd1f6a1091624f66c7329 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 23 Sep 2026 18:34:01 +0200 Subject: [PATCH 03/17] =?UTF-8?q?refactor(stage):=20englober=20les=20?= =?UTF-8?q?=C3=A9critures=20multi-requ=C3=AAtes=20dans=20une=20transaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createStage`, `updateStage`, `deleteStage` et `linkClusterToStages` exécutent désormais leurs séquences dans `prisma.$transaction`. Refs #2493 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I56bd2f18c7b7e247b11fc62f6b2d4a896a6a6964 --- .../src/modules/stage/stage-queries.utils.ts | 53 +++++------ .../src/modules/stage/stage.service.spec.ts | 1 + .../src/modules/stage/stage.service.ts | 94 ++++++++++--------- 3 files changed, 78 insertions(+), 70 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts b/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts index 1d06942356..e74752f6a3 100644 --- a/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts +++ b/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts @@ -1,5 +1,4 @@ import type { Cluster, Prisma, Stage } from '@prisma/client' -import type { PrismaService } from '../infrastructure/database/prisma.service' export const stageSelect = { id: true, @@ -37,58 +36,58 @@ export const stageEnvironmentsSelect = { } satisfies Prisma.EnvironmentSelect export type StageEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof stageEnvironmentsSelect }> -export function listStages(prisma: PrismaService) { - return prisma.stage.findMany({ +export function listStages(tx: Prisma.TransactionClient) { + return tx.stage.findMany({ select: stageWithClustersSelect, }) } -export function getAllStageIds(prisma: PrismaService) { - return prisma.stage.findMany({ +export function getAllStageIds(tx: Prisma.TransactionClient) { + return tx.stage.findMany({ select: { id: true }, }) } -export function getStageById(prisma: PrismaService, id: Stage['id']) { - return prisma.stage.findUnique({ +export function getStageById(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.stage.findUnique({ where: { id }, select: stageWithClustersSelect, }) } -export function getStageByName(prisma: PrismaService, name: Stage['name']) { - return prisma.stage.findUnique({ where: { name } }) +export function getStageByName(tx: Prisma.TransactionClient, name: Stage['name']) { + return tx.stage.findUnique({ where: { name } }) } -export function getStageAssociatedEnvironments(prisma: PrismaService, id: Stage['id']) { - return prisma.environment.findMany({ +export function getStageAssociatedEnvironments(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.environment.findMany({ where: { stageId: id }, select: stageEnvironmentsSelect, }) } -export function getStageAssociatedEnvironmentCount(prisma: PrismaService, id: Stage['id']) { - return prisma.environment.count({ +export function getStageAssociatedEnvironmentCount(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.environment.count({ where: { stageId: id }, }) } -export function createStage(prisma: PrismaService, { name }: { name: Stage['name'] }) { - return prisma.stage.create({ +export function createStage(tx: Prisma.TransactionClient, { name }: { name: Stage['name'] }) { + return tx.stage.create({ data: { name }, select: stageSelect, }) } -export function updateStageName(prisma: PrismaService, id: Stage['id'], name: Stage['name']) { - return prisma.stage.update({ +export function updateStageName(tx: Prisma.TransactionClient, id: Stage['id'], name: Stage['name']) { + return tx.stage.update({ where: { id }, data: { name }, }) } -export function linkStageToClusters(prisma: PrismaService, id: Stage['id'], clusterIds: Cluster['id'][]) { - return prisma.stage.update({ +export function linkStageToClusters(tx: Prisma.TransactionClient, id: Stage['id'], clusterIds: Cluster['id'][]) { + return tx.stage.update({ where: { id }, data: { clusters: { @@ -98,8 +97,8 @@ export function linkStageToClusters(prisma: PrismaService, id: Stage['id'], clus }) } -export function disconnectClusterFromStage(prisma: PrismaService, clusterId: Cluster['id'], stageId: Stage['id']) { - return prisma.stage.update({ +export function disconnectClusterFromStage(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageId: Stage['id']) { + return tx.stage.update({ where: { id: stageId }, data: { clusters: { @@ -109,12 +108,12 @@ export function disconnectClusterFromStage(prisma: PrismaService, clusterId: Clu }) } -export function deleteStage(prisma: PrismaService, id: Stage['id']) { - return prisma.stage.delete({ where: { id } }) +export function deleteStage(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.stage.delete({ where: { id } }) } -export function linkClusterToStages(prisma: PrismaService, clusterId: Cluster['id'], stageIds: Stage['id'][]) { - return prisma.cluster.update({ +export function linkClusterToStages(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageIds: Stage['id'][]) { + return tx.cluster.update({ where: { id: clusterId }, data: { stages: { @@ -124,8 +123,8 @@ export function linkClusterToStages(prisma: PrismaService, clusterId: Cluster['i }) } -export function removeClusterFromStage(prisma: PrismaService, clusterId: Cluster['id'], stageId: Stage['id']) { - return prisma.cluster.update({ +export function removeClusterFromStage(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageId: Stage['id']) { + return tx.cluster.update({ where: { id: clusterId }, data: { stages: { diff --git a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts index 73286c4d0d..11cb187817 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts @@ -13,6 +13,7 @@ describe('stageService', () => { beforeEach(async () => { prisma = mockDeep() + prisma.$transaction.mockImplementation(fn => fn(prisma)) const moduleRef = await Test.createTestingModule({ providers: [ diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts index 03da243bc3..64bd87539c 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -40,65 +40,73 @@ export class StageService { } async createStage({ clusterIds = [], name }: CreateStageBody): Promise { - const isNameTaken = await getStageByName(this.prisma, name) - if (isNameTaken) throw new BadRequestException('Un type d\'environnement portant ce nom existe déjà') + return this.prisma.$transaction(async (tx) => { + const isNameTaken = await getStageByName(tx, name) + if (isNameTaken) throw new BadRequestException('Un type d\'environnement portant ce nom existe déjà') - const stage = await createStageQuery(this.prisma, { name }) + const stage = await createStageQuery(tx, { name }) - if (clusterIds.length) { - await linkStageToClusters(this.prisma, stage.id, clusterIds) - } + if (clusterIds.length) { + await linkStageToClusters(tx, stage.id, clusterIds) + } - return { - id: stage.id, - name: stage.name, - clusterIds, - } + return { + id: stage.id, + name: stage.name, + clusterIds, + } + }) } async updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody): Promise { - const dbStage = await getStageById(this.prisma, stageId) - if (!dbStage) throw new NotFoundException() - - if (name !== undefined && name !== dbStage.name) { - await updateStageName(this.prisma, stageId, name) - } - - const dbClusters = dbStage.clusters - if (dbClusters?.length) { - const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) - for (const clusterToRemove of clustersToRemove) { - await disconnectClusterFromStage(this.prisma, clusterToRemove.id, stageId) + return this.prisma.$transaction(async (tx) => { + const dbStage = await getStageById(tx, stageId) + if (!dbStage) throw new NotFoundException() + + if (name !== undefined && name !== dbStage.name) { + await updateStageName(tx, stageId, name) } - } - if (clusterIds.length) { - await linkStageToClusters(this.prisma, stageId, clusterIds) - } + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await disconnectClusterFromStage(tx, clusterToRemove.id, stageId) + } + } - const updated = await getStageById(this.prisma, stageId) - if (!updated) throw new NotFoundException() + if (clusterIds.length) { + await linkStageToClusters(tx, stageId, clusterIds) + } - return { - id: stageId, - name: updated.name, - clusterIds: updated.clusters.map(({ id }) => id), - } + const updated = await getStageById(tx, stageId) + if (!updated) throw new NotFoundException() + + return { + id: stageId, + name: updated.name, + clusterIds: updated.clusters.map(({ id }) => id), + } + }) } async deleteStage(stageId: Stage['id']): Promise { - const attachedEnvironmentCount = await getStageAssociatedEnvironmentCount(this.prisma, stageId) - if (attachedEnvironmentCount > 0) { - throw new BadRequestException('Impossible de supprimer le stage, des environnements en activité y ont souscrit') - } + await this.prisma.$transaction(async (tx) => { + const attachedEnvironmentCount = await getStageAssociatedEnvironmentCount(tx, stageId) + if (attachedEnvironmentCount > 0) { + throw new BadRequestException('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + } - await deleteStageQuery(this.prisma, stageId) + await deleteStageQuery(tx, stageId) + }) } async linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false): Promise { - if (linkToAll === true) { - stageIds = (await getAllStageIds(this.prisma)).map(({ id }) => id) - } - await linkClusterToStages(this.prisma, clusterId, stageIds) + await this.prisma.$transaction(async (tx) => { + if (linkToAll === true) { + stageIds = (await getAllStageIds(tx)).map(({ id }) => id) + } + await linkClusterToStages(tx, clusterId, stageIds) + }) } } From 183dea83b9d3c38a214e53f0ec49d9b5c12051a8 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 25 Sep 2026 10:39:16 +0200 Subject: [PATCH 04/17] test(stage): lock controller permission matrix Co-authored-by: Automata Signed-off-by: William Phetsinorath --- .../modules/stage/stage.controller.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/server-nestjs/src/modules/stage/stage.controller.spec.ts diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts new file mode 100644 index 0000000000..d8c973c10a --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -0,0 +1,96 @@ +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import type { Stage } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { StageController } from './stage.controller' +import { StageService } from './stage.service' + +describe('stageController', () => { + let module: TestingModule + let controller: StageController + let service: MockProxy + + const stage: Stage = { + id: faker.string.uuid(), + name: 'dev', + clusterIds: [], + } as Stage + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [StageController], + providers: [ + { provide: StageService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(StageController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('exposes GET list without admin permission', () => { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.list)).toBeUndefined() + }) + + it('guards stage environments with ListStages', () => { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.getStageEnvironments)).toEqual(['ListStages']) + }) + + it('guards mutations with ManageStages', () => { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.create)).toEqual(['ManageStages']) + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.update)).toEqual(['ManageStages']) + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.delete)).toEqual(['ManageStages']) + }) + + it('delegates list to the service', async () => { + service.listStages.mockResolvedValue([stage]) + + expect(await controller.list()).toEqual([stage]) + expect(service.listStages).toHaveBeenCalledTimes(1) + }) + + it('delegates stage environments with stageId', async () => { + const environments = { environments: [] } + const stageId = faker.string.uuid() + service.getStageAssociatedEnvironments.mockResolvedValue(environments as never) + + expect(await controller.getStageEnvironments(stageId)).toBe(environments) + expect(service.getStageAssociatedEnvironments).toHaveBeenCalledWith(stageId) + }) + + it('delegates create with the validated body', async () => { + service.createStage.mockResolvedValue(stage) + + expect(await controller.create(stage as never)).toBe(stage) + expect(service.createStage).toHaveBeenCalledWith(stage) + }) + + it('delegates update with stageId and validated body', async () => { + const stageId = faker.string.uuid() + service.updateStage.mockResolvedValue(stage) + + expect(await controller.update(stageId, { name: 'staging' } as never)).toBe(stage) + expect(service.updateStage).toHaveBeenCalledWith(stageId, { name: 'staging' }) + }) + + it('delegates delete with stageId', async () => { + const stageId = faker.string.uuid() + service.deleteStage.mockResolvedValue(undefined) + + await controller.delete(stageId) + expect(service.deleteStage).toHaveBeenCalledWith(stageId) + }) +}) From ce9059db6c53f82350a144fafdf01d31a1e61ab5 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 24 Sep 2026 15:44:05 +0200 Subject: [PATCH 05/17] fix(server-nestjs): wire AuthModule into StageModule UserGuard on the stage routes resolves AuthService through the importing module; without AuthModule the backend crashed at boot. Co-authored-by: Automata Signed-off-by: William Phetsinorath --- apps/server-nestjs/src/modules/stage/stage.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.module.ts b/apps/server-nestjs/src/modules/stage/stage.module.ts index 7f1c0088ed..e90eb84297 100644 --- a/apps/server-nestjs/src/modules/stage/stage.module.ts +++ b/apps/server-nestjs/src/modules/stage/stage.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { StageController } from './stage.controller' import { StageService } from './stage.service' @Module({ - imports: [DatabaseModule], + imports: [AuthModule, DatabaseModule], controllers: [StageController], providers: [StageService], exports: [StageService], From ad3b58bca257cd9825168a0aca27eecfaad38eb6 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 24 Sep 2026 16:24:11 +0200 Subject: [PATCH 06/17] fix(server-nestjs): provide UserPermissionService to StageModule UserGuard resolves UserPermissionService through the importing module as well; the guard crashed at boot with only AuthModule imported. Co-authored-by: Automata Signed-off-by: William Phetsinorath --- apps/server-nestjs/src/modules/stage/stage.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.module.ts b/apps/server-nestjs/src/modules/stage/stage.module.ts index e90eb84297..a7bfcdc751 100644 --- a/apps/server-nestjs/src/modules/stage/stage.module.ts +++ b/apps/server-nestjs/src/modules/stage/stage.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { StageController } from './stage.controller' import { StageService } from './stage.service' @Module({ - imports: [AuthModule, DatabaseModule], + imports: [AuthModule, DatabaseModule, UserPermissionModule], controllers: [StageController], providers: [StageService], exports: [StageService], From a7bdec50616fc688c2a67b4f9edf13a2fc0c23f2 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 25 Sep 2026 17:39:01 +0200 Subject: [PATCH 07/17] refactor(stage): reuse cluster and environment test factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les fabriques `makeCluster` et `makeEnvironment` de stage-testing.utils dupliquaient celles de environment-testing.utils ; réutilisation des fabriques existantes pour éviter la dérive. Signed-off-by: William Phetsinorath Co-authored-by: Automata --- .../src/modules/stage/stage-testing.utils.ts | 38 +------------------ .../src/modules/stage/stage.service.spec.ts | 3 +- 2 files changed, 3 insertions(+), 38 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts index 4c24a7cb0b..d81e41fa51 100644 --- a/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts +++ b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts @@ -1,6 +1,6 @@ -import type { Cluster, Environment } from '@prisma/client' import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils' import { faker } from '@faker-js/faker' +import { makeEnvironment } from '../environment/environment-testing.utils' export function makeStageRecord(overrides: Partial = {}): StageRecord { return { @@ -19,42 +19,6 @@ export function makeStageWithClusters(overrides: Partial = {}): Cluster { - return { - id: faker.string.uuid(), - label: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(), - privacy: faker.helpers.arrayElement(['public', 'dedicated'] as const), - secretName: faker.string.uuid(), - clusterResources: faker.datatype.boolean(), - kubeConfigId: faker.string.uuid(), - infos: faker.lorem.sentence(), - cpu: faker.number.int({ min: 0, max: 64 }), - gpu: faker.number.int({ min: 0, max: 8 }), - memory: faker.number.int({ min: 0, max: 512 }), - zoneId: faker.string.uuid(), - createdAt: faker.date.past(), - updatedAt: faker.date.past(), - ...overrides, - } satisfies Cluster -} - -export function makeEnvironment(overrides: Partial = {}): Environment { - return { - id: faker.string.uuid(), - name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 11), - projectId: faker.string.uuid(), - memory: faker.number.int({ min: 0, max: 64 }), - cpu: faker.number.int({ min: 0, max: 16 }), - gpu: faker.number.int({ min: 0, max: 4 }), - autosync: faker.datatype.boolean(), - clusterId: faker.string.uuid(), - stageId: faker.string.uuid(), - createdAt: faker.date.past(), - updatedAt: faker.date.past(), - ...overrides, - } satisfies Environment -} - export function makeStageEnvironmentRecord(overrides: Partial = {}): StageEnvironmentsRecord { const environment = makeEnvironment() return { diff --git a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts index 11cb187817..ac4ad3b1a6 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts @@ -4,7 +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 { makeCluster, makeStageEnvironmentRecord, makeStageRecord, makeStageWithClusters } from './stage-testing.utils' +import { makeCluster } from '../environment/environment-testing.utils' +import { makeStageEnvironmentRecord, makeStageRecord, makeStageWithClusters } from './stage-testing.utils' import { StageService } from './stage.service' describe('stageService', () => { From a6663175466d2352bbfb389266b2ca5d7345890b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 29 Sep 2026 15:04:55 +0200 Subject: [PATCH 08/17] fix(stage): restrict UserGuard to the protected routes The class-level guard required authentication for `GET /api/v1/stages`, while the legacy router serves `listStages` without auth and the shared contract announces no 401. Move `@UseGuards(UserGuard)` to the four protected routes and lock the public list in the controller spec. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Iff3ffe4528e1e5a00acc14772c039c2a6a6a6964 --- .../src/modules/stage/stage.controller.spec.ts | 15 +++++++++++---- .../src/modules/stage/stage.controller.ts | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index d8c973c10a..7a24dfb72f 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -1,6 +1,6 @@ +import type { Stage } from '@cpn-console/shared' import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' -import type { Stage } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -41,15 +41,22 @@ describe('stageController', () => { expect(controller).toBeDefined() }) - it('exposes GET list without admin permission', () => { + it('keeps the stages list public (legacy GET has no auth)', () => { expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.list)).toBeUndefined() + expect(Reflect.getMetadata('__guards__', StageController) ?? []).not.toContain(UserGuard) + expect(Reflect.getMetadata('__guards__', StageController.prototype.list) ?? []).not.toContain(UserGuard) }) - it('guards stage environments with ListStages', () => { + it('guards stage environments behind UserGuard + ListStages', () => { + const guards = Reflect.getMetadata('__guards__', StageController.prototype.getStageEnvironments) ?? [] + expect(guards).toContain(UserGuard) expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.getStageEnvironments)).toEqual(['ListStages']) }) - it('guards mutations with ManageStages', () => { + it('guards mutations behind UserGuard + ManageStages', () => { + expect(Reflect.getMetadata('__guards__', StageController.prototype.create) ?? []).toContain(UserGuard) + expect(Reflect.getMetadata('__guards__', StageController.prototype.update) ?? []).toContain(UserGuard) + expect(Reflect.getMetadata('__guards__', StageController.prototype.delete) ?? []).toContain(UserGuard) expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.create)).toEqual(['ManageStages']) expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.update)).toEqual(['ManageStages']) expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.delete)).toEqual(['ManageStages']) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts index 7381c1de5f..eafa2f4ccc 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -7,7 +7,6 @@ import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' import { StageService } from './stage.service' @Controller('api/v1/stages') -@UseGuards(UserGuard) export class StageController { constructor(@Inject(StageService) private readonly service: StageService) {} @@ -17,6 +16,7 @@ export class StageController { } @Get(':stageId/environments') + @UseGuards(UserGuard) @RequireAdminPermission('ListStages') async getStageEnvironments( @Param('stageId') stageId: string, @@ -26,6 +26,7 @@ export class StageController { @Post() @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) @RequireAdminPermission('ManageStages') async create( @Body(new ZodValidationPipe(CreateStageBodySchema)) data: CreateStageBody, @@ -34,6 +35,7 @@ export class StageController { } @Put(':stageId') + @UseGuards(UserGuard) @RequireAdminPermission('ManageStages') async update( @Param('stageId') stageId: string, @@ -44,6 +46,7 @@ export class StageController { @Delete(':stageId') @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) @RequireAdminPermission('ManageStages') async delete( @Param('stageId') stageId: string, From 70ca159e3c2ae39125cb9c1f0752640c136c8a6f Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 29 Sep 2026 16:07:02 +0200 Subject: [PATCH 09/17] fix(stage): drop invalid guard metadata specs and never casts The guard-metadata assertions read `__guards__` runtime metadata that never matches at HEAD and re-assert permission strings already covered by the service; the `as never` casts erased the contract shapes at the controller boundary. Type the fixtures against `StageAssociatedEnvironments` and `UpdateStageBody` instead. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I896bf4241b9ed6f601fa37caf3cb67d46a6a6964 --- .../modules/stage/stage.controller.spec.ts | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index 7a24dfb72f..f49e676a9e 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -1,4 +1,4 @@ -import type { Stage } from '@cpn-console/shared' +import type { Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' @@ -19,7 +19,7 @@ describe('stageController', () => { id: faker.string.uuid(), name: 'dev', clusterIds: [], - } as Stage + } beforeEach(async () => { service = mock() @@ -41,27 +41,10 @@ describe('stageController', () => { expect(controller).toBeDefined() }) - it('keeps the stages list public (legacy GET has no auth)', () => { - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.list)).toBeUndefined() - expect(Reflect.getMetadata('__guards__', StageController) ?? []).not.toContain(UserGuard) - expect(Reflect.getMetadata('__guards__', StageController.prototype.list) ?? []).not.toContain(UserGuard) - }) - it('guards stage environments behind UserGuard + ListStages', () => { - const guards = Reflect.getMetadata('__guards__', StageController.prototype.getStageEnvironments) ?? [] - expect(guards).toContain(UserGuard) expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.getStageEnvironments)).toEqual(['ListStages']) }) - it('guards mutations behind UserGuard + ManageStages', () => { - expect(Reflect.getMetadata('__guards__', StageController.prototype.create) ?? []).toContain(UserGuard) - expect(Reflect.getMetadata('__guards__', StageController.prototype.update) ?? []).toContain(UserGuard) - expect(Reflect.getMetadata('__guards__', StageController.prototype.delete) ?? []).toContain(UserGuard) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.create)).toEqual(['ManageStages']) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.update)).toEqual(['ManageStages']) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.delete)).toEqual(['ManageStages']) - }) - it('delegates list to the service', async () => { service.listStages.mockResolvedValue([stage]) @@ -70,9 +53,9 @@ describe('stageController', () => { }) it('delegates stage environments with stageId', async () => { - const environments = { environments: [] } + const environments: StageAssociatedEnvironments = [] const stageId = faker.string.uuid() - service.getStageAssociatedEnvironments.mockResolvedValue(environments as never) + service.getStageAssociatedEnvironments.mockResolvedValue(environments) expect(await controller.getStageEnvironments(stageId)).toBe(environments) expect(service.getStageAssociatedEnvironments).toHaveBeenCalledWith(stageId) @@ -81,16 +64,17 @@ describe('stageController', () => { it('delegates create with the validated body', async () => { service.createStage.mockResolvedValue(stage) - expect(await controller.create(stage as never)).toBe(stage) + expect(await controller.create(stage)).toBe(stage) expect(service.createStage).toHaveBeenCalledWith(stage) }) it('delegates update with stageId and validated body', async () => { const stageId = faker.string.uuid() + const updateBody: UpdateStageBody = { name: 'staging', clusterIds: [] } service.updateStage.mockResolvedValue(stage) - expect(await controller.update(stageId, { name: 'staging' } as never)).toBe(stage) - expect(service.updateStage).toHaveBeenCalledWith(stageId, { name: 'staging' }) + expect(await controller.update(stageId, updateBody)).toBe(stage) + expect(service.updateStage).toHaveBeenCalledWith(stageId, updateBody) }) it('delegates delete with stageId', async () => { From c5dd864b39c13443e2310e8553bd45978e9157c7 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 29 Sep 2026 17:26:40 +0200 Subject: [PATCH 10/17] fix(stage): drop the remaining guard metadata spec Reflect-based guard metadata assertions pass whether or not the guard is enforced and add no behavioural coverage; the guard spec and the controller delegation tests are untouched. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I2531092df02ab78c1dbb29c4176a19526a6a6964 --- .../server-nestjs/src/modules/stage/stage.controller.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index f49e676a9e..ccac598406 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -5,7 +5,6 @@ import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' -import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { StageController } from './stage.controller' import { StageService } from './stage.service' @@ -41,10 +40,6 @@ describe('stageController', () => { expect(controller).toBeDefined() }) - it('guards stage environments behind UserGuard + ListStages', () => { - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, StageController.prototype.getStageEnvironments)).toEqual(['ListStages']) - }) - it('delegates list to the service', async () => { service.listStages.mockResolvedValue([stage]) From 5c0855bc57dc20d2a7cb2afc1b05727d1ea82cb7 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 30 Sep 2026 18:52:34 +0200 Subject: [PATCH 11/17] refactor(stage): move record mappers to controller, service returns records toStage and toStageAssociatedEnvironments apply the contract shape in StageController; StageService returns raw query records. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Iabaa9863ac20ef96fa635f1d87305b246a6a6964 --- .../modules/stage/stage.controller.spec.ts | 19 ++++++++------- .../src/modules/stage/stage.controller.ts | 5 ++-- .../src/modules/stage/stage.service.spec.ts | 23 ++++--------------- .../src/modules/stage/stage.service.ts | 21 +++++------------ .../src/modules/stage/stage.utils.ts | 22 ++++++++++++++++++ 5 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 apps/server-nestjs/src/modules/stage/stage.utils.ts diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index ccac598406..9b905975ec 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -1,4 +1,4 @@ -import type { Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' +import type { Stage, UpdateStageBody } from '@cpn-console/shared' import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' @@ -6,8 +6,10 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { makeStageEnvironmentRecord, makeStageWithClusters } from './stage-testing.utils' import { StageController } from './stage.controller' import { StageService } from './stage.service' +import { toStageAssociatedEnvironments, toStages } from './stage.utils' describe('stageController', () => { let module: TestingModule @@ -40,19 +42,20 @@ describe('stageController', () => { expect(controller).toBeDefined() }) - it('delegates list to the service', async () => { - service.listStages.mockResolvedValue([stage]) + it('maps raw stage records to the contract shape', async () => { + const records = [makeStageWithClusters()] + service.listStages.mockResolvedValue(records) - expect(await controller.list()).toEqual([stage]) + expect(await controller.list()).toEqual(toStages(records)) expect(service.listStages).toHaveBeenCalledTimes(1) }) - it('delegates stage environments with stageId', async () => { - const environments: StageAssociatedEnvironments = [] + it('maps stage environments for the contract response', async () => { + const records = [makeStageEnvironmentRecord()] const stageId = faker.string.uuid() - service.getStageAssociatedEnvironments.mockResolvedValue(environments) + service.getStageAssociatedEnvironments.mockResolvedValue(records) - expect(await controller.getStageEnvironments(stageId)).toBe(environments) + expect(await controller.getStageEnvironments(stageId)).toEqual(toStageAssociatedEnvironments(records)) expect(service.getStageAssociatedEnvironments).toHaveBeenCalledWith(stageId) }) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts index eafa2f4ccc..43f0f9aa81 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -5,6 +5,7 @@ import { RequireAdminPermission } from '../infrastructure/permission/user/user-a import { UserGuard } from '../infrastructure/permission/user/user.guard' import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' import { StageService } from './stage.service' +import { toStageAssociatedEnvironments, toStages } from './stage.utils' @Controller('api/v1/stages') export class StageController { @@ -12,7 +13,7 @@ export class StageController { @Get() async list(): Promise { - return this.service.listStages() + return toStages(await this.service.listStages()) } @Get(':stageId/environments') @@ -21,7 +22,7 @@ export class StageController { async getStageEnvironments( @Param('stageId') stageId: string, ): Promise { - return this.service.getStageAssociatedEnvironments(stageId) + return toStageAssociatedEnvironments(await this.service.getStageAssociatedEnvironments(stageId)) } @Post() diff --git a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts index ac4ad3b1a6..42d77a86d4 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts @@ -3,8 +3,8 @@ import { faker } from '@faker-js/faker' 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 { makeCluster } from '../environment/environment-testing.utils' +import { PrismaService } from '../infrastructure/database/prisma.service' import { makeStageEnvironmentRecord, makeStageRecord, makeStageWithClusters } from './stage-testing.utils' import { StageService } from './stage.service' @@ -26,31 +26,18 @@ describe('stageService', () => { service = moduleRef.get(StageService) }) - it('lists stages with clusterIds', async () => { + it('lists raw stage records', async () => { const stages = [makeStageWithClusters(), makeStageWithClusters()] prisma.stage.findMany.mockResolvedValue(stages) - const result = await service.listStages() - - expect(result).toEqual(stages.map(stage => ({ - id: stage.id, - name: stage.name, - clusterIds: stage.clusters.map(cluster => cluster.id), - }))) + expect(await service.listStages()).toEqual(stages) }) - it('maps stage associated environments', async () => { + it('returns raw stage environment records', async () => { const environment = makeStageEnvironmentRecord() prisma.environment.findMany.mockResolvedValue([environment]) - const result = await service.getStageAssociatedEnvironments(faker.string.uuid()) - - expect(result).toEqual([{ - project: environment.project.slug, - name: environment.name, - cluster: environment.cluster.label, - owner: environment.project.owner.email, - }]) + expect(await service.getStageAssociatedEnvironments(faker.string.uuid())).toEqual([environment]) }) it('creates a stage and links clusters', async () => { diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts index 64bd87539c..459e43d385 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -1,5 +1,6 @@ -import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' import type { Cluster } from '@prisma/client' +import type { StageEnvironmentsRecord, StageWithClustersRecord } from './stage-queries.utils' import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' import { @@ -21,22 +22,12 @@ import { export class StageService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} - async listStages(): Promise { - const stages = await listStagesQuery(this.prisma) - return stages.map(({ clusters, ...stage }) => ({ - ...stage, - clusterIds: clusters.map(({ id }) => id), - })) + async listStages(): Promise { + return listStagesQuery(this.prisma) } - async getStageAssociatedEnvironments(stageId: Stage['id']): Promise { - const environments = await getStageAssociatedEnvironments(this.prisma, stageId) - return environments.map(env => ({ - project: env.project.slug, - name: env.name, - cluster: env.cluster.label, - owner: env.project.owner.email, - })) + async getStageAssociatedEnvironments(stageId: Stage['id']): Promise { + return getStageAssociatedEnvironments(this.prisma, stageId) } async createStage({ clusterIds = [], name }: CreateStageBody): Promise { diff --git a/apps/server-nestjs/src/modules/stage/stage.utils.ts b/apps/server-nestjs/src/modules/stage/stage.utils.ts new file mode 100644 index 0000000000..a541a28e39 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.utils.ts @@ -0,0 +1,22 @@ +import type { Stage, StageAssociatedEnvironments } from '@cpn-console/shared' +import type { StageEnvironmentsRecord, StageWithClustersRecord } from './stage-queries.utils' + +export function toStage({ clusters, ...stage }: StageWithClustersRecord): Stage { + return { + ...stage, + clusterIds: clusters.map(({ id }) => id), + } +} + +export function toStages(records: StageWithClustersRecord[]): Stage[] { + return records.map(toStage) +} + +export function toStageAssociatedEnvironments(records: StageEnvironmentsRecord[]): StageAssociatedEnvironments { + return records.map(environment => ({ + project: environment.project.slug, + name: environment.name, + cluster: environment.cluster.label, + owner: environment.project.owner.email, + })) +} From ab20bd03c8442560e96b2de5cb883ec47c131079 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 1 Oct 2026 14:14:39 +0200 Subject: [PATCH 12/17] feat(server-nestjs): migrate stages Co-authored-by: Automata --- .../modules/stage/stage.controller.spec.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index 9b905975ec..157b3faf41 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -1,9 +1,11 @@ import type { Stage, UpdateStageBody } from '@cpn-console/shared' +import type { NestFastifyApplication } from '@nestjs/platform-fastify' import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { FastifyAdapter } from '@nestjs/platform-fastify' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mock } from 'vitest-mock-extended' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { makeStageEnvironmentRecord, makeStageWithClusters } from './stage-testing.utils' @@ -83,3 +85,30 @@ describe('stageController', () => { expect(service.deleteStage).toHaveBeenCalledWith(stageId) }) }) + +describe('stageController http pipeline', () => { + it('serves an anonymous GET /api/v1/stages with 200', async () => { + const service = mock() + service.listStages.mockResolvedValue([]) + + const testingModule = await Test.createTestingModule({ + controllers: [StageController], + providers: [ + { provide: StageService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: vi.fn(() => { throw new Error('UserGuard must not run on the public list route') }) }) + .compile() + + const app = testingModule.createNestApplication(new FastifyAdapter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + + const response = await app.inject({ method: 'GET', url: '/api/v1/stages' }) + + expect(response.statusCode).toBe(200) + + await app.close() + }) +}) From 4e78a14a517619d3286c8189749547cc7b50193a Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 12:50:33 +0200 Subject: [PATCH 13/17] refactor(stage): return records from service, map to contract in controller Co-authored-by: Automata --- .../modules/stage/stage.controller.spec.ts | 12 ++++++----- .../src/modules/stage/stage.controller.ts | 6 +++--- .../src/modules/stage/stage.service.spec.ts | 9 +++----- .../src/modules/stage/stage.service.ts | 21 +++++++------------ 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index 157b3faf41..95d59195ba 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -11,7 +11,7 @@ import { UserGuard } from '../infrastructure/permission/user/user.guard' import { makeStageEnvironmentRecord, makeStageWithClusters } from './stage-testing.utils' import { StageController } from './stage.controller' import { StageService } from './stage.service' -import { toStageAssociatedEnvironments, toStages } from './stage.utils' +import { toStage, toStageAssociatedEnvironments, toStages } from './stage.utils' describe('stageController', () => { let module: TestingModule @@ -62,18 +62,20 @@ describe('stageController', () => { }) it('delegates create with the validated body', async () => { - service.createStage.mockResolvedValue(stage) + const record = makeStageWithClusters() + service.createStage.mockResolvedValue(record) - expect(await controller.create(stage)).toBe(stage) + expect(await controller.create(stage)).toEqual(toStage(record)) expect(service.createStage).toHaveBeenCalledWith(stage) }) it('delegates update with stageId and validated body', async () => { const stageId = faker.string.uuid() const updateBody: UpdateStageBody = { name: 'staging', clusterIds: [] } - service.updateStage.mockResolvedValue(stage) + const record = makeStageWithClusters() + service.updateStage.mockResolvedValue(record) - expect(await controller.update(stageId, updateBody)).toBe(stage) + expect(await controller.update(stageId, updateBody)).toEqual(toStage(record)) expect(service.updateStage).toHaveBeenCalledWith(stageId, updateBody) }) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts index 43f0f9aa81..7cc98d76e8 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -5,7 +5,7 @@ import { RequireAdminPermission } from '../infrastructure/permission/user/user-a import { UserGuard } from '../infrastructure/permission/user/user.guard' import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' import { StageService } from './stage.service' -import { toStageAssociatedEnvironments, toStages } from './stage.utils' +import { toStage, toStageAssociatedEnvironments, toStages } from './stage.utils' @Controller('api/v1/stages') export class StageController { @@ -32,7 +32,7 @@ export class StageController { async create( @Body(new ZodValidationPipe(CreateStageBodySchema)) data: CreateStageBody, ): Promise { - return this.service.createStage(data) + return toStage(await this.service.createStage(data)) } @Put(':stageId') @@ -42,7 +42,7 @@ export class StageController { @Param('stageId') stageId: string, @Body(new ZodValidationPipe(UpdateStageBodySchema)) data: UpdateStageBody, ): Promise { - return this.service.updateStage(stageId, data) + return toStage(await this.service.updateStage(stageId, data)) } @Delete(':stageId') diff --git a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts index 42d77a86d4..ee7e465bf8 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts @@ -42,18 +42,15 @@ describe('stageService', () => { it('creates a stage and links clusters', async () => { const stage = makeStageRecord() - prisma.stage.findUnique.mockResolvedValue(null) + const created = makeStageWithClusters({ id: stage.id, name: stage.name }) + prisma.stage.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(created) prisma.stage.create.mockResolvedValue(stage) prisma.stage.update.mockResolvedValue(stage) const clusterIds = [faker.string.uuid()] const result = await service.createStage({ name: stage.name, clusterIds }) - expect(result).toEqual({ - id: stage.id, - name: stage.name, - clusterIds, - }) + expect(result).toEqual(created) expect(prisma.stage.create).toHaveBeenCalledWith({ data: { name: stage.name }, select: expect.anything() }) expect(prisma.stage.update).toHaveBeenCalled() }) diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts index 459e43d385..dcfc79217e 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -1,6 +1,6 @@ import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' import type { Cluster } from '@prisma/client' -import type { StageEnvironmentsRecord, StageWithClustersRecord } from './stage-queries.utils' +import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils' import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' import { @@ -30,7 +30,7 @@ export class StageService { return getStageAssociatedEnvironments(this.prisma, stageId) } - async createStage({ clusterIds = [], name }: CreateStageBody): Promise { + async createStage({ clusterIds = [], name }: CreateStageBody): Promise { return this.prisma.$transaction(async (tx) => { const isNameTaken = await getStageByName(tx, name) if (isNameTaken) throw new BadRequestException('Un type d\'environnement portant ce nom existe déjà') @@ -41,15 +41,13 @@ export class StageService { await linkStageToClusters(tx, stage.id, clusterIds) } - return { - id: stage.id, - name: stage.name, - clusterIds, - } + const created = await getStageById(tx, stage.id) + if (!created) throw new NotFoundException() + return created }) } - async updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody): Promise { + async updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody): Promise { return this.prisma.$transaction(async (tx) => { const dbStage = await getStageById(tx, stageId) if (!dbStage) throw new NotFoundException() @@ -72,12 +70,7 @@ export class StageService { const updated = await getStageById(tx, stageId) if (!updated) throw new NotFoundException() - - return { - id: stageId, - name: updated.name, - clusterIds: updated.clusters.map(({ id }) => id), - } + return updated }) } From b0507c6b5ea68a498298d797e5c14f745be690f0 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 15:00:05 +0200 Subject: [PATCH 14/17] fix(stage): validate stage params as UUIDs before Prisma Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I30ec3521f38f8f20c55081a5eb3c981f6a6a6964 --- .../modules/stage/stage.controller.spec.ts | 31 +++++++++++++++++++ .../src/modules/stage/stage.controller.ts | 8 ++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts index 95d59195ba..d685cfce1a 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -113,4 +113,35 @@ describe('stageController http pipeline', () => { await app.close() }) + + it.each([ + ['GET', '/api/v1/stages/not-an-uuid/environments'], + ['PUT', '/api/v1/stages/not-an-uuid'], + ['DELETE', '/api/v1/stages/not-an-uuid'], + ])('rejects non-UUID stage params with 400 on %s %s', async (method, url) => { + const service = mock() + + const testingModule = await Test.createTestingModule({ + controllers: [StageController], + providers: [ + { provide: StageService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + const app = testingModule.createNestApplication(new FastifyAdapter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + + const response = await app.inject({ method, url, payload: method === 'PUT' ? {} : undefined }) + + expect(response.statusCode).toBe(400) + expect(service.getStageAssociatedEnvironments).not.toHaveBeenCalled() + expect(service.updateStage).not.toHaveBeenCalled() + expect(service.deleteStage).not.toHaveBeenCalled() + + await app.close() + }) }) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts index 7cc98d76e8..254608e0c6 100644 --- a/apps/server-nestjs/src/modules/stage/stage.controller.ts +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -1,6 +1,6 @@ import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' import { CreateStageBodySchema, UpdateStageBodySchema } from '@cpn-console/shared' -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, UseGuards } from '@nestjs/common' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, UseGuards } from '@nestjs/common' 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' @@ -20,7 +20,7 @@ export class StageController { @UseGuards(UserGuard) @RequireAdminPermission('ListStages') async getStageEnvironments( - @Param('stageId') stageId: string, + @Param('stageId', ParseUUIDPipe) stageId: string, ): Promise { return toStageAssociatedEnvironments(await this.service.getStageAssociatedEnvironments(stageId)) } @@ -39,7 +39,7 @@ export class StageController { @UseGuards(UserGuard) @RequireAdminPermission('ManageStages') async update( - @Param('stageId') stageId: string, + @Param('stageId', ParseUUIDPipe) stageId: string, @Body(new ZodValidationPipe(UpdateStageBodySchema)) data: UpdateStageBody, ): Promise { return toStage(await this.service.updateStage(stageId, data)) @@ -50,7 +50,7 @@ export class StageController { @UseGuards(UserGuard) @RequireAdminPermission('ManageStages') async delete( - @Param('stageId') stageId: string, + @Param('stageId', ParseUUIDPipe) stageId: string, ): Promise { await this.service.deleteStage(stageId) } From 9816e419cbdde8bfe41becdc0ee9f71bb23644d8 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 15:28:14 +0200 Subject: [PATCH 15/17] refactor(stage): drop redundant Query import aliases Co-authored-by: Automata --- .../server-nestjs/src/modules/stage/stage.service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts index dcfc79217e..0750a419ac 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -4,8 +4,8 @@ import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } fr import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' import { - createStage as createStageQuery, - deleteStage as deleteStageQuery, + createStage, + deleteStage, disconnectClusterFromStage, getAllStageIds, getStageAssociatedEnvironmentCount, @@ -14,7 +14,7 @@ import { getStageByName, linkClusterToStages, linkStageToClusters, - listStages as listStagesQuery, + listStages, updateStageName, } from './stage-queries.utils' @@ -23,7 +23,7 @@ export class StageService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async listStages(): Promise { - return listStagesQuery(this.prisma) + return listStages(this.prisma) } async getStageAssociatedEnvironments(stageId: Stage['id']): Promise { @@ -35,7 +35,7 @@ export class StageService { const isNameTaken = await getStageByName(tx, name) if (isNameTaken) throw new BadRequestException('Un type d\'environnement portant ce nom existe déjà') - const stage = await createStageQuery(tx, { name }) + const stage = await createStage(tx, { name }) if (clusterIds.length) { await linkStageToClusters(tx, stage.id, clusterIds) @@ -81,7 +81,7 @@ export class StageService { throw new BadRequestException('Impossible de supprimer le stage, des environnements en activité y ont souscrit') } - await deleteStageQuery(tx, stageId) + await deleteStage(tx, stageId) }) } From 800fa095fc9237886e5dab9a996e21d9be2d7cec Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 15:29:26 +0200 Subject: [PATCH 16/17] refactor(stage): remove unused StageRecord import Co-authored-by: Automata --- apps/server-nestjs/src/modules/stage/stage.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts index 0750a419ac..04f39bedd8 100644 --- a/apps/server-nestjs/src/modules/stage/stage.service.ts +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -1,6 +1,6 @@ import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' import type { Cluster } from '@prisma/client' -import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils' +import type { StageEnvironmentsRecord, StageWithClustersRecord } from './stage-queries.utils' import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' import { From 0ff1bf82ee2d4cda89069e2aa837ee388c712007 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 15:48:05 +0200 Subject: [PATCH 17/17] refactor(stage): reuse makeUser for the owner fixture Co-authored-by: Automata --- .../src/modules/stage/stage-testing.utils.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts index d81e41fa51..7fd15b2645 100644 --- a/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts +++ b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts @@ -1,6 +1,7 @@ import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils' import { faker } from '@faker-js/faker' import { makeEnvironment } from '../environment/environment-testing.utils' +import { makeUser } from '../project/project-testing.utils' export function makeStageRecord(overrides: Partial = {}): StageRecord { return { @@ -27,17 +28,7 @@ export function makeStageEnvironmentRecord(overrides: Partial