diff --git a/.vscode/launch.json b/.vscode/launch.json index 8205221d49..82d6dcbd33 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,6 +16,7 @@ "run", "integ" ], + "envFile": "${workspaceFolder}/apps/server/.integ.env", "console": "integratedTerminal", "restart": true, "presentation": { @@ -38,6 +39,7 @@ "run", "dev" ], + "envFile": "${workspaceFolder}/apps/server/.env", "console": "integratedTerminal", "restart": true, "presentation": { @@ -100,6 +102,7 @@ "run", "start:dev" ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.integ.env", "console": "integratedTerminal", "restart": true, "presentation": { @@ -122,6 +125,7 @@ "run", "start:dev" ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env", "console": "integratedTerminal", "restart": true, "presentation": { @@ -164,6 +168,7 @@ "8080", "--strictPort" ], + "envFile": "${workspaceFolder}/apps/client/.integ.env", "console": "integratedTerminal", "restart": true, "serverReadyAction": { @@ -171,9 +176,6 @@ "action": "startDebugging", "name": "Launch Client (Chrome)" }, - "env": { - "SERVER_PORT": "4001" - }, "presentation": { "hidden": true } @@ -194,6 +196,7 @@ "8080", "--strictPort" ], + "envFile": "${workspaceFolder}/apps/client/.env", "console": "integratedTerminal", "restart": true, "serverReadyAction": { @@ -221,6 +224,7 @@ "8080", "--strictPort" ], + "envFile": "${workspaceFolder}/apps/client/.integ.env", "console": "integratedTerminal", "restart": true, "serverReadyAction": { @@ -248,6 +252,7 @@ "8080", "--strictPort" ], + "envFile": "${workspaceFolder}/apps/client/.env", "console": "integratedTerminal", "restart": true, "serverReadyAction": { diff --git a/apps/client/.envrc b/apps/client/.envrc new file mode 100644 index 0000000000..8c2f820249 --- /dev/null +++ b/apps/client/.envrc @@ -0,0 +1,9 @@ +if [ "$INTEGRATION" = true ]; then + dotenv .env.integ +elif [ "$DOCKER" = true ]; then + dotenv .env.docker +else + dotenv +fi + +source_up diff --git a/apps/client/src/utils/env.ts b/apps/client/src/utils/env.ts index a6f6ffd166..57ad1f789f 100644 --- a/apps/client/src/utils/env.ts +++ b/apps/client/src/utils/env.ts @@ -1,18 +1,18 @@ export const serverHost: string = process.env.SERVER_HOST ?? 'dso-server-host' -export const serverPort: string = process.env.SERVER_PORT ?? 'dso-server-port' +export const serverPort: string = '4001' export const clientPort: string = process.env.CLIENT_PORT ?? 'dso-client-port' -export const keycloakProtocol: string = process.env.KEYCLOAK_PROTOCOL ?? 'dso-keycloak-protocol' +export const keycloakProtocol: string = 'https' -export const keycloakDomain: string = process.env.KEYCLOAK_DOMAIN ?? 'dso-keycloak-domain' +export const keycloakDomain: string = 'keycloak.dso.cpin-hp.numerique-interieur.fr' -export const keycloakRealm: string = process.env.KEYCLOAK_REALM ?? 'dso-keycloak-realm' +export const keycloakRealm: string = 'dso' -export const keycloakClientId: string = process.env.KEYCLOAK_CLIENT_ID ?? 'dso-keycloak-client-id' +export const keycloakClientId: string = 'console-frontend' -export const keycloakRedirectUri: string = process.env.KEYCLOAK_REDIRECT_URI ?? 'dso-keycloak-redirect-uri' +export const keycloakRedirectUri: string = 'http://localhost:8080' export const contactEmail: string = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index 1245ffdc4c..927bdceccf 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -79,6 +79,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /api/v1/projects { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── location /api/ { proxy_pass http://server-legacy; diff --git a/apps/server-nestjs/.envrc b/apps/server-nestjs/.envrc new file mode 100644 index 0000000000..8c2f820249 --- /dev/null +++ b/apps/server-nestjs/.envrc @@ -0,0 +1,9 @@ +if [ "$INTEGRATION" = true ]; then + dotenv .env.integ +elif [ "$DOCKER" = true ]; then + dotenv .env.docker +else + dotenv +fi + +source_up diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md index d9c3066263..673fbd13b6 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -196,8 +196,8 @@ Plus le score est eleve, plus le module est prioritaire. | 13 | zone | Metier | 6.4 | V2 | S6 | | | 14 | environment | Metier | 6.3 | V3 | S7 | | | 15 | admin-role | Metier | 6.1 | V2 | S5 | | -| 16 | project-core | Metier | 5.8 | V4 | S9 | | -| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 16 | project-core | Metier | 5.8 | V4 | S9 | ✅ MIGRÉ (2026-05-28) | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRÉ (2026-04-09) | | 18 | repository | Metier | 5.8 | V3 | S7-S8 | | | 19 | cluster | Metier | 5.7 | V3 | S7 | | | 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | @@ -206,8 +206,8 @@ Plus le score est eleve, plus le module est prioritaire. | 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | | 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | | 25 | project-member | Metier | 4.7 | V3 | S8 | | -| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | -| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | ✅ MIGRÉ (2026-05-28, fusionné dans project-core) | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | ✅ MIGRÉ (2026-05-28, fusionné dans project-core) | | 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | **Note** : Le score brut ne dicte pas directement l'ordre de migration. @@ -825,7 +825,7 @@ Encapsuler les plugins intermediaires. **Livrable fin S10** : 75 routes migrees (100% des routes metier) + plugins vault, keycloak, gitlab, harbor, nexus encapsules -### 19. project-core +### 19. project-core — ✅ MIGRÉ (2026-05-28) | Attribut | Valeur | |----------|--------| @@ -857,7 +857,7 @@ vault, keycloak, gitlab, harbor, nexus encapsules --- -### 20. project-secrets +### 20. project-secrets — ✅ MIGRÉ (2026-05-28, fusionné dans project-core) | Attribut | Valeur | |----------|--------| @@ -882,10 +882,12 @@ vault, keycloak, gitlab, harbor, nexus encapsules --- -### 21. project-bulk +### 21. project-bulk — ✅ MIGRÉ (2026-05-28, fusionné dans project-core) | Attribut | Valeur | |----------|--------| +| **Routes** | 2 (bulk action + export CSV) | +| **Note** | La route `POST /api/v1/projects/:projectId/replay-hooks` n'est pas encore migrée | | **Routes** | 3 | | **Score** | 4.2 | | **Sprint** | S9-S10 | diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md index 5d36a235df..b58031e935 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -7,9 +7,9 @@ ## 🎯 Progression globale -![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) +![Progress](https://progress-bar.dev/21/?title=modularisation&width=400) -**~7%** complété (1/18 modules métier migrés, 5/75 routes) +**~21%** complété (3/18 modules métier migrés, 16/75 routes) --- @@ -17,9 +17,9 @@ | Statut | Nombre de modules | % du total | |--------|-------------------|------------| -| ✅ Migré | 1 (ServiceChain) | ~6% | +| ✅ Migré | 3 (ServiceChain, Project, ProjectMember) | ~17% | | 🚧 En cours | 0 | 0% | -| 📅 Planifié | 17 | ~94% | +| 📅 Planifié | 15 | ~83% | | ⏳ En attente de cartographie | 0 | 0% | --- @@ -48,9 +48,45 @@ profitant de son isolement complet vis-à-vis du reste du codebase. | `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | | `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | +### Project — migrés le 2026-05-28 + +#### Project + +Module cœur de gestion des projets (CRUD, secrets, bulk actions, export CSV). +Regroupe les sous-modules `project-core`, `project-secrets` et `project-bulk` +découpés dans la cartographie Vague 4, implémités ici en un seul module +unifié pour accélérer la migration. + +- **Routes** : 7 (`/api/v1/projects/...`) +- **Auth** : Token (`x-dso-token`) + guards projet (`ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard`) +- **Validation** : Contrats Zod via `projectContract` de `@cpn-console/shared` avec `ZodValidationPipe` +- **Tests** : Controller + Service couverts (Vitest) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/projects/data` | `Manage` (admin) | +| `GET` | `/api/v1/projects` | Authentifié | +| `POST` | `/api/v1/projects` | `ManageProjects` (admin) + type `human` | +| `POST` | `/api/v1/projects-bulk` | `Manage` (admin) | +| `GET` | `/api/v1/projects/:projectId` | Authentifié + projet | +| `GET` | `/api/v1/projects/:projectId/secrets` | Authentifié + projet (statut ≠ archived) | +| `PUT` | `/api/v1/projects/:projectId` | Authentifié + projet (statut ≠ archived) | + +**Infrastructure créée/mise à jour** : +- `ProjectContextGuard` + `Project` decorator : chargement du projet par id/slug, résolution des permissions via bitmask +- `ProjectStatusGuard` + `@RequireProjectStatus()` : filtrage par statut du projet +- `ProjectLockedGuard` : protection contre les modifications de projets verrouillés +- `UserGuard` + `User` decorator : authentification token + injection du contexte utilisateur + +**Différences avec le legacy** : +- Utilisation de `projectContract` de `@cpn-console/shared` pour les schemas de validation (cohérence client/serveur) +- ZodValidationPipe au lieu de la validation ts-rest implicite +- Les routes `secrets` et `bulk` sont fusiongées dans le module Project (pas de sous-modules séparés) +- Pas de `DELETE /api/v1/projects/:projectId` (archivage) dans cette version initiale + ### Infrastructure transverse déployée -En support de cette migration, les éléments d'infrastructure suivants ont été +En support de ces migrations, les éléments d'infrastructure suivants ont été créés : - **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token @@ -68,6 +104,31 @@ créés : > mais les usages côté contrôleurs restent encore à homogénéiser au fil des > modules migrés. +#### ProjectMember + +Module de gestion des membres projet (ajout, modification, suppression, liste). +Dédié aux routes `/api/v1/projects/:projectId/members/...`. + +- **Routes** : 4 (`/api/v1/projects/:projectId/members/...`) +- **Auth** : Token (`x-dso-token`) + guards projet (`ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard`) +- **Validation** : Contrats Zod via `projectMemberContract` de `@cpn-console/shared` + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/projects/:projectId/members` | `ListMembers` (admin) | +| `POST` | `/api/v1/projects/:projectId/members` | `ManageMembers` (admin) + statut non archivé | +| `PATCH` | `/api/v1/projects/:projectId/members` | `ManageMembers` (admin) + statut non archivé | +| `DELETE` | `/api/v1/projects/:projectId/members/:userId` | `ManageMembers` (admin) ou auto-suppression + statut non archivé | + +**Fichiers** : +- `src/modules/project/project-member.controller.ts` +- Méthodes ajoutées dans `src/modules/project/project.service.ts` + +**Différences avec le legacy** : +- Recherche par email non disponible (nécessite le hook Keycloak `user.retrieveUserByEmail`, non migré) +- Retour de la liste complète des membres après chaque mutation (cohérence avec le legacy) +- Événements `projectMember.upsert` et `projectMember.delete` via EventEmitter (remplacement du hook `projectMember.upsert`/`projectMember.delete`) + --- ## 🚧 En cours de modularisation @@ -140,9 +201,9 @@ créés : ### Routes par statut - **Total** : ~75 routes métier -- **Migrés** : 5 (~7%) +- **Migrés** : 16 (~21%) - **En cours** : 0 (0%) -- **Restants** : ~70 (~93%) +- **Restants** : ~59 (~79%) --- @@ -156,6 +217,7 @@ créés : | 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | | 09/03/2026 | Point mi-parcours - 60% complété | | 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 28/05/2026 | Migration du module **Project** (7 routes) — CRUD + secrets + bulk | | 06/04/2026 | Fin de modularisation - 100% complété | --- @@ -188,6 +250,14 @@ créés : ## 🔄 Historique des changements +### 2026-05-28 +- ✅ Migration du module **Project** — 7 routes : CRUD projets, secrets, bulk actions, export CSV +- ✅ Migration du module **ProjectMember** — 4 routes : membres projet (list, add, patch, delete) +- ✅ Création des guards projet : `ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard` +- ✅ Création des décorateurs `@Project()` et `@RequireProjectStatus()` +- ✅ Utilisation de `projectContract` de `@cpn-console/shared` pour la validation Zod +- ✅ Build et lint verts (server-nestjs) + ### 2026-04-09 - ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe - ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md index 811aa04f0c..c83513cdfb 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md @@ -117,4 +117,4 @@ Ce dossier contient toute la documentation nécessaire pour mener à bien la mod **Bonne modularisation ! 🚀** -*Version 1.0 - Dernière mise à jour : 2026-01-07* +*Version 1.0 - Dernière mise à jour : 2026-05-28* diff --git a/apps/server-nestjs/src/__mocks__/prisma.ts b/apps/server-nestjs/src/__mocks__/prisma.ts index 075578c96c..2a871fd476 100644 --- a/apps/server-nestjs/src/__mocks__/prisma.ts +++ b/apps/server-nestjs/src/__mocks__/prisma.ts @@ -2,7 +2,7 @@ import type { PrismaClient } from '@prisma/client' import { beforeEach, vi } from 'vitest' import { mockDeep, mockReset } from 'vitest-mock-extended' -vi.mock('../prisma.js') +vi.mock('../prisma') const prisma = mockDeep() diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index a0dc901c88..33a4bece14 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule' import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' import { ProjectModule } from './modules/project/project.module' import { ServiceChainModule } from './modules/service-chain/service-chain.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' @@ -18,6 +19,7 @@ import { VersionModule } from './modules/version/version.module' SystemSettingsModule, ServiceChainModule, ProjectModule, + ProjectMembersModule, DeploymentModule, VersionModule, ], diff --git a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts index ea2570e298..87d19ff6d5 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -21,9 +21,6 @@ describe('deploymentController', () => { name: 'dev', projectId: '11111111-1111-1111-1111-111111111111', environmentId: '22222222-2222-2222-2222-222222222222', - cpu: 1, - gpu: 0, - memory: 512, autosync: true, deploymentSources: [ { diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts index 672f0590bf..7592d1abb2 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -17,7 +17,7 @@ const mockDeploymentDatastoreService = { } const mockProjectService = { - getProjectWithDetails: vi.fn(), + get: vi.fn(), } const mockEventEmitter = { @@ -111,7 +111,7 @@ describe('deploymentService', () => { const createdDeployment = { id: deploymentId } mockDeploymentDatastoreService.createDeployment.mockResolvedValue(createdDeployment) - mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockProjectService.get.mockResolvedValue(mockProject) mockEventEmitter.emitAsync.mockResolvedValue([]) const result = await service.createDeployment(projectId, validCreateDeployment) @@ -134,7 +134,7 @@ describe('deploymentService', () => { }, }) - expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockProjectService.get).toHaveBeenCalledWith(projectId) expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) expect(result).toEqual(createdDeployment) }) @@ -154,7 +154,7 @@ describe('deploymentService', () => { mockDeploymentDatastoreService.getDeploymentById.mockResolvedValue(existingDeployment) mockDeploymentDatastoreService.updateDeployment.mockResolvedValue(updatedDeployment) - mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockProjectService.get.mockResolvedValue(mockProject) mockEventEmitter.emitAsync.mockResolvedValue([]) const result = await service.updateDeployment(deploymentId, validUpdateDeployment) @@ -172,7 +172,7 @@ describe('deploymentService', () => { }), ) - expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockProjectService.get).toHaveBeenCalledWith(projectId) expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) expect(result).toEqual(updatedDeployment) }) @@ -194,13 +194,13 @@ describe('deploymentService', () => { id: deploymentId, projectId, }) - mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockProjectService.get.mockResolvedValue(mockProject) mockEventEmitter.emitAsync.mockResolvedValue([]) await service.deleteDeployment(deploymentId) expect(mockDeploymentDatastoreService.deleteDeployment).toHaveBeenCalledWith(deploymentId) - expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockProjectService.get).toHaveBeenCalledWith(projectId) expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) }) }) @@ -208,13 +208,13 @@ describe('deploymentService', () => { describe('deleteAllDeploymentsByProjectId', () => { it('should delete all deployments and upsert project', async () => { mockDeploymentDatastoreService.deleteAllDeploymentsByProjectId.mockResolvedValue(undefined) - mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockProjectService.get.mockResolvedValue(mockProject) mockEventEmitter.emitAsync.mockResolvedValue([]) await service.deleteAllDeploymentsByProjectId(projectId) expect(mockDeploymentDatastoreService.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) - expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockProjectService.get).toHaveBeenCalledWith(projectId) expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) }) }) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.ts index 8eb4e94e01..8177d6eb41 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.service.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -99,8 +99,8 @@ export class DeploymentService { } private async upsertProject(projectId: string) { - const projectWithDetails = await this.projectService.getProjectWithDetails(projectId) + const project = await this.projectService.get(projectId) - await this.eventEmitter.emitAsync('project.upsert', projectWithDetails) + await this.eventEmitter.emitAsync('project.upsert', project) } } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts index 3a28be82c4..a441d01da6 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -1,5 +1,6 @@ import type KcAdminClient from '@keycloak/keycloak-admin-client' import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' import type { OnModuleInit } from '@nestjs/common' import type { ProjectWithDetails } from './keycloak-datastore.service' import { Inject, Injectable, Logger } from '@nestjs/common' @@ -98,6 +99,15 @@ export class KeycloakClientService implements OnModuleInit { return members || [] } + async getUserByEmail(email: string): Promise { + const users = await this.client.users.find({ + email, + exact: true, + max: 1, + }) + return users[0] + } + @StartActiveSpan() async createGroup(name: string) { const span = trace.getActiveSpan() diff --git a/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts new file mode 100644 index 0000000000..ecc0084de8 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -0,0 +1,53 @@ +import type { projectMemberContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' + +export const projectMemberWithUser = { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, +} satisfies Prisma.ProjectMembersInclude + +export type ProjectMemberWithUser = Prisma.ProjectMembersGetPayload<{ + include: typeof projectMemberWithUser +}> + +export function listProjectMembersWithUser(db: Prisma.TransactionClient, projectId: string) { + return db.projectMembers.findMany({ + where: { projectId }, + include: projectMemberWithUser, + }) +} + +export function upsertProjectMember( + tx: Prisma.TransactionClient, + projectId: string, + member: typeof projectMemberContract.patchMembers.body._type[number], +) { + const { userId, roles } = member + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: roles }, + update: { roleIds: roles }, + }) +} + +export function upsertProjectMemberIfMissing(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: [] }, + update: {}, + }) +} + +export function createProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.create({ data: { projectId, userId } }) +} + +export function deleteProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.delete({ where: { projectId_userId: { projectId, userId } } }) +} diff --git a/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/apps/server-nestjs/src/modules/project-members/project-members.controller.ts new file mode 100644 index 0000000000..b3f0fa9de2 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -0,0 +1,76 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard.js' +import { projectMemberContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator.js' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator.js' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator.js' +import { Project } from '../infrastructure/permission/project/project.decorator.js' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard.js' +import { UserGuard } from '../infrastructure/permission/user/user.guard.js' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectMembersService } from './project-members.service' +import { generateProjectMember } from './project-members.utils' + +@Controller('api/v1/projects') +export class ProjectMembersController { + private readonly logger = new Logger(ProjectMembersController.name) + + constructor( + @Inject(ProjectMembersService) private readonly projectMembers: ProjectMembersService, + ) {} + + @Get('/:projectId/members') + @UseGuards(UserGuard, ProjectGuard) + @RequireProjectPermission('ListMembers') + async listMembers( + @Project() project: ProjectContext, + ): Promise { + return (await this.projectMembers.listMembers(project.id)).map(generateProjectMember) + } + + @Post('/:projectId/members') + @HttpCode(201) + @UseGuards(UserGuard, ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async addMember( + @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: typeof projectMemberContract.addMember.body._type, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.addMember(project.id, body) + this.logger.log(`projectMembers.addMember completed (memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Patch('/:projectId/members') + @HttpCode(200) + @UseGuards(UserGuard, ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async patchMembers( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: typeof projectMemberContract.patchMembers.body._type, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.patchMembers(project.id, body) + this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Delete('/:projectId/members/:userId') + @HttpCode(200) + @UseGuards(UserGuard, ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async removeMember( + @Project() project: ProjectContext, + @Param('userId') userId: string, + ): Promise { + const members = await this.projectMembers.removeMember(project.id, userId) + this.logger.log(`projectMembers.removeMember completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } +} diff --git a/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/apps/server-nestjs/src/modules/project-members/project-members.module.ts new file mode 100644 index 0000000000..75f05d7713 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { ProjectMembersController } from './project-members.controller' +import { ProjectMembersService } from './project-members.service' + +@Module({ + imports: [InfrastructureModule, AuthModule, KeycloakModule], + controllers: [ProjectMembersController], + providers: [ProjectMembersService], +}) +export class ProjectMembersModule {} diff --git a/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts new file mode 100644 index 0000000000..3450cef2df --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -0,0 +1,282 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service.js' +import { + makeProject, + makeProjectMembers, + makeProjectMemberWithUser, + makeUser, +} from '../project/project-testing.utils' +import { ProjectMembersService } from './project-members.service' + +describe('projectMembersService', () => { + let module: TestingModule + let service: ProjectMembersService + let prisma: DeepMockProxy + let events: Mocked + let keycloak: Mocked + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectMembersService, + { + provide: PrismaService, + useValue: prisma, + }, + { + provide: EventEmitter2, + useValue: { + emitAsync: vi.fn().mockResolvedValue([]), + } satisfies Partial, + }, + { + provide: KeycloakClientService, + useValue: { + getUserByEmail: vi.fn().mockResolvedValue(undefined), + } satisfies Partial, + }, + ], + }).compile() + + service = module.get(ProjectMembersService) + events = module.get(EventEmitter2) + keycloak = module.get(KeycloakClientService) + }) + + describe('listMembers', () => { + it('returns members', async () => { + const projectId = faker.string.uuid() + const user1 = makeUser() + const user2 = makeUser() + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMemberWithUser(user1, { roleIds: [faker.string.uuid()] }), + makeProjectMemberWithUser(user2), + ]) + + const result = await service.listMembers(projectId) + + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('roleIds') + expect(result[0]).toHaveProperty('user') + expect(result[0].user.id).toBe(user1.id) + }) + + it('returns empty array when no members', async () => { + const projectId = faker.string.uuid() + prisma.projectMembers.findMany.mockResolvedValue([]) + + const result = await service.listMembers(projectId) + + expect(result).toEqual([]) + }) + }) + + describe('addMember', () => { + it('adds member by userId and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const newUserId = faker.string.uuid() + const body = { userId: newUserId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: newUserId })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: newUserId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.addMember(projectId, body) + + expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId: newUserId } }, + create: { projectId, userId: newUserId, roleIds: [] }, + update: {}, + }) + expect(events.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: newUserId, + }) + expect(result).toBeDefined() + }) + + it('adds member by email and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.addMember(projectId, body) + + expect(tx.user.findFirst).toHaveBeenCalledWith({ + where: { email, type: 'human' }, + }) + expect(result).toBeDefined() + }) + + it('throws when adding owner as member', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const body = { userId: ownerId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.addMember(projectId, body)) + .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') + }) + + it('throws NotFoundException when user not found by userId', async () => { + const projectId = faker.string.uuid() + const body = { userId: faker.string.uuid() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.addMember(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('falls back to Keycloak when user not found locally by email', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const keycloakUserId = faker.string.uuid() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(null) + tx.user.upsert.mockResolvedValue(makeUser({ id: keycloakUserId, email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: keycloakUserId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + keycloak.getUserByEmail.mockResolvedValue(makeUser({ + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + })) + + const result = await service.addMember(projectId, body) + + expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) + expect(tx.user.upsert).toHaveBeenCalledWith({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + adminRoleIds: [], + type: 'human', + }, + update: { + email, + firstName: 'Keycloak', + lastName: 'User', + type: 'human', + }, + }) + expect(result).toBeDefined() + }) + + it('throws NotFoundException when user not found by email anywhere', async () => { + const projectId = faker.string.uuid() + const body = { email: faker.internet.email() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.addMember(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.addMember(faker.string.uuid(), { userId: faker.string.uuid() }), + ).rejects.toThrow(NotFoundException) + }) + }) + + describe('patchMembers', () => { + it('upserts multiple members and emits events', async () => { + const projectId = faker.string.uuid() + const members = [ + { userId: faker.string.uuid(), roles: [faker.string.uuid()] }, + { userId: faker.string.uuid(), roles: [] }, + ] + + const tx = mockDeep() + prisma.$transaction.mockImplementation(async cb => cb(tx)) + tx.projectMembers.findMany.mockResolvedValue([]) + + await service.patchMembers(projectId, members) + + expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) + expect(events.emitAsync).toHaveBeenCalledTimes(2) + expect(events.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[0].userId, + }) + expect(events.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[1].userId, + }) + }) + }) + + describe('removeMember', () => { + it('deletes member, emits event, returns updated list', async () => { + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + const tx = mockDeep() + tx.projectMembers.delete.mockResolvedValue(makeProjectMembers({ projectId, userId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.removeMember(projectId, userId) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(events.emitAsync).toHaveBeenCalledWith('projectMember.delete', { + projectId, + userId, + }) + expect(result).toBeDefined() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/apps/server-nestjs/src/modules/project-members/project-members.service.ts new file mode 100644 index 0000000000..521a182d90 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -0,0 +1,170 @@ +import type { projectMemberContract } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service.js' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from '../keycloak/keycloak-client.service.js' +import { getHumanUser, getProjectOwnerId } from '../project/project-queries.utils.js' +import { + deleteProjectMember, + listProjectMembersWithUser, + upsertProjectMember, + upsertProjectMemberIfMissing, +} from './project-members-queries.utils.js' + +@Injectable() +export class ProjectMembersService { + private readonly logger = new Logger(ProjectMembersService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + ) {} + + @StartActiveSpan() + async listMembers(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) + const members = await listProjectMembersWithUser(this.prisma, projectId) + span?.setAttribute('project.members.count', members.length) + this.logger.debug(`projectMembers.listMembers completed (projectId=${projectId}, count=${members.length})`) + return members + } + + @StartActiveSpan() + async addMember( + projectId: string, + body: typeof projectMemberContract.addMember.body._type, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + const lookupBy = 'userId' in body ? 'userId' : 'email' + span?.setAttribute('project.member.lookupBy', lookupBy) + const userIdCandidate = 'userId' in body ? body.userId : undefined + this.logger.log(`projectMembers.addMember started (projectId=${projectId}, lookupBy=${lookupBy}, userId=${userIdCandidate})`) + try { + const result = await this.prisma.$transaction(async (tx) => { + const project = await getProjectOwnerId(tx, projectId) + if (!project) throw new NotFoundException() + + const userId = 'userId' in body ? body.userId : undefined + const email = 'email' in body ? body.email : undefined + + const userDb = await this.resolveHumanUser(tx, { userId, email }) + if (!userDb) throw new NotFoundException('Utilisateur introuvable') + + if (userDb.id === project.ownerId) { + throw new BadRequestException('Le owner ne peut pas être ajouté à cette liste') + } + + await upsertProjectMemberIfMissing(tx, projectId, userDb.id) + + const members = await listProjectMembersWithUser(tx, projectId) + return { userId: userDb.id, members } + }) + await this.eventEmitter.emitAsync('projectMember.upsert', { projectId, userId: result.userId }) + span?.setAttribute('project.member.userId', result.userId) + span?.setAttribute('project.members.count', result.members.length) + this.logger.log(`projectMembers.addMember completed (projectId=${projectId}, userId=${result.userId}, memberCount=${result.members.length})`) + return result.members + } catch (error) { + this.logger.error( + `projectMembers.addMember failed (projectId=${projectId}, lookupBy=${lookupBy}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveHumanUser( + tx: Parameters[0], + opts: { userId?: string, email?: string }, + ) { + const userDb = await getHumanUser(tx, opts) + if (userDb || !opts.email) { + return userDb + } + + const keycloakUser = await this.keycloak.getUserByEmail(opts.email) + if (!keycloakUser) { + return null + } + + const keycloakUserId = keycloakUser.id ?? opts.email + return tx.user.upsert({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + adminRoleIds: [], + type: 'human', + }, + update: { + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + type: 'human', + }, + }) + } + + @StartActiveSpan() + async patchMembers( + projectId: string, + body: typeof projectMemberContract.patchMembers.body._type, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.members.patch.count', body.length) + this.logger.log(`projectMembers.patchMembers started (projectId=${projectId}, patchCount=${body.length})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + for (const member of body) { + await upsertProjectMember(tx, projectId, member) + } + return listProjectMembersWithUser(tx, projectId) + }) + await Promise.all( + body.map(member => this.eventEmitter.emitAsync('projectMember.upsert', { projectId, userId: member.userId })), + ) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.patchMembers completed (projectId=${projectId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.patchMembers failed (projectId=${projectId}, patchCount=${body.length}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async removeMember(projectId: string, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.member.userId', userId) + this.logger.log(`projectMembers.removeMember started (projectId=${projectId}, userId=${userId})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + await deleteProjectMember(tx, projectId, userId) + return listProjectMembersWithUser(tx, projectId) + }) + await this.eventEmitter.emitAsync('projectMember.delete', { projectId, userId }) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.removeMember completed (projectId=${projectId}, userId=${userId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.removeMember failed (projectId=${projectId}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/apps/server-nestjs/src/modules/project-members/project-members.utils.ts new file mode 100644 index 0000000000..4131cfbebd --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -0,0 +1,13 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectMemberWithUser } from './project-members-queries.utils' + +export function generateProjectMember(member: ProjectMemberWithUser): Member { + const { roleIds, user } = member + return { + userId: user.id, + roleIds, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + } +} diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts deleted file mode 100644 index bc10155604..0000000000 --- a/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PrismaService } from '../infrastructure/database/prisma.service' -import { ProjectDatastoreService } from './project-datastore.service' - -const mockPrismaService = { - project: { - findUnique: vi.fn(), - }, -} - -describe('projectDatastoreService', () => { - let module: TestingModule - let service: ProjectDatastoreService - - const projectId = '11111111-1111-1111-1111-111111111111' - - beforeEach(async () => { - vi.clearAllMocks() - - module = await Test.createTestingModule({ - providers: [ - ProjectDatastoreService, - { - provide: PrismaService, - useValue: mockPrismaService, - }, - ], - }).compile() - - service = module.get(ProjectDatastoreService) - }) - - it('should be defined', () => { - expect(service).toBeDefined() - }) - - describe('getProjectWithDetails', () => { - it('should call prisma.project.findUnique with correct project id and selection', async () => { - const mockProject = { - id: projectId, - name: 'Test Project', - slug: 'test-project', - plugins: [], - repositories: [], - environments: [], - deployments: [], - } - - mockPrismaService.project.findUnique.mockResolvedValue(mockProject) - - const result = await service.getProjectWithDetails(projectId) - - expect(mockPrismaService.project.findUnique).toHaveBeenCalledTimes(1) - expect(mockPrismaService.project.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: projectId }, - select: expect.objectContaining({ - id: true, - name: true, - slug: true, - plugins: expect.any(Object), - repositories: expect.any(Object), - environments: expect.any(Object), - deployments: expect.any(Object), - }), - }), - ) - - expect(result).toEqual(mockProject) - }) - - it('should return null if project is not found', async () => { - mockPrismaService.project.findUnique.mockResolvedValue(null) - - const result = await service.getProjectWithDetails(projectId) - - expect(mockPrismaService.project.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: projectId }, - }), - ) - - expect(result).toBeNull() - }) - }) -}) diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.ts deleted file mode 100644 index 550baebd20..0000000000 --- a/apps/server-nestjs/src/modules/project/project-datastore.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Prisma } from '@prisma/client' -import { Inject, Injectable } from '@nestjs/common' -import { PrismaService } from '../infrastructure/database/prisma.service' - -const projectSelect = { - id: true, - name: true, - slug: true, - plugins: { - select: { - pluginName: true, - key: true, - value: true, - }, - }, - repositories: { - select: { - id: true, - internalRepoName: true, - isInfra: true, - helmValuesFiles: true, - deployRevision: true, - deployPath: true, - }, - }, - environments: { - select: { - id: true, - name: true, - cpu: true, - gpu: true, - memory: true, - autosync: true, - cluster: { - select: { - id: true, - label: true, - zone: { - select: { - slug: true, - }, - }, - }, - }, - }, - }, - deployments: { - select: { - id: true, - name: true, - autosync: true, - environment: { - select: { - id: true, - name: true, - cluster: { - select: { - id: true, - label: true, - zone: { - select: { - slug: true, - }, - }, - }, - }, - cpu: true, - gpu: true, - memory: true, - autosync: true, - }, - }, - deploymentSources: { - select: { - type: true, - path: true, - targetRevision: true, - helmValuesFiles: true, - repository: { - select: { - id: true, - internalRepoName: true, - }, - }, - }, - }, - }, - }, -} satisfies Prisma.ProjectSelect - -@Injectable() -export class ProjectDatastoreService { - constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} - - getProjectWithDetails(projectId: string) { - return this.prisma.project.findUnique({ where: { id: projectId }, select: projectSelect }) - } -} diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.ts new file mode 100644 index 0000000000..7054c74a79 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -0,0 +1,322 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + status: true, + locked: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + everyonePerms: true, + ownerId: true, + createdAt: true, + updatedAt: true, + lastSuccessProvisionningVersion: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + createdAt: true, + updatedAt: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + clusterId: true, + stageId: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + createdAt: true, + updatedAt: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + id: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect + +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectOwnerIdSelect = { + ownerId: true, +} satisfies Prisma.ProjectSelect + +export type ProjectDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectId = Prisma.ProjectGetPayload<{ + select: typeof projectIdSelect +}> + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export type ProjectOwnerId = Prisma.ProjectGetPayload<{ + select: typeof projectOwnerIdSelect +}> + +export function getProject(db: Prisma.TransactionClient, projectId: string) { + return db.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(db: Prisma.TransactionClient, projectId: string) { + return db.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(db: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return db.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(db: Prisma.TransactionClient, prefix: string) { + return db.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: projectSlugSelect, + }) +} + +export function getProjectSlug(db: Prisma.TransactionClient, projectId: string) { + return db.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} + +export function getProjectOwnerId(db: Prisma.TransactionClient, projectId: string) { + return db.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +} + +export function listProjectIdsNotArchived(db: Prisma.TransactionClient) { + return db.project.findMany({ + select: projectIdSelect, + where: { status: { not: 'archived' } }, + }) +} + +export function updateProjectLocked(db: Prisma.TransactionClient, projectId: string, locked: boolean) { + return db.project.update({ + where: { id: projectId }, + data: { locked }, + }) +} + +export function listProjectsForDataExport(db: Prisma.TransactionClient) { + return db.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) +} + +export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: string, email?: string }) { + const { userId, email } = opts + return tx.user.findFirst({ + where: { + type: 'human', + ...(typeof userId === 'string' ? { id: userId } : {}), + ...(typeof email === 'string' ? { email } : {}), + }, + }) +} diff --git a/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/apps/server-nestjs/src/modules/project/project-testing.utils.ts new file mode 100644 index 0000000000..3d50324973 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -0,0 +1,160 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma, Project, ProjectMembers, User } from '@prisma/client' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard.js' +import type { VaultMetadata, VaultSecret } from '../vault/vault-client.service' +import type { ProjectDetails } from './project-queries.utils' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [] as string[], + type: 'human' as const, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + ...overrides, + } satisfies User +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectDetails { + const owner = overrides.owner ?? makeUser() + const id = faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + limitless: false, + hprodCpu: 1, + hprodGpu: 0, + hprodMemory: 2, + prodCpu: 1, + prodGpu: 0, + prodMemory: 2, + everyonePerms: PROJECT_PERMS.GUEST, + ownerId: owner.id, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } satisfies ProjectDetails +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + locked: false, + status: 'created', + ...overrides, + } satisfies ProjectContext +} + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } satisfies typeof projectContract.createProject.body._type +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member' as const, + ...overrides, + } satisfies typeof projectContract.listProjects.query._type +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + ownerId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + ...overrides, + } satisfies Project +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} + +type ProjectMembersWithUser = ProjectMembers & { user: User } + +export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectMembersWithUser { + return { + ...makeProjectMembers({ userId: user.id, ...overrides }), + user, + } +} + +export function makeVaultSecret = Record>(overrides: Partial> = {}): VaultSecret { + return { + data: {} as T, + metadata: makeVaultMetadata(), + ...overrides, + } satisfies VaultSecret +} + +function makeVaultMetadata(overrides: Partial = {}): VaultMetadata { + return { + created_time: faker.date.past().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } satisfies VaultMetadata +} + +export type ProjectWithMembers = Prisma.ProjectGetPayload<{ + include: { members: { include: { user: true } } } +}> + +export function makeProjectWithMembersResult( + project: ProjectDetails, + members: Array> = [], +): ProjectWithMembers { + return { ...project, members } as ProjectWithMembers +} diff --git a/apps/server-nestjs/src/modules/project/project.controller.ts b/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..53cb9f1894 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,101 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth.service' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard.js' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, Get, HttpCode, Inject, Logger, Post, Put, Query, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator.js' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator.js' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator.js' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator.js' +import { Project } from '../infrastructure/permission/project/project.decorator.js' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard.js' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator.js' +import { UserGuard } from '../infrastructure/permission/user/user.guard.js' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + private readonly logger = new Logger(ProjectController.name) + + constructor( + @Inject(ProjectService) private readonly projectService: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getData(): Promise { + return json2csv(await this.projectService.getData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + return (await this.projectService.list(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(201) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + ): Promise { + return generateProjectV2(await this.projectService.create(body, user.userId)) + } + + @Post('-bulk') + @HttpCode(202) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + ): Promise { + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${body.projectIds === 'all' ? 'all' : `count=${body.projectIds.length}`})`) + await this.projectService.bulkAction(body) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } + + @Get('/:projectId') + @UseGuards(UserGuard, ProjectGuard) + @RequireAdminPermission('Manage') + @RequireProjectPermission('ListMembers') + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.projectService.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(200) + @UseGuards(UserGuard, ProjectGuard) + @RequireAdminPermission('Manage') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ): Promise { + return generateProjectV2(await this.projectService.update(data, user, project.id)) + } + + @Get('/:projectId/secrets') + @UseGuards(UserGuard, ProjectGuard) + @RequireAdminPermission('Manage') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('SeeSecrets') + async getSecrets( + @Project() project: ProjectContext, + ): Promise>> { + return this.projectService.getSecrets(project.id) + } +} diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts index 055105ab3a..48123f32f5 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { ProjectDatastoreService } from './project-datastore.service' +import { VaultModule } from '../vault/vault.module' +import { ProjectController } from './project.controller' import { ProjectService } from './project.service' @Module({ - imports: [InfrastructureModule], - controllers: [], + imports: [InfrastructureModule, VaultModule], + controllers: [ProjectController], providers: [ ProjectService, - ProjectDatastoreService, ], exports: [ProjectService], }) diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index a6202fe039..80a993d415 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -1,69 +1,706 @@ import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth.service.js' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ProjectDatastoreService } from './project-datastore.service' -import { ProjectService } from './project.service' - -const mockProjectDatastoreService = { - getProjectWithDetails: vi.fn(), -} +import { mockDeep } from 'vitest-mock-extended' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service.js' +import { PrismaService } from '../infrastructure/database/prisma.service.js' +import { VaultClientService } from '../vault/vault-client.service.js' +import { VaultService } from '../vault/vault.service.js' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, + makeVaultSecret, +} from './project-testing.utils.js' +import { ProjectService } from './project.service.js' +import { generateSlug } from './project.utils.js' describe('projectService', () => { let module: TestingModule let service: ProjectService - - const projectId = '11111111-1111-1111-1111-111111111111' + let prisma: DeepMockProxy + let events: Mocked + let vault: Mocked + let vaultClient: Mocked beforeEach(async () => { - vi.clearAllMocks() + prisma = mockDeep() module = await Test.createTestingModule({ providers: [ ProjectService, { - provide: ProjectDatastoreService, - useValue: mockProjectDatastoreService, + provide: PrismaService, + useValue: prisma, + }, + { + provide: EventEmitter2, + useValue: { + emitAsync: vi.fn().mockResolvedValue([]), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + appVersion: 'dev', + projectRootDir: '/vault', + } satisfies Partial, + }, + { + provide: VaultService, + useValue: { + listProjectSecrets: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultClientService, + useValue: { + read: vi.fn(), + } satisfies Partial, }, ], }).compile() - service = module.get(ProjectService) + service = module.get(ProjectService) + prisma = module.get(PrismaService) + events = module.get(EventEmitter2) + vault = module.get(VaultService) + vaultClient = module.get(VaultClientService) + + prisma.$transaction.mockImplementation(async (cb) => { + const tx = { + project: prisma.project, + projectMembers: prisma.projectMembers, + user: prisma.user, + repository: prisma.repository, + environment: prisma.environment, + deployment: prisma.deployment, + } as unknown as Prisma.TransactionClient + return cb(tx) + }) }) - it('should be defined', () => { - expect(service).toBeDefined() + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const result = await service.create(body, userId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.anything()) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId)) + .rejects.toThrow(InternalServerErrorException) + }) }) - describe('getProjectWithDetails', () => { - it('should return project details when project exists', async () => { - const mockProject = { - id: projectId, - name: 'Test Project', - slug: 'test-project', - plugins: [], - repositories: [], - environments: [], - deployments: [], - } + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.list( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.list( + makeListProjectsQuery({ filter: 'all' }), + user, + ) - mockProjectDatastoreService.getProjectWithDetails.mockResolvedValue(mockProject) + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) - const result = await service.getProjectWithDetails(projectId) + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledTimes(1) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledWith(projectId) - expect(result).toEqual(mockProject) + await expect( + service.list(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) }) - it('should throw an error when project does not exist', async () => { - mockProjectDatastoreService.getProjectWithDetails.mockResolvedValue(null) + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.list( + makeListProjectsQuery({ status: 'created' }), + user, + ) - await expect(service.getProjectWithDetails(projectId)).rejects.toThrow( - `Project with id ${projectId} not found`, + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledTimes(1) - expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + await service.list( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) + }) + + describe('get', () => { + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) + + const result = await service.get(ctx.id) + + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) + + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(result.description).toBe('Updated desc') + expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', expect.anything()) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd as any) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.archive(projectId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(events.emitAsync).toHaveBeenCalledWith('project.delete', pwd) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('replayHooks', () => { + it('emits project.upsert event', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId }) + prisma.project.findFirst.mockResolvedValue(pwd) + + await service.replayHooks(projectId) + + expect(prisma.project.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: projectId }) })) + expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', pwd) + }) + + it('throws NotFoundException when project does not exist', async () => { + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.replayHooks(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getSecrets', () => { + it('returns parsed secrets from vault', async () => { + const projectId = faker.string.uuid() + const slug = 'myproject' + prisma.project.findUnique.mockResolvedValue(makeProject({ slug })) + vault.listProjectSecrets.mockResolvedValue(['group1/secret1']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { key1: 'value1', key2: 42, key3: true, key4: null } })) + + const result = await service.getSecrets(projectId) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { slug: true }, + }) + expect(vault.listProjectSecrets).toHaveBeenCalledWith(slug) + expect(result).toHaveProperty('group1') + expect(result.group1).toHaveProperty('secret1.key1', 'value1') + expect(result.group1).toHaveProperty('secret1.key2', '42') + expect(result.group1).toHaveProperty('secret1.key3', 'true') + expect(result.group1).toHaveProperty('secret1.key4', '') + }) + + it('handles nested secret paths', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/sub/path']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { nested: 'value' } })) + + const result = await service.getSecrets(projectId) + + expect(result.group1).toHaveProperty('sub/path.nested', 'value') + }) + + it('throws NotFoundException when project does not exist', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + await expect(service.getSecrets(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + + it('returns empty object when no secrets exist', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue([]) + + const result = await service.getSecrets(projectId) + + expect(result).toEqual({}) + }) + + it('skips secrets that fail to read', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/s1', 'group1/s2']) + vaultClient.read + .mockRejectedValueOnce(new Error('vault error')) + .mockResolvedValueOnce(makeVaultSecret({ data: { key: 'val' } })) + + const result = await service.getSecrets(projectId) + + expect(result.group1).toEqual({ 's2.key': 'val' }) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + }) + + describe('bulkAction', () => { + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + const data = { action: 'archive' as const, projectIds } + + prisma.repository.deleteMany.mockResolvedValue({ count: 0 }) + prisma.environment.deleteMany.mockResolvedValue({ count: 0 }) + prisma.deployment.deleteMany.mockResolvedValue({ count: 0 }) + prisma.project.findUnique.mockResolvedValue(makeProjectWithDetails()) + prisma.project.update.mockResolvedValue(makeProject()) + + await service.bulkAction(data) + + expect(prisma.project.findUnique).toHaveBeenCalledTimes(2) + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([makeProject({ id: project1Id }), makeProject({ id: project2Id })]) + prisma.repository.deleteMany.mockResolvedValue({ count: 0 }) + prisma.environment.deleteMany.mockResolvedValue({ count: 0 }) + prisma.deployment.deleteMany.mockResolvedValue({ count: 0 }) + prisma.project.findUnique.mockResolvedValue(makeProjectWithDetails()) + prisma.project.update.mockResolvedValue(makeProject()) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }) + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(prisma.project.findUnique).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via prisma', async () => { + const projectId = faker.string.uuid() + + prisma.project.update.mockResolvedValue(makeProject({ id: projectId })) + + await service.bulkAction( + { action: 'lock', projectIds: [projectId] }, + ) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: projectId }, + data: { locked: true }, + }) + }) + + it('unlock action updates locked to false via prisma', async () => { + const projectId = faker.string.uuid() + + prisma.project.update.mockResolvedValue(makeProject({ id: projectId })) + + await service.bulkAction( + { action: 'unlock', projectIds: [projectId] }, + + ) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: projectId }, + data: { locked: false }, + }) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId }) + prisma.project.findFirst.mockResolvedValue(pwd) + + await service.bulkAction( + { action: 'replay', projectIds: [projectId] }, + + ) + + expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', pwd) + }) + + it('silently ignores errors in individual tasks', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findUnique + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce(makeProjectWithDetails({ id: project2Id })) + prisma.repository.deleteMany.mockResolvedValue({ count: 0 }) + prisma.environment.deleteMany.mockResolvedValue({ count: 0 }) + prisma.deployment.deleteMany.mockResolvedValue({ count: 0 }) + prisma.project.update.mockResolvedValue(makeProject({ id: project2Id })) + + await service.bulkAction( + { action: 'archive', projectIds: [project1Id, project2Id] }, + ) }) }) }) diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 965b95960b..d7297c0ff8 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,15 +1,348 @@ -import { Inject, Injectable } from '@nestjs/common' -import { ProjectDatastoreService } from './project-datastore.service' +import type { projectContract } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth.service' +import type { ProjectDataExport, ProjectDetails } from './project-queries.utils.js' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service.js' +import { PrismaService } from '../infrastructure/database/prisma.service.js' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils.js' +import { VaultClientService } from '../vault/vault-client.service.js' +import { VaultService } from '../vault/vault.service.js' +import { generateProjectPath } from '../vault/vault.utils.js' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + getProjectNotArchived, + getProjectSlug, + listProjectIdsNotArchived, + listProjects, + listProjectsForDataExport, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + updateProject, + updateProjectLocked, +} from './project-queries.utils.js' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput, parseSecretValue } from './project.utils.js' @Injectable() export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + constructor( - @Inject(ProjectDatastoreService) private readonly deploymentDatastoreService: ProjectDatastoreService, + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, ) {} - async getProjectWithDetails(projectId: string) { - const projectWithDetails = await this.deploymentDatastoreService.getProjectWithDetails(projectId) - if (!projectWithDetails) throw new Error(`Project with id ${projectId} not found`) - return projectWithDetails + @StartActiveSpan() + async getData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await listProjectsForDataExport(this.prisma) + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async list( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.config.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await listProjects(this.prisma, whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() + async get(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await getProjectNotArchived(this.prisma, projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException() + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create(body: typeof projectContract.createProject.body._type, requestorUserId: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.eventEmitter.emitAsync('project.upsert', project) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException() + + const isOwner = projectDb.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + if (strippedKeys.length) { + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${user.userId}, strippedKeys=${strippedKeys.join(',')})`) + } + + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (ownerIdCandidate && ownerIdCandidate !== projectDb.ownerId) { + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${user.userId}, previousOwnerId=${projectDb.ownerId}, nextOwnerId=${ownerIdCandidate})`) + const memberCandidate = projectDb.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + const oldOwnerIsMember = projectDb.members.some(member => member.userId === projectDb.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, projectDb.id, projectDb.ownerId) + } + await deleteProjectMember(tx, projectDb.id, ownerIdCandidate) + await updateProject(tx, projectDb.id, { owner: { connect: { id: ownerIdCandidate } } }) + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${user.userId}, previousOwnerId=${projectDb.ownerId}, nextOwnerId=${ownerIdCandidate})`) + } + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException() + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.eventEmitter.emitAsync('project.upsert', project) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException() + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + await this.eventEmitter.emitAsync('project.delete', project) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async replayHooks(projectId: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.replayHooks started (projectId=${projectId})`) + const project = await this.get(projectId) + span?.setAttribute('project.slug', project.slug) + await this.eventEmitter.emitAsync('project.upsert', project) + this.logger.log(`project.replayHooks completed (projectId=${projectId})`) + } + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + ): Promise { + const span = trace.getActiveSpan() + const projectSelector = data.projectIds + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = projectSelector === 'all' ? 'all' : `count=${projectSelector.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + try { + let projectIds = data.projectIds + if (projectIds === 'all') { + projectIds = (await listProjectIdsNotArchived(this.prisma)) + .map(({ id }) => id) + } + span?.setAttribute('project.bulk.count', projectIds.length) + + const tasks = projectIds.map((projectId) => { + if (data.action === 'archive') { + return () => this.archive(projectId) + } + if (data.action === 'lock' || data.action === 'unlock') { + return () => updateProjectLocked(this.prisma, projectId, data.action === 'lock') + } + if (data.action === 'replay') { + return () => this.replayHooks(projectId) + } + return async () => undefined + }) + + const results = await Promise.allSettled(tasks.map(t => t())) + const summary = results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectSelector === 'all' ? 'all' : `count=${projectSelector.length}`}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async getSecrets(projectId: string): Promise>> { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.getSecrets started (projectId=${projectId})`) + try { + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException() + span?.setAttribute('project.slug', project.slug) + const projectPath = generateProjectPath(this.config.projectRootDir, project.slug) + + const result: Record> = {} + const relativePaths = await this.vault.listProjectSecrets(project.slug) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.getSecrets listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } + } + + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.getSecrets completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } catch (error) { + this.logger.error( + `project.getSecrets failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } } } diff --git a/apps/server-nestjs/src/modules/project/project.utils.ts b/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..a3df864609 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,235 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export function generateProjectV2(project: ProjectDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectSchemaV2.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} + +const SecretValueSchema = z.union([ + z.string(), + z.undefined().transform(() => ''), + z.number().transform(String), + z.bigint().transform(String), + z.boolean().transform(String), + z.null().transform(() => ''), +]).catch('') + +export function parseSecretValue(value: string): string { + return SecretValueSchema.parse(value) +} diff --git a/apps/server-nestjs/test/project-members.e2e-spec.ts b/apps/server-nestjs/test/project-members.e2e-spec.ts new file mode 100644 index 0000000000..b8cc86047a --- /dev/null +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -0,0 +1,165 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' + +const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) + +describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectMembersService + let eventEmitter: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + eventEmitter = mockDeep() + eventEmitter.emitAsync.mockResolvedValue([]) + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigurationModule, InfrastructureModule], + providers: [ + ProjectMembersService, + { + provide: EventEmitter2, + useValue: eventEmitter, + }, + { + provide: KeycloakClientService, + useValue: keycloakClient, + }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectMembersService) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects addMember when project does not exist', async () => { + await expect(service.addMember(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + projectId = faker.string.uuid() + const projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('listMembers', async () => { + const members = await service.listMembers(projectId) + expect(members).toHaveLength(0) + }) + + it('addMember', async () => { + const afterAdd = await service.addMember(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) + }) + + it('patchMembers', async () => { + await service.addMember(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await service.patchMembers(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('removeMember', async () => { + await service.addMember(projectId, { userId: memberId }) + const afterRemove = await service.removeMember(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) + }) + + it('rejects addMember when adding owner', async () => { + await expect(service.addMember(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects addMember when user does not exist', async () => { + await expect(service.addMember(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..c8495de0b6 --- /dev/null +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,253 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectService } from '../src/modules/project/project.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultService } from '../src/modules/vault/vault.service' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let projectMembers: ProjectMembersService + let eventEmitter: DeepMockProxy + let vaultService: DeepMockProxy + let vaultClient: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + eventEmitter = mockDeep() + eventEmitter.emitAsync.mockResolvedValue([]) + vaultService = mockDeep() + vaultService.listProjectSecrets.mockResolvedValue([]) + vaultClient = mockDeep() + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigurationModule, InfrastructureModule], + providers: [ + ProjectService, + ProjectMembersService, + { + provide: EventEmitter2, + useValue: eventEmitter, + }, + { + provide: VaultService, + useValue: vaultService, + }, + { + provide: VaultClientService, + useValue: vaultClient, + }, + { + provide: KeycloakClientService, + useValue: keycloakClient, + }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + projectMembers = moduleRef.get(ProjectMembersService) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.list({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + it('rejects ProjectService.getSecrets when project does not exist', async () => { + await expect(service.getSecrets(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + it('rejects ProjectMembersService.addMember when project does not exist', async () => { + await expect(projectMembers.addMember(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.list({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.replayHooks', async () => { + await service.replayHooks(projectId) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.anything()) + }) + + it('ProjectService.bulkAction lock/unlock', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('ProjectService.getSecrets', async () => { + const secrets = await service.getSecrets(projectId) + expect(secrets).toEqual({}) + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('ProjectMembersService.listMembers', async () => { + const members = await projectMembers.listMembers(projectId) + expect(members).toHaveLength(0) + }) + + it('ProjectMembersService.addMember', async () => { + const afterAdd = await projectMembers.addMember(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + }) + + it('ProjectMembersService.patchMembers', async () => { + await projectMembers.addMember(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await projectMembers.patchMembers(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('ProjectMembersService.removeMember', async () => { + await projectMembers.addMember(projectId, { userId: memberId }) + const afterRemove = await projectMembers.removeMember(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectMembersService.addMember when adding owner', async () => { + await expect(projectMembers.addMember(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects ProjectMembersService.addMember when user does not exist', async () => { + await expect(projectMembers.addMember(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/apps/server/.envrc b/apps/server/.envrc new file mode 100644 index 0000000000..8c2f820249 --- /dev/null +++ b/apps/server/.envrc @@ -0,0 +1,9 @@ +if [ "$INTEGRATION" = true ]; then + dotenv .env.integ +elif [ "$DOCKER" = true ]; then + dotenv .env.docker +else + dotenv +fi + +source_up