diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 88fb9a02c1..b5561a0ffa 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -22,6 +22,7 @@ import { RepositoryModule } from './modules/repository/repository.module' import { SystemConfigModule } from './modules/system-config/system-config.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' +import { ZoneModule } from './modules/zone/zone.module' import { getDotenvPaths } from './utils/dotenv.utils' @Module({ @@ -52,6 +53,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' SystemConfigModule, SystemSettingsModule, VersionModule, + ZoneModule, ], controllers: [], providers: [], diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts index 89bc4d85e8..1b7f74ee83 100644 --- a/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts +++ b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts @@ -1,5 +1,4 @@ import type { AdminRole } from './admin-role-queries.utils' -import type { AdminRoleService } from './admin-role.service' import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils' export function makeAdminRole(overrides: Partial = {}): AdminRole { diff --git a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts index 35fa8759c4..88393b119f 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -2,6 +2,7 @@ import type { ConfigType } from '@nestjs/config' import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -175,4 +176,26 @@ describe('appEventsService', () => { data: expect.objectContaining({ args: payload }), })) }) + + it('emits and logs zone events with the event-log data shape and no project id', async () => { + const payload = { id: faker.string.uuid(), slug: faker.string.alpha({ length: 6, casing: 'lower' }) } + eventEmitter.emitAsync.mockResolvedValue([ + { vault: { status: 'KO', message: 'Vault unreachable', executionTime: 5, error: new Error('boom') } }, + ]) + + const results = await service.emitZoneEvent('zone.delete', payload, { action: 'Delete zone', userId: 'user-id', requestId: 'request-id' }) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('zone.delete', payload) + expect(results.vault?.status).toEqual('KO') + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Delete zone', + projectId: null, + data: expect.objectContaining({ + args: payload, + failed: ['vault'], + results: { vault: expect.objectContaining({ status: expect.objectContaining({ result: 'KO' }) }) }, + messageResume: expect.stringContaining('vault'), + }), + })) + }) }) diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 86cc755168..ad3861f697 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -13,6 +13,12 @@ import { formatEventLogData, isPluginResults } from './app-events.utils' export type ProjectEventName = 'project.upsert' | 'project.delete' export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete' export type RepositoryEventName = 'repository.sync' +export type ZoneEventName = 'zone.upsert' | 'zone.delete' + +export interface ZoneEventPayload { + id: string + slug: string +} export interface ProjectMemberEventPayload { projectId: string @@ -37,6 +43,7 @@ export type EventLogAction | 'Create Environment' | 'Update Environment' | 'Delete Environment' | 'Create Repository' | 'Update Repository' | 'Delete Repository' | 'Sync Repository' | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' + | 'Create zone' | 'Update zone' | 'Delete zone' export interface EventContext { /** Action label persisted in the admin log. */ @@ -109,10 +116,21 @@ export class AppEventsService { return this.emitAndLog(event, payload, payload.projectId, context) } + // Emits a zone event. Zones have no project row: the log carries no project id + // and the caller awaits the merged results to answer 422 on failure (legacy hooks + // behavior on `POST`/`PUT`/`DELETE /zones`). + async emitZoneEvent( + event: ZoneEventName, + zone: ZoneEventPayload, + context: EventContext, + ): Promise { + return this.emitAndLog(event, zone, null, context) + } + private async emitAndLog( event: string, payload: unknown, - projectId: string, + projectId: string | null, context: EventContext, ): Promise { const start = process.hrtime.bigint() diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 465034901e..64632bed7e 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -1,4 +1,5 @@ import type { ConfigType } from '@nestjs/config' +import type { ZoneEventPayload } from '../events/app-events.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' import { Inject, Injectable, Logger } from '@nestjs/common' @@ -85,31 +86,31 @@ export class VaultService { } @OnEvent('zone.upsert') - async handleUpsertZone(zone: ZoneWithDetails): Promise> { - return capturePluginResult('vault', () => this.syncZone(zone)) + async handleUpsertZone(payload: ZoneEventPayload): Promise> { + return capturePluginResult('vault', () => this.syncZone(payload.slug)) } @StartActiveSpan() - private async syncZone(zone: ZoneWithDetails) { + private async syncZone(zoneSlug: string) { const span = trace.getActiveSpan() - span?.setAttribute('zone.slug', zone.slug) - this.logger.log(`Handling a zone upsert event for ${zone.slug}`) - await this.ensureZone(zone) - this.logger.log(`Vault zone sync completed for ${zone.slug}`) + span?.setAttribute('zone.slug', zoneSlug) + this.logger.log(`Handling a zone upsert event for ${zoneSlug}`) + await this.upsertZone(zoneSlug) + this.logger.log(`Vault zone sync completed for ${zoneSlug}`) } @OnEvent('zone.delete') - async handleDeleteZone(zone: ZoneWithDetails): Promise> { - return capturePluginResult('vault', () => this.cleanupZone(zone)) + async handleDeleteZone(payload: ZoneEventPayload): Promise> { + return capturePluginResult('vault', () => this.cleanupZone(payload.slug)) } @StartActiveSpan() - private async cleanupZone(zone: ZoneWithDetails) { + private async cleanupZone(zoneSlug: string) { const span = trace.getActiveSpan() - span?.setAttribute('zone.slug', zone.slug) - this.logger.log(`Handling a zone delete event for ${zone.slug}`) - await this.deleteZone(zone.slug) - this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) + span?.setAttribute('zone.slug', zoneSlug) + this.logger.log(`Handling a zone delete event for ${zoneSlug}`) + await this.deleteZone(zoneSlug) + this.logger.log(`Vault zone cleanup completed for ${zoneSlug}`) } // @Cron(CronExpression.EVERY_HOUR) diff --git a/apps/server-nestjs/src/modules/zone/zone-queries.utils.ts b/apps/server-nestjs/src/modules/zone/zone-queries.utils.ts new file mode 100644 index 0000000000..8c7e6600ae --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone-queries.utils.ts @@ -0,0 +1,58 @@ +import type { Cluster, Prisma } from '@prisma/client' + +export const zoneSelect = { + id: true, + slug: true, + label: true, + argocdUrl: true, + description: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.ZoneSelect + +export type Zone = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +export function getZoneById(tx: Prisma.TransactionClient, id: Zone['id']) { + return tx.zone.findUnique({ where: { id }, select: zoneSelect }) +} + +export function listZones(tx: Prisma.TransactionClient) { + return tx.zone.findMany({ select: zoneSelect }) +} + +export function createZone( + tx: Prisma.TransactionClient, + data: Pick, +) { + return tx.zone.create({ data, select: zoneSelect }) +} + +export function updateZone( + tx: Prisma.TransactionClient, + zoneId: Zone['id'], + data: Pick, +) { + return tx.zone.update({ where: { id: zoneId }, data, select: zoneSelect }) +} + +export function linkZoneToClusters(tx: Prisma.TransactionClient, zoneId: Zone['id'], clusterIds: Array) { + return tx.zone.update({ + where: { id: zoneId }, + data: { clusters: { connect: clusterIds.map(id => ({ id })) } }, + select: zoneSelect, + }) +} + +export async function createZoneWithClusters( + tx: Prisma.TransactionClient, + data: Pick, + clusterIds?: Array, +) { + const zone = await createZone(tx, data) + if (clusterIds?.length) { + await linkZoneToClusters(tx, zone.id, clusterIds) + } + return zone +} diff --git a/apps/server-nestjs/src/modules/zone/zone-testing.utils.ts b/apps/server-nestjs/src/modules/zone/zone-testing.utils.ts new file mode 100644 index 0000000000..30832662b2 --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone-testing.utils.ts @@ -0,0 +1,15 @@ +import type { Zone } from './zone-queries.utils' +import { faker } from '@faker-js/faker' + +export function makeZone(overrides: Partial = {}): Zone { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(), + label: faker.word.sample(10), + argocdUrl: `https://argocd.example.com/${faker.helpers.slugify(faker.word.sample(5))}`, + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } satisfies Zone +} diff --git a/apps/server-nestjs/src/modules/zone/zone.controller.spec.ts b/apps/server-nestjs/src/modules/zone/zone.controller.spec.ts new file mode 100644 index 0000000000..00341d05c1 --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone.controller.spec.ts @@ -0,0 +1,65 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { TestingModule } from '@nestjs/testing' +import { FastifyAdapter } from '@nestjs/platform-fastify' +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 { makeZone } from './zone-testing.utils' +import { ZoneController } from './zone.controller' +import { ZoneService } from './zone.service' +import { toZone } from './zone.utils' + +describe('zoneController', () => { + let module: TestingModule + let controller: ZoneController + let service: MockProxy + + const zone = makeZone({ slug: 'paris', label: 'Paris' }) + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ZoneController], + providers: [ + { provide: ZoneService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ZoneController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('serves GET list without authentication, matching the legacy public route', async () => { + const app = module.createNestApplication(new FastifyAdapter()) + await app.init() + service.list.mockResolvedValue([zone]) + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/zones', + }) + + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([toZone(zone)]) + await app.close() + }) + + it('rejects non-UUID zone params with 400 before reaching the service', async () => { + const app = module.createNestApplication(new FastifyAdapter()) + await app.init() + + for (const method of ['PUT', 'DELETE'] as const) { + const response = await app.inject({ method, url: '/api/v1/zones/not-an-uuid' }) + expect(response.statusCode).toEqual(400) + } + await app.close() + }) +}) diff --git a/apps/server-nestjs/src/modules/zone/zone.controller.ts b/apps/server-nestjs/src/modules/zone/zone.controller.ts new file mode 100644 index 0000000000..f7020cb251 --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone.controller.ts @@ -0,0 +1,61 @@ +import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { zoneContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { RequireUserType } from '../infrastructure/permission/user/user-type.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ZoneService } from './zone.service' +import { toZone, toZones } from './zone.utils' + +@Controller('api/v1/zones') +export class ZoneController { + constructor(@Inject(ZoneService) private readonly zoneService: ZoneService) {} + + @Get() + async list(): Promise { + return toZones(await this.zoneService.list()) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireUserType('human') + @RequireAdminPermission('ManageZones') + async create( + @Body(new ZodValidationPipe(zoneContract.createZone.body)) body: CreateZoneBody, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return toZone(await this.zoneService.create(body, user.userId, request.id)) + } + + @Put(':zoneId') + @UseGuards(UserGuard) + @RequireUserType('human') + @RequireAdminPermission('ManageZones') + async update( + @Param('zoneId', ParseUUIDPipe) zoneId: string, + @Body(new ZodValidationPipe(zoneContract.updateZone.body)) body: UpdateZoneBody, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return toZone(await this.zoneService.update(zoneId, body, user.userId, request.id)) + } + + @Delete(':zoneId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireUserType('human') + @RequireAdminPermission('ManageZones') + async delete( + @Param('zoneId', ParseUUIDPipe) zoneId: string, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.zoneService.delete(zoneId, user.userId, request.id) + } +} diff --git a/apps/server-nestjs/src/modules/zone/zone.module.ts b/apps/server-nestjs/src/modules/zone/zone.module.ts new file mode 100644 index 0000000000..e1f771941e --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { ZoneController } from './zone.controller' +import { ZoneService } from './zone.service' + +@Module({ + imports: [ + AppEventsModule, + InfrastructureModule, + ], + controllers: [ZoneController], + providers: [ZoneService], + exports: [ZoneService], +}) +export class ZoneModule {} diff --git a/apps/server-nestjs/src/modules/zone/zone.service.spec.ts b/apps/server-nestjs/src/modules/zone/zone.service.spec.ts new file mode 100644 index 0000000000..427af6e1de --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone.service.spec.ts @@ -0,0 +1,200 @@ +import type { Cluster, Prisma } from '@prisma/client' +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 { mock, mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import type { Zone } from './zone-queries.utils' +import { makeZone } from './zone-testing.utils' +import { ZoneService } from './zone.service' + +describe('zoneService', () => { + let service: ZoneService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + appEvents.emitZoneEvent.mockResolvedValue({}) + + const moduleRef = await Test.createTestingModule({ + providers: [ + ZoneService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = moduleRef.get(ZoneService) + }) + + describe('list', () => { + it('returns all zones', async () => { + const zones = [makeZone(), makeZone()] + prisma.zone.findMany.mockResolvedValue(zones) + + const result = await service.list() + + expect(result).toEqual(zones) + expect(prisma.zone.findMany).toHaveBeenCalled() + }) + }) + + describe('create', () => { + let zone: Zone + + beforeEach(() => { + zone = makeZone() + }) + + it('creates a zone and connects clusters within a transaction', async () => { + prisma.zone.findUnique.mockResolvedValue(null) + const tx = mockDeep() + tx.zone.create.mockResolvedValue(zone) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.create( + { slug: zone.slug, label: zone.label, argocdUrl: zone.argocdUrl, description: zone.description, clusterIds: ['cluster-1'] }, + faker.string.uuid(), + faker.string.uuid(), + ) + + expect(result).toEqual(zone) + expect(tx.zone.create).toHaveBeenCalled() + expect(tx.zone.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: zone.id }, + data: { clusters: { connect: [{ id: 'cluster-1' }] } }, + })) + expect(appEvents.emitZoneEvent).toHaveBeenCalledWith('zone.upsert', { id: zone.id, slug: zone.slug }, expect.objectContaining({ action: 'Create zone' })) + }) + + it('creates a zone without clusters', async () => { + prisma.zone.findUnique.mockResolvedValue(null) + const tx = mockDeep() + tx.zone.create.mockResolvedValue(zone) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.create( + { slug: zone.slug, label: zone.label, argocdUrl: zone.argocdUrl, description: zone.description }, + faker.string.uuid(), + faker.string.uuid(), + ) + + expect(result).toEqual(zone) + expect(tx.zone.update).not.toHaveBeenCalled() + expect(appEvents.emitZoneEvent).toHaveBeenCalledWith('zone.upsert', { id: zone.id, slug: zone.slug }, expect.anything()) + }) + + it('throws when zone slug already exists', async () => { + prisma.zone.findUnique.mockResolvedValue(makeZone()) + + await expect( + service.create( + { slug: zone.slug, label: zone.label, argocdUrl: zone.argocdUrl }, + faker.string.uuid(), + faker.string.uuid(), + ), + ).rejects.toThrow('Une zone portant le nom') + }) + + it('rejects with 422 when a plugin fails', async () => { + prisma.zone.findUnique.mockResolvedValue(null) + const tx = mockDeep() + tx.zone.create.mockResolvedValue(zone) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + appEvents.emitZoneEvent.mockResolvedValue({ + vault: { status: 'KO', message: 'Vault unreachable', executionTime: 1, error: new Error('boom') }, + }) + + await expect( + service.create({ slug: zone.slug, label: zone.label, argocdUrl: zone.argocdUrl }, faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow('Echec des services lors de la création de la zone') + }) + }) + + describe('update', () => { + let zone: Zone + + beforeEach(() => { + zone = makeZone() + }) + + it('updates a zone', async () => { + prisma.zone.findUnique.mockResolvedValue(zone) + prisma.zone.update.mockResolvedValue(zone) + + const result = await service.update( + zone.id, + { label: 'new label', argocdUrl: zone.argocdUrl, description: zone.description }, + faker.string.uuid(), + faker.string.uuid(), + ) + + expect(result).toEqual(zone) + expect(prisma.zone.update).toHaveBeenCalledTimes(1) + expect(appEvents.emitZoneEvent).toHaveBeenCalledWith('zone.upsert', { id: zone.id, slug: zone.slug }, expect.objectContaining({ action: 'Update zone' })) + }) + + it('throws when zone not found', async () => { + prisma.zone.findUnique.mockResolvedValue(null) + + await expect( + service.update(zone.id, { label: 'new label', argocdUrl: zone.argocdUrl }, faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow('Zone non trouvée') + }) + }) + + describe('delete', () => { + let zone: Zone + + beforeEach(() => { + zone = makeZone() + }) + + it('reconciles first and deletes only on success', async () => { + prisma.cluster.findFirst.mockResolvedValue(null) + prisma.zone.findUnique.mockResolvedValue(zone) + prisma.zone.delete.mockResolvedValue(zone) + + await service.delete(zone.id, faker.string.uuid(), faker.string.uuid()) + + expect(appEvents.emitZoneEvent).toHaveBeenCalledWith('zone.delete', { id: zone.id, slug: zone.slug }, expect.objectContaining({ action: 'Delete zone' })) + expect(prisma.zone.delete).toHaveBeenCalledWith({ where: { id: zone.id } }) + }) + + it('keeps the zone when a plugin fails', async () => { + prisma.cluster.findFirst.mockResolvedValue(null) + prisma.zone.findUnique.mockResolvedValue(zone) + appEvents.emitZoneEvent.mockResolvedValue({ + vault: { status: 'KO', message: 'Vault unreachable', executionTime: 1, error: new Error('boom') }, + }) + + await expect( + service.delete(zone.id, faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow('Echec des services lors de la suppression de la zone') + expect(prisma.zone.delete).not.toHaveBeenCalled() + }) + + it('throws when zone has attached clusters', async () => { + prisma.cluster.findFirst.mockResolvedValue(mock({ id: 'cluster-1' })) + + await expect( + service.delete(zone.id, faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow('Vous ne pouvez supprimer cette zone') + expect(prisma.zone.delete).not.toHaveBeenCalled() + }) + + it('throws when zone not found', async () => { + prisma.cluster.findFirst.mockResolvedValue(null) + prisma.zone.findUnique.mockResolvedValue(null) + + await expect( + service.delete(zone.id, faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow('Zone non trouvée') + expect(appEvents.emitZoneEvent).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/zone/zone.service.ts b/apps/server-nestjs/src/modules/zone/zone.service.ts new file mode 100644 index 0000000000..c57212ab22 --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone.service.ts @@ -0,0 +1,84 @@ +import type { CreateZoneBody, UpdateZoneBody } from '@cpn-console/shared' +import type { EventLogAction, ZoneEventName, ZoneEventPayload } from '../events/app-events.service' +import type { Zone } from './zone-queries.utils' +import { BadRequestException, Inject, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getFailedPlugins } from '../plugin/plugin.utils' +import { createZoneWithClusters, getZoneById, listZones, updateZone } from './zone-queries.utils' + +@Injectable() +export class ZoneService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async list(): Promise { + return listZones(this.prisma) + } + + async create(data: CreateZoneBody, userId: string, requestId: string): Promise { + const existing = await this.prisma.zone.findUnique({ where: { slug: data.slug } }) + if (existing) throw new BadRequestException(`Une zone portant le nom ${data.slug} existe déjà.`) + + const zone = await this.prisma.$transaction(tx => createZoneWithClusters(tx, { + slug: data.slug, + label: data.label, + argocdUrl: data.argocdUrl, + description: data.description ?? null, + }, data.clusterIds)) + + await this.emitZoneEventAndThrowOnFailure( + 'zone.upsert', + { id: zone.id, slug: zone.slug }, + 'Create zone', + userId, + requestId, + 'Echec des services lors de la création de la zone', + ) + return zone + } + + async update(zoneId: string, data: UpdateZoneBody, userId: string, requestId: string): Promise { + const existing = await getZoneById(this.prisma, zoneId) + if (!existing) throw new NotFoundException('Zone non trouvée') + + const zone = await updateZone(this.prisma, zoneId, { + label: data.label, + argocdUrl: data.argocdUrl, + description: data.description ?? null, + }) + + await this.emitZoneEventAndThrowOnFailure('zone.upsert', { id: zone.id, slug: zone.slug }, 'Update zone', userId, requestId, 'Echec des services lors de la mise à jour de la zone') + return zone + } + + async delete(zoneId: string, userId: string, requestId: string): Promise { + const attachedCluster = await this.prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) { + throw new BadRequestException('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + } + + const zone = await getZoneById(this.prisma, zoneId) + if (!zone) throw new NotFoundException('Zone non trouvée') + + await this.emitZoneEventAndThrowOnFailure('zone.delete', { id: zone.id, slug: zone.slug }, 'Delete zone', userId, requestId, 'Echec des services lors de la suppression de la zone') + await this.prisma.zone.delete({ where: { id: zoneId } }) + } + + private async emitZoneEventAndThrowOnFailure( + event: ZoneEventName, + payload: ZoneEventPayload, + action: EventLogAction, + userId: string, + requestId: string, + failureMessage: string, + ): Promise { + const results = await this.appEvents.emitZoneEvent(event, payload, { action, userId, requestId }) + + if (getFailedPlugins(results).length) { + throw new UnprocessableEntityException(failureMessage) + } + } +} diff --git a/apps/server-nestjs/src/modules/zone/zone.utils.ts b/apps/server-nestjs/src/modules/zone/zone.utils.ts new file mode 100644 index 0000000000..7270c16f16 --- /dev/null +++ b/apps/server-nestjs/src/modules/zone/zone.utils.ts @@ -0,0 +1,16 @@ +import type { Zone as ZoneContract } from '@cpn-console/shared' +import type { Zone } from './zone-queries.utils' + +export function toZone(record: Zone): ZoneContract { + return { + id: record.id, + slug: record.slug, + label: record.label, + argocdUrl: record.argocdUrl, + description: record.description ?? '', + } +} + +export function toZones(records: Zone[]): ZoneContract[] { + return records.map(toZone) +} diff --git a/apps/server-nestjs/test/zone.e2e-spec.ts b/apps/server-nestjs/test/zone.e2e-spec.ts index 76123f904a..f13978f7f1 100644 --- a/apps/server-nestjs/test/zone.e2e-spec.ts +++ b/apps/server-nestjs/test/zone.e2e-spec.ts @@ -12,7 +12,6 @@ import { EventsModule } from '../src/modules/infrastructure/events/events.module import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' -import { makeZoneWithDetails } from '../src/modules/vault/vault-testing.utils' import { VaultModule } from '../src/modules/vault/vault.module' import { VaultService } from '../src/modules/vault/vault.service' import { getDotenvPaths } from '../src/utils/dotenv.utils' @@ -63,9 +62,7 @@ describeWithZone('Zone lifecycle (e2e)', () => { }) it('should provision zone secrets space in Vault (mount, policy, approle)', async () => { - const zone = makeZoneWithDetails({ id: zoneId, slug: zoneSlug }) - - await eventEmitter.emitAsync('zone.upsert', zone) + await eventEmitter.emitAsync('zone.upsert', { id: zoneId, slug: zoneSlug }) const kvName = `zone-${zoneSlug}` const roleId = await vaultClient.getAuthApproleRoleRoleId(kvName) @@ -73,12 +70,10 @@ describeWithZone('Zone lifecycle (e2e)', () => { }, VAULT_PROVISION_TIMEOUT) it('should remove zone from Vault on delete', async () => { - const zone = makeZoneWithDetails({ id: zoneId, slug: zoneSlug }) - const kvName = `zone-${zoneSlug}` expect(await vaultClient.getAuthApproleRoleRoleId(kvName)).toBeTruthy() - await eventEmitter.emitAsync('zone.delete', zone) + await eventEmitter.emitAsync('zone.delete', { id: zoneId, slug: zoneSlug }) await expect(vaultClient.getAuthApproleRoleRoleId(kvName)).rejects.toThrow() }, VAULT_PROVISION_TIMEOUT) diff --git a/packages/shared/src/contracts/zone.ts b/packages/shared/src/contracts/zone.ts index 90a300ba5f..6979a3c1d2 100644 --- a/packages/shared/src/contracts/zone.ts +++ b/packages/shared/src/contracts/zone.ts @@ -3,6 +3,7 @@ import { ContractNoBody } from '@ts-rest/core' import { z } from 'zod' import { apiPrefix, contractInstance } from '../api-client.js' import { + UpdateZoneBodySchema, ZoneSchema, } from '../schemas/index.js' import { baseHeaders, ErrorSchema } from './_utils.js' @@ -49,9 +50,9 @@ export const zoneContract = contractInstance.router({ zoneId: z.string() .uuid(), }), - body: ZoneSchema.omit({ id: true, slug: true }), + body: UpdateZoneBodySchema, responses: { - 201: ZoneSchema, + 200: ZoneSchema, 400: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, diff --git a/packages/shared/src/schemas/zone.ts b/packages/shared/src/schemas/zone.ts index e402a73a6c..71741a8ffc 100644 --- a/packages/shared/src/schemas/zone.ts +++ b/packages/shared/src/schemas/zone.ts @@ -21,4 +21,6 @@ export const ZoneSchema = z.object({ .transform(value => value ?? ''), }) +export const UpdateZoneBodySchema = ZoneSchema.omit({ id: true, slug: true }) + export type Zone = Zod.infer