From d76d8653648f41c3afcffc172d50275e852a43bd Mon Sep 17 00:00:00 2001 From: Shikanime Deva Date: Fri, 26 Jun 2026 15:03:58 +0200 Subject: [PATCH] refactor(user-tokens): migrate from server Signed-off-by: Shikanime Deva Change-Id: I5b6db65fcf411b3bfddc4a72430b8d4c6a6a6964 --- .../MODULARISATION-CARTOGRAPHIE.md | 2 +- apps/server-nestjs/src/main.module.ts | 4 + .../admin-token/admin-token-queries.utils.ts | 80 ++++++++ .../admin-token/admin-token.controller.ts | 34 ++++ .../modules/admin-token/admin-token.module.ts | 12 ++ .../admin-token/admin-token.service.spec.ts | 121 ++++++++++++ .../admin-token/admin-token.service.ts | 92 +++++++++ .../modules/argocd/argocd-health.service.ts | 4 +- .../modules/gitlab/gitlab-health.service.ts | 4 +- .../keycloak/keycloak-health.service.ts | 4 +- .../src/modules/nexus/nexus-health.service.ts | 4 +- .../nexus/nexus-http-client.service.ts | 10 +- .../modules/opencds/opencds-health.service.ts | 4 +- .../src/modules/project/project.controller.ts | 8 +- .../registry/registry-health.service.ts | 4 +- .../registry/registry-http-client.service.ts | 10 +- .../registry/registry-testing.utils.ts | 7 +- .../modules/registry/registry.service.spec.ts | 7 +- .../service-chain/open-cds-client.service.ts | 3 +- .../service-chain/service-chain.utils.ts | 3 +- .../sonarqube/sonarqube-health.service.ts | 4 +- .../sonarqube-http-client.service.ts | 10 +- .../user-tokens/user-tokens-queries.utils.ts | 60 ++++++ .../user-tokens/user-tokens.controller.ts | 45 +++++ .../modules/user-tokens/user-tokens.module.ts | 12 ++ .../user-tokens/user-tokens.service.spec.ts | 126 +++++++++++++ .../user-tokens/user-tokens.service.ts | 87 +++++++++ .../src/modules/vault/vault-health.service.ts | 4 +- .../vault/vault-http-client.service.ts | 8 +- apps/server-nestjs/src/utils/token.spec.ts | 76 ++++++++ apps/server-nestjs/src/utils/token.ts | 37 ++++ .../test/admin-token.e2e-spec.ts | 144 ++++++++++++++ .../test/user-tokens.e2e-spec.ts | 175 ++++++++++++++++++ 33 files changed, 1157 insertions(+), 48 deletions(-) create mode 100644 apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts create mode 100644 apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts create mode 100644 apps/server-nestjs/src/modules/admin-token/admin-token.module.ts create mode 100644 apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/admin-token/admin-token.service.ts create mode 100644 apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.ts create mode 100644 apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.ts create mode 100644 apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts create mode 100644 apps/server-nestjs/src/modules/user-tokens/user-tokens.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts create mode 100644 apps/server-nestjs/src/utils/token.spec.ts create mode 100644 apps/server-nestjs/src/utils/token.ts create mode 100644 apps/server-nestjs/test/admin-token.e2e-spec.ts create mode 100644 apps/server-nestjs/test/user-tokens.e2e-spec.ts 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..a5e6b10c1d 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 @@ -18,7 +18,7 @@ Cartographier l'ensemble des modules de l'application backend actuelle pour : | Decision | Choix | |----------|-------| -| Approche migration | Bottom-up (feuilles d'abord, puis remontee vers les modules couples) | +| Approche migration | Bottom-up (feuilles d'abord, puis remontée vers les modules couples) | | Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) | | queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma | | Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) | diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0bc0e5a91d..74c7cf2adc 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common' import { ScheduleModule } from '@nestjs/schedule' +import { AdminTokenModule } from './modules/admin-token/admin-token.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' @@ -13,10 +14,12 @@ import { ProjectServicesModule } from './modules/project-services/project-servic 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' +import { UserTokensModule } from './modules/user-tokens/user-tokens.module' import { VersionModule } from './modules/version/version.module' @Module({ imports: [ + AdminTokenModule, HealthzModule, KeycloakModule, ScheduleModule.forRoot(), @@ -31,6 +34,7 @@ import { VersionModule } from './modules/version/version.module' ProjectRolesModule, LogModule, DeploymentModule, + UserTokensModule, VersionModule, ], controllers: [], diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts new file mode 100644 index 0000000000..41d07061c8 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts @@ -0,0 +1,80 @@ +import type { Prisma } from '@prisma/client' + +export const adminTokenOwnerSelect = { + id: true, + email: true, + firstName: true, + lastName: true, + type: true, +} satisfies Prisma.UserSelect + +export const adminTokenSelect = { + id: true, + name: true, + permissions: true, + lastUse: true, + expirationDate: true, + status: true, + createdAt: true, + userId: true, + owner: { + select: adminTokenOwnerSelect, + }, +} satisfies Prisma.AdminTokenSelect + +export type AdminTokenRecord = Prisma.AdminTokenGetPayload<{ + select: typeof adminTokenSelect +}> + +export function listAdminTokens(tx: Prisma.TransactionClient, withRevoked: boolean) { + const where: Prisma.AdminTokenWhereInput = withRevoked + ? { status: { in: ['active', 'revoked'] } } + : { status: 'active' } + + return tx.adminToken.findMany({ + where, + select: adminTokenSelect, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + }) +} + +export function createBotUser(tx: Prisma.TransactionClient, data: { botUserId: string, name: string }) { + return tx.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: data.botUserId, + email: `${data.botUserId}@bot.io`, + }, + }) +} + +export function createAdminToken(tx: Prisma.TransactionClient, data: { + name: string + permissions: bigint + expirationDate: Date | null + hash: string + userId: string +}) { + return tx.adminToken.create({ + data: { + name: data.name, + permissions: data.permissions, + expirationDate: data.expirationDate, + hash: data.hash, + userId: data.userId, + }, + select: adminTokenSelect, + }) +} + +export function revokeAdminToken(tx: Prisma.TransactionClient, id: string) { + return tx.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(), + }, + }) +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts new file mode 100644 index 0000000000..950f7f7e66 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts @@ -0,0 +1,34 @@ +import { AdminTokenSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Query, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { AdminTokenService } from './admin-token.service' + +@Controller('api/v1/admin/tokens') +@UseGuards(UserGuard) +export class AdminTokenController { + constructor(@Inject(AdminTokenService) private readonly service: AdminTokenService) {} + + @Get() + @RequireAdminPermission('ListAdminToken') + async list(@Query('withRevoked') withRevoked?: string) { + return this.service.list(withRevoked === 'true') + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireAdminPermission('ManageAdminToken') + async create( + @Body(new ZodValidationPipe(AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required())) data: { name: string, permissions: string, expirationDate: string | null }, + ) { + return this.service.create(data) + } + + @Delete(':tokenId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireAdminPermission('ManageAdminToken') + async revoke(@Param('tokenId') tokenId: string): Promise { + return this.service.revoke(tokenId) + } +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.module.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.module.ts new file mode 100644 index 0000000000..176543d961 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AdminTokenController } from './admin-token.controller' +import { AdminTokenService } from './admin-token.service' + +@Module({ + imports: [InfrastructureModule], + controllers: [AdminTokenController], + providers: [AdminTokenService], + exports: [AdminTokenService], +}) +export class AdminTokenModule {} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts new file mode 100644 index 0000000000..46cb361917 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts @@ -0,0 +1,121 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { AdminTokenService } from './admin-token.service' + +describe('adminTokenService', () => { + let module: TestingModule + let service: AdminTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AdminTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(AdminTokenService) + }) + + describe('list', () => { + it('returns active tokens with permissions serialized as string', async () => { + const tokenId = faker.string.uuid() + const userId = faker.string.uuid() + prisma.adminToken.findMany.mockResolvedValue([{ + id: tokenId, + name: 'my-token', + permissions: 4n, + lastUse: null, + expirationDate: null, + status: 'active' as const, + createdAt: new Date(), + userId, + hash: 'hash-1', + }]) + + const result = await service.list() + + expect(prisma.adminToken.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + expect(result[0].id).toBe(tokenId) + expect(result[0].permissions).toBe('4') + }) + + it('includes revoked tokens when withRevoked is true', async () => { + prisma.adminToken.findMany.mockResolvedValue([]) + + await service.list(true) + + const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0] + expect(callArgs?.where).toEqual({ status: { in: ['active', 'revoked'] } }) + }) + + it('filters to active only by default', async () => { + prisma.adminToken.findMany.mockResolvedValue([]) + + await service.list() + + const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0] + expect(callArgs?.where).toEqual({ status: 'active' }) + }) + }) + + describe('create', () => { + it('throws BadRequestException if expirationDate is too soon', async () => { + const today = new Date() + await expect(service.create({ name: 'x', permissions: '4', expirationDate: today.toISOString() })) + .rejects.toThrow('Date d\'expiration trop courte') + expect(prisma.adminToken.create).not.toHaveBeenCalled() + }) + + it('returns created token with plaintext password and serialized permissions', async () => { + const tokenId = faker.string.uuid() + const botUserId = faker.string.uuid() + prisma.user.create.mockResolvedValue({ id: botUserId, firstName: 'Bot Admin', lastName: 'my-token', type: 'bot', email: 'x@bot.io' } as never) + prisma.adminToken.create.mockResolvedValue({ + id: tokenId, + name: 'my-token', + permissions: 2n, + lastUse: null, + expirationDate: null, + status: 'active' as const, + createdAt: new Date(), + userId: botUserId, + hash: 'hash-2', + }) + + const result = await service.create({ name: 'my-token', permissions: '2', expirationDate: null }) + + expect(prisma.user.create).toHaveBeenCalled() + expect(prisma.adminToken.create).toHaveBeenCalled() + expect(result.id).toBe(tokenId) + expect(result.password).toBeTruthy() + expect(result.permissions).toBe('2') + }) + }) + + describe('revoke', () => { + it('sets status to revoked and expiration date to now', async () => { + const tokenId = faker.string.uuid() + prisma.adminToken.updateMany.mockResolvedValue({ count: 1 } as never) + + await service.revoke(tokenId) + + expect(prisma.adminToken.updateMany).toHaveBeenCalledWith({ + where: { id: tokenId }, + data: { + status: 'revoked', + expirationDate: expect.any(Date) as Date, + }, + }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts new file mode 100644 index 0000000000..bacacc66f3 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts @@ -0,0 +1,92 @@ +import { randomUUID } from 'node:crypto' +import { isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { generateTokenPair } from '../../utils/token' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { + createAdminToken, + createBotUser, + listAdminTokens, + revokeAdminToken, +} from './admin-token-queries.utils' + +@Injectable() +export class AdminTokenService { + private readonly logger = new Logger(AdminTokenService.name) + + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + @StartActiveSpan() + async list(withRevoked = false) { + const span = trace.getActiveSpan() + this.logger.debug(`adminToken.list requested (withRevoked=${withRevoked})`) + span?.setAttribute('adminToken.list.withRevoked', withRevoked) + + const tokens = await listAdminTokens(this.prisma, withRevoked) + + span?.setAttribute('adminToken.list.count', tokens.length) + this.logger.debug(`adminToken.list completed (count=${tokens.length})`) + + return tokens.map(({ permissions, ...token }) => ({ + ...token, + permissions: permissions.toString(), + })) + } + + @StartActiveSpan() + async create(data: { name: string, permissions: string, expirationDate?: string | null }) { + const span = trace.getActiveSpan() + span?.setAttribute('adminToken.create.name', data.name) + this.logger.debug(`adminToken.create started (tokenName=${data.name})`) + + const expirationDate = data.expirationDate ? z.coerce.date().parse(data.expirationDate) : null + + if (expirationDate && !isAtLeastTomorrow(expirationDate)) { + this.logger.warn(`adminToken.create rejected (tokenName=${data.name}, reason=expirationTooSoon)`) + throw new BadRequestException('Date d\'expiration trop courte') + } + + try { + const { password, hash } = await generateTokenPair() + const botUserId = randomUUID() + + await createBotUser(this.prisma, { botUserId, name: data.name }) + + const token = await createAdminToken(this.prisma, { + name: data.name, + permissions: BigInt(data.permissions), + expirationDate, + hash, + userId: botUserId, + }) + + span?.setAttribute('adminToken.create.tokenId', token.id) + this.logger.log(`adminToken.create completed (adminTokenId=${token.id}, botUserId=${botUserId}, status=${token.status})`) + return { + ...token, + password, + permissions: token.permissions.toString(), + } + } catch (error) { + this.logger.error( + `adminToken.create failed (tokenName=${data.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async revoke(id: string) { + const span = trace.getActiveSpan() + span?.setAttribute('adminToken.revoke.tokenId', id) + this.logger.debug(`adminToken.revoke started (adminTokenId=${id})`) + + await revokeAdminToken(this.prisma, id) + + this.logger.log(`adminToken.revoke completed (adminTokenId=${id})`) + } +} diff --git a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts index 0a929bd6e4..4271b4dfd0 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -16,7 +16,7 @@ export class ArgoCDHealthService { const url = new URL('/api/version', this.config.argocdUrl).toString() try { const response = await fetch(url) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts index 87346f32e5..fc50892690 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -17,7 +17,7 @@ export class GitlabHealthService { const url = new URL('/-/health', urlBase).toString() try { const response = await fetch(url) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts index 87e0e46e17..03268304db 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -16,7 +16,7 @@ export class KeycloakHealthService { try { const response = await fetch(url) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts index 8cf4223b4c..9453ee1d10 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -23,7 +23,7 @@ export class NexusHealthService { try { const response = await fetch(url, { headers }) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts index 7c0471231f..d5405f24c4 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' @@ -10,7 +10,7 @@ export interface NexusFetchOptions { } export interface NexusResponse { - status: number + status: HttpStatus data: T | null } @@ -21,7 +21,7 @@ export type NexusErrorKind export class NexusError extends Error { readonly kind: NexusErrorKind - readonly status?: number + readonly status?: HttpStatus readonly method?: string readonly path?: string readonly statusText?: string @@ -29,7 +29,7 @@ export class NexusError extends Error { constructor( kind: NexusErrorKind, message: string, - details: { status?: number, method?: string, path?: string, statusText?: string } = {}, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, ) { super(message) this.name = 'NexusError' @@ -118,7 +118,7 @@ export class NexusHttpClientService { } async function handleResponse(response: Response): Promise> { - if (response.status === 204) return { status: response.status, data: null } + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } const contentType = response.headers.get('content-type') ?? '' const parsed = contentType.includes('application/json') ? await response.json() diff --git a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts index 3cdc6d8181..f91f493f5b 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts +++ b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -21,7 +21,7 @@ export class OpenCdsHealthService { } const response = await fetch(url, { headers }) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/project/project.controller.ts b/apps/server-nestjs/src/modules/project/project.controller.ts index b42005cc37..a10afe449f 100644 --- a/apps/server-nestjs/src/modules/project/project.controller.ts +++ b/apps/server-nestjs/src/modules/project/project.controller.ts @@ -3,7 +3,7 @@ import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import { AdminAuthorized, projectContract } from '@cpn-console/shared' -import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' import { json2csv } from 'json-2-csv' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' @@ -43,7 +43,7 @@ export class ProjectController { } @Post('') - @HttpCode(201) + @HttpCode(HttpStatus.CREATED) @UseGuards(UserGuard) @RequireAdminPermission('ManageProjects') async create( @@ -64,7 +64,7 @@ export class ProjectController { } @Put('/:projectId') - @HttpCode(200) + @HttpCode(HttpStatus.OK) @UseGuards(ProjectGuard) @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectPermission('Manage') @@ -78,7 +78,7 @@ export class ProjectController { } @Delete('/:projectId') - @HttpCode(204) + @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(ProjectGuard) @RequireProjectPermission('Manage') async archive( diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts index fa3569b4ca..2a1b33454c 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -23,7 +23,7 @@ export class RegistryHealthService { try { const response = await fetch(url, { method: 'GET', headers }) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts index dc93d7588f..1d9aa95158 100644 --- a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { encodeBasicAuth } from './registry.utils' @@ -10,7 +10,7 @@ export interface RegistryFetchOptions { } export interface RegistryResponse { - status: number + status: HttpStatus data: T | null } @@ -20,7 +20,7 @@ export type RegistryErrorKind export class RegistryError extends Error { readonly kind: RegistryErrorKind - readonly status?: number + readonly status?: HttpStatus readonly method?: string readonly path?: string readonly statusText?: string @@ -28,7 +28,7 @@ export class RegistryError extends Error { constructor( kind: RegistryErrorKind, message: string, - details: { status?: number, method?: string, path?: string, statusText?: string } = {}, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, ) { super(message) this.name = 'RegistryError' @@ -104,7 +104,7 @@ export class RegistryHttpClientService { } async function handleResponse(response: Response): Promise> { - if (response.status === 204) return { status: response.status, data: null } + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } const contentType = response.headers.get('content-type') ?? '' const parsed = contentType.includes('application/json') ? await response.json() diff --git a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts index a61a7768a2..c04eae0429 100644 --- a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -1,17 +1,18 @@ import type { ProjectWithDetails } from './registry-datastore.service' import type { RegistryResponse } from './registry-http-client.service' import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' export function makeOkResponse(data: T): RegistryResponse { - return { status: 200, data } + return { status: HttpStatus.OK, data } } export function makeCreatedResponse(data: T): RegistryResponse { - return { status: 201, data } + return { status: HttpStatus.CREATED, data } } export function makeNoContent(): RegistryResponse { - return { status: 204, data: null } + return { status: HttpStatus.NO_CONTENT, data: null } } export function makeProjectWithDetails(overrides: Partial = {}) { diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 4d623d543b..05c6afea2a 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -1,5 +1,6 @@ import type { Mocked } from 'vitest' import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -164,9 +165,9 @@ describe('registryService', () => { const project = makeProjectWithDetails() registry.addGroupMember.mockImplementation(async (_projectName, body) => { if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { - return { status: 400, data: null } + return { status: HttpStatus.BAD_REQUEST, data: null } } - return { status: 201, data: null } + return { status: HttpStatus.CREATED, data: null } }) await expect(service.handleUpsert(project)).resolves.toEqual({ @@ -329,7 +330,7 @@ describe('registryService', () => { }) it('should not delete project when it does not exist', async () => { - registry.getProjectByName.mockResolvedValueOnce({ status: 404, data: null }) + registry.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) await service.handleDelete(makeProjectWithDetails()) expect(registry.deleteProjectByName).not.toHaveBeenCalled() }) diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts index 2374211ed4..b018e8d450 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -1,3 +1,4 @@ +import type { HttpStatus } from '@nestjs/common' import type { Dispatcher, HeadersInit } from 'undici' import { Inject, Injectable, Logger } from '@nestjs/common' import { Agent, fetch, Headers, ProxyAgent } from 'undici' @@ -19,7 +20,7 @@ export interface OpenCdsRequestOptions { export class OpenCdsClientError extends Error { constructor( - public readonly status: number, + public readonly status: HttpStatus, public readonly statusText: string, public readonly body?: string, ) { diff --git a/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts b/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts index 53d5db9aa2..3500ad7ec9 100644 --- a/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts +++ b/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts @@ -1,8 +1,9 @@ +import type { HttpStatus } from '@nestjs/common' import type { Response } from 'undici' export class OpenCdsClientError extends Error { constructor( - public readonly status: number, + public readonly status: HttpStatus, public readonly statusText: string, public readonly body?: string, ) { diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 870f53eebc..25f2cc5a11 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -24,7 +24,7 @@ export class SonarqubeHealthService { try { const response = await fetch(url, { headers }) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts index f1d508723e..9318e5d569 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -8,7 +8,7 @@ export interface SonarqubeFetchOptions { } export interface SonarqubeResponse { - status: number + status: HttpStatus data: T | null } @@ -16,14 +16,14 @@ export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' export class SonarqubeError extends Error { readonly kind: SonarqubeErrorKind - readonly status?: number + readonly status?: HttpStatus readonly method?: string readonly path?: string constructor( kind: SonarqubeErrorKind, message: string, - details: { status?: number, method?: string, path?: string } = {}, + details: { status?: HttpStatus, method?: string, path?: string } = {}, ) { super(message) this.name = 'SonarqubeError' @@ -99,7 +99,7 @@ function formatErrorMessage(status: number, data: unknown): string { } async function handleResponse(response: Response): Promise> { - if (response.status === 204) return { status: response.status, data: null } + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } const contentType = response.headers.get('content-type') ?? '' const parsed = contentType.includes('application/json') ? await response.json() diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.ts new file mode 100644 index 0000000000..43f2d71db0 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.ts @@ -0,0 +1,60 @@ +import type { Prisma } from '@prisma/client' + +export const userTokenOwnerSelect = { + id: true, + email: true, + firstName: true, + lastName: true, + type: true, +} satisfies Prisma.UserSelect + +export const userTokenSelect = { + id: true, + name: true, + lastUse: true, + expirationDate: true, + status: true, + createdAt: true, + userId: true, + owner: { + select: userTokenOwnerSelect, + }, +} satisfies Prisma.PersonalAccessTokenSelect + +export type UserTokenRecord = Prisma.PersonalAccessTokenGetPayload<{ + select: typeof userTokenSelect +}> + +export function listUserTokens(tx: Prisma.TransactionClient, userId: string) { + return tx.personalAccessToken.findMany({ + where: { userId }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + select: userTokenSelect, + }) +} + +export function createUserToken(tx: Prisma.TransactionClient, data: { + name: string + expirationDate: Date + hash: string + userId: string +}) { + return tx.personalAccessToken.create({ + data: { + name: data.name, + hash: data.hash, + expirationDate: data.expirationDate, + userId: data.userId, + }, + select: userTokenSelect, + }) +} + +export function getOwnedUserToken(tx: Prisma.TransactionClient, tokenId: string, userId: string) { + return tx.personalAccessToken.findUnique({ + where: { + id: tokenId, + userId, + }, + }) +} diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.ts new file mode 100644 index 0000000000..afa2005f7a --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.ts @@ -0,0 +1,45 @@ +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { PersonalAccessTokenSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Param, Post, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { UserTokensService } from './user-tokens.service' + +@Controller('api/v1/user/tokens') +@UseGuards(UserGuard) +export class UserTokensController { + constructor(@Inject(UserTokensService) private readonly service: UserTokensService) {} + + @Get() + async list(@AuthUser() user: UserContext) { + if (!user.userId || user.userType !== 'human') { + throw new ForbiddenException('Seuls les utilisateurs humains peuvent gérer des tokens personnels') + } + return this.service.list(user.userId) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @Body(new ZodValidationPipe(PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required())) data: { name: string, expirationDate: string }, + @AuthUser() user: UserContext, + ) { + if (!user.userId || user.userType !== 'human') { + throw new ForbiddenException('Seuls les utilisateurs humains peuvent gérer des tokens personnels') + } + return this.service.create(data, user.userId) + } + + @Delete(':tokenId') + @HttpCode(HttpStatus.NO_CONTENT) + async delete( + @Param('tokenId') tokenId: string, + @AuthUser() user: UserContext, + ): Promise { + if (!user.userId || user.userType !== 'human') { + throw new ForbiddenException('Seuls les utilisateurs humains peuvent gérer des tokens personnels') + } + return this.service.delete(tokenId, user.userId) + } +} diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts new file mode 100644 index 0000000000..af7a0028ba --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { UserTokensController } from './user-tokens.controller' +import { UserTokensService } from './user-tokens.service' + +@Module({ + imports: [InfrastructureModule], + controllers: [UserTokensController], + providers: [UserTokensService], + exports: [UserTokensService], +}) +export class UserTokensModule {} diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.spec.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.spec.ts new file mode 100644 index 0000000000..45f41f1e3b --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.spec.ts @@ -0,0 +1,126 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { userTokenSelect } from './user-tokens-queries.utils' +import { UserTokensService } from './user-tokens.service' + +describe('userTokensService', () => { + let module: TestingModule + let service: UserTokensService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + UserTokensService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(UserTokensService) + }) + + describe('list', () => { + it('returns user tokens ordered by status then creation date', async () => { + const userId = faker.string.uuid() + const tokenId = faker.string.uuid() + prisma.personalAccessToken.findMany.mockResolvedValue([{ + id: tokenId, + name: 'my-token', + lastUse: null, + expirationDate: new Date(), + status: 'active' as const, + createdAt: new Date(), + userId, + hash: 'hash-1', + }]) + + const result = await service.list(userId) + + expect(prisma.personalAccessToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId } }), + ) + expect(result).toHaveLength(1) + expect(result[0].id).toBe(tokenId) + }) + + it('selects only exposed token fields', async () => { + const userId = faker.string.uuid() + prisma.personalAccessToken.findMany.mockResolvedValue([]) + + await service.list(userId) + + const callArgs = prisma.personalAccessToken.findMany.mock.calls[0]?.[0] + expect(callArgs?.select).toEqual(userTokenSelect) + }) + }) + + describe('create', () => { + it('throws BadRequestException if expirationDate is too soon', async () => { + const userId = faker.string.uuid() + const today = new Date() + await expect(service.create({ name: 'x', expirationDate: today.toISOString() }, userId)) + .rejects.toThrow('Date d\'expiration trop courte') + expect(prisma.personalAccessToken.create).not.toHaveBeenCalled() + }) + + it('returns created token with plaintext password', async () => { + const userId = faker.string.uuid() + const tokenId = faker.string.uuid() + prisma.personalAccessToken.create.mockResolvedValue({ + id: tokenId, + name: 'my-token', + lastUse: null, + expirationDate: new Date(), + status: 'active' as const, + createdAt: new Date(), + userId, + hash: 'hash-2', + }) + + const result = await service.create({ name: 'my-token', expirationDate: '2099-01-01' }, userId) + + expect(prisma.personalAccessToken.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId, + name: 'my-token', + }), + select: userTokenSelect, + }), + ) + expect(result.id).toBe(tokenId) + expect(result.password).toBeTruthy() + }) + }) + + describe('delete', () => { + it('deletes token if it exists and belongs to user', async () => { + const tokenId = faker.string.uuid() + const userId = faker.string.uuid() + prisma.personalAccessToken.findUnique.mockResolvedValue({ id: tokenId, userId } as never) + prisma.personalAccessToken.delete.mockResolvedValue({ id: tokenId } as never) + + await service.delete(tokenId, userId) + + expect(prisma.personalAccessToken.findUnique).toHaveBeenCalledWith({ + where: { id: tokenId, userId }, + }) + expect(prisma.personalAccessToken.delete).toHaveBeenCalledWith({ where: { id: tokenId } }) + }) + + it('does nothing if token not found or belongs to other user', async () => { + prisma.personalAccessToken.findUnique.mockResolvedValue(null) + + await service.delete('unknown', 'user-1') + + expect(prisma.personalAccessToken.delete).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts new file mode 100644 index 0000000000..25bafade1e --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts @@ -0,0 +1,87 @@ +import { isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { generateTokenPair } from '../../utils/token' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { createUserToken, getOwnedUserToken, listUserTokens } from './user-tokens-queries.utils' + +@Injectable() +export class UserTokensService { + private readonly logger = new Logger(UserTokensService.name) + + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + @StartActiveSpan() + async list(userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('userTokens.list.userId', userId) + this.logger.log(`userTokens.list requested (userId=${userId})`) + + const tokens = await listUserTokens(this.prisma, userId) + + span?.setAttribute('userTokens.list.count', tokens.length) + this.logger.log(`userTokens.list completed (userId=${userId}, count=${tokens.length})`) + return tokens + } + + @StartActiveSpan() + async create(data: { name: string, expirationDate: string }, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('userTokens.create.name', data.name) + span?.setAttribute('userTokens.create.userId', userId) + this.logger.log(`userTokens.create started (tokenName=${data.name}, userId=${userId})`) + + const expirationDate = z.coerce.date().parse(data.expirationDate) + + if (!isAtLeastTomorrow(expirationDate)) { + this.logger.warn(`userTokens.create rejected (tokenName=${data.name}, userId=${userId}, reason=expirationTooSoon)`) + throw new BadRequestException('Date d\'expiration trop courte') + } + + try { + const { password, hash } = await generateTokenPair() + + const token = await createUserToken(this.prisma, { + ...data, + hash, + userId, + expirationDate, + }) + + span?.setAttribute('userTokens.create.tokenId', token.id) + this.logger.log(`userTokens.create completed (tokenId=${token.id}, userId=${userId})`) + + return { + ...token, + password, + } + } catch (error) { + this.logger.error( + `userTokens.create failed (tokenName=${data.name}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async delete(tokenId: string, userId: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('userTokens.delete.tokenId', tokenId) + span?.setAttribute('userTokens.delete.userId', userId) + this.logger.log(`userTokens.delete started (tokenId=${tokenId}, userId=${userId})`) + + const token = await getOwnedUserToken(this.prisma, tokenId, userId) + + if (token) { + await this.prisma.personalAccessToken.delete({ + where: { id: tokenId }, + }) + this.logger.log(`userTokens.delete completed (tokenId=${tokenId})`) + } else { + this.logger.log(`userTokens.delete skipped (tokenId=${tokenId}, reason=notFoundOrNotOwner)`) + } + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/apps/server-nestjs/src/modules/vault/vault-health.service.ts index 5db057091f..649770a558 100644 --- a/apps/server-nestjs/src/modules/vault/vault-health.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -17,7 +17,7 @@ export class VaultHealthService { const url = new URL('/v1/sys/health', urlBase).toString() try { const response = await fetch(url) - if (response.status < 500) return indicator.up({ httpStatus: response.status }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) diff --git a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts index 7af75440af..6cba06e0b5 100644 --- a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from '@nestjs/common' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import z from 'zod' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -19,7 +19,7 @@ export type VaultErrorKind export class VaultError extends Error { readonly kind: VaultErrorKind - readonly status?: number + readonly status?: HttpStatus readonly method?: string readonly path?: string readonly statusText?: string @@ -28,7 +28,7 @@ export class VaultError extends Error { constructor( kind: VaultErrorKind, message: string, - details: { status?: number, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, ) { super(message) this.name = 'VaultError' @@ -113,7 +113,7 @@ export class VaultHttpClientService { } private async handleResponse(response: Response, method: string, path: string): Promise { - if (response.status === 204) return null + if (response.status === HttpStatus.NO_CONTENT) return null if (!response.ok) { await this.throwForStatus(response, method, path) diff --git a/apps/server-nestjs/src/utils/token.spec.ts b/apps/server-nestjs/src/utils/token.spec.ts new file mode 100644 index 0000000000..5c4350df8f --- /dev/null +++ b/apps/server-nestjs/src/utils/token.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { generateTokenPair, verifyTokenHash } from './token' + +const SHA256_HEX_RE = /^[0-9a-f]{64}$/ + +describe('generateTokenPair', () => { + it('returns a plaintext token and a sha256 hex hash', async () => { + const { password, hash } = await generateTokenPair() + + expect(password).toHaveLength(48) + expect(password).toMatch(/^[a-z0-9-]+$/i) + expect(hash).toMatch(SHA256_HEX_RE) + }) + + it('honours a custom length', async () => { + const { password, hash } = await generateTokenPair(64) + + expect(password).toHaveLength(64) + expect(hash).toMatch(SHA256_HEX_RE) + }) + + it('produces distinct tokens on each call', async () => { + const first = await generateTokenPair() + const second = await generateTokenPair() + + expect(first.password).not.toBe(second.password) + expect(first.hash).not.toBe(second.hash) + }) + + it('does not embed the plaintext token in the hash', async () => { + const { password, hash } = await generateTokenPair() + + expect(hash).not.toContain(password) + }) + + it('round-trips: the generated hash verifies the plaintext token', async () => { + const { password, hash } = await generateTokenPair() + + expect(await verifyTokenHash(password, hash)).toBe(true) + }) +}) + +describe('verifyTokenHash', () => { + it('accepts a 64-char lowercase hex sha256 digest of the token', async () => { + const { createHash } = await import('node:crypto') + const token = 'some-plaintext-token' + const hash = createHash('sha256').update(token).digest('hex') + + expect(await verifyTokenHash(token, hash)).toBe(true) + }) + + it('rejects a malformed (non-hex) hash', async () => { + const token = 'some-plaintext-token' + + expect(await verifyTokenHash(token, 'not-a-valid-sha-hash')).toBe(false) + }) + + it('rejects the sha256 of a different token', async () => { + const { createHash } = await import('node:crypto') + const hash = createHash('sha256').update('other-token').digest('hex') + + expect(await verifyTokenHash('some-plaintext-token', hash)).toBe(false) + }) + + it('rejects an empty hash', async () => { + const { password } = await generateTokenPair() + + expect(await verifyTokenHash(password, '')).toBe(false) + }) + + it('rejects an unrecognised format', async () => { + const { password } = await generateTokenPair() + + expect(await verifyTokenHash(password, 'not-a-sha-hash')).toBe(false) + }) +}) diff --git a/apps/server-nestjs/src/utils/token.ts b/apps/server-nestjs/src/utils/token.ts new file mode 100644 index 0000000000..3bdcdb9c93 --- /dev/null +++ b/apps/server-nestjs/src/utils/token.ts @@ -0,0 +1,37 @@ +import { createHash, timingSafeEqual } from 'node:crypto' +import { generateRandomPassword } from '@cpn-console/shared' + +const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-' +const TOKEN_LENGTH = 48 + +export interface TokenPair { + /** Plaintext token. Returned to the caller once and never stored. */ + password: string + /** SHA-256 hex digest of the token (64 lowercase hex chars). */ + hash: string +} + +/** + * Generate a secure random access token and its SHA-256 storage hash. + * The plaintext token is shown to the caller exactly once; only the hash is + * persisted. This keeps the storage format compatible with the Fastify server + * (unsalted `sha256(token)` hex digest). + */ +export async function generateTokenPair(length: number = TOKEN_LENGTH): Promise { + const password = generateRandomPassword(length, TOKEN_ALPHABET) + const hash = createHash('sha256').update(password).digest('hex') + return { password, hash } +} + +/** + * Verify a plaintext token against a stored SHA-256 hex hash + * (64 lowercase hex chars, unsalted — the format shared with the Fastify server). + * Uses a constant-time comparison to avoid timing leaks. + */ +export async function verifyTokenHash(token: string, storedHash: string): Promise { + if (!/^[0-9a-f]{64}$/.test(storedHash)) return false + + const expected = Buffer.from(storedHash, 'hex') + const derived = createHash('sha256').update(token).digest() + return expected.length === derived.length && timingSafeEqual(expected, derived) +} diff --git a/apps/server-nestjs/test/admin-token.e2e-spec.ts b/apps/server-nestjs/test/admin-token.e2e-spec.ts new file mode 100644 index 0000000000..c60b674201 --- /dev/null +++ b/apps/server-nestjs/test/admin-token.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AdminTokenModule } from '../src/modules/admin-token/admin-token.module' +import { AdminTokenService } from '../src/modules/admin-token/admin-token.service' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' + +const canRunAdminTokenE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithAdminToken = describe.runIf(canRunAdminTokenE2E) + +describeWithAdminToken('AdminTokenService (e2e)', () => { + let moduleRef: TestingModule + let service: AdminTokenService + let prisma: PrismaService + + const createdTokenIds: string[] = [] + const createdBotUserIds: string[] = [] + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [AdminTokenModule], + }).compile() + + await moduleRef.init() + + service = moduleRef.get(AdminTokenService) + prisma = moduleRef.get(PrismaService) + }) + + afterAll(async () => { + if (prisma) { + await prisma.adminToken.deleteMany({ where: { id: { in: createdTokenIds } } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: { in: createdBotUserIds } } }).catch(() => {}) + } + + await moduleRef?.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create a bot user and issue a token with plaintext password', async () => { + const result = await service.create({ + name: `e2e-admin-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + + expect(result.id).toBeTruthy() + expect(result.password).toBeTruthy() + expect(result.password.length).toBe(48) + expect(result.name).toMatch(/^e2e-admin-/) + expect(result.status).toBe('active') + expect(result.permissions).toBe(String(1n << 16n)) + + createdTokenIds.push(result.id) + if (result.owner?.id) { + createdBotUserIds.push(result.owner.id) + } + }) + + it('should list active tokens by default and include revoked when requested', async () => { + const tokenName = `e2e-admin-${faker.string.uuid()}` + const created = await service.create({ + name: tokenName, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + const activeOnly = await service.list(false) + expect(activeOnly.some(t => t.id === created.id)).toBe(true) + expect(activeOnly.every(t => t.status === 'active')).toBe(true) + + const withRevoked = await service.list(true) + expect(withRevoked.length).toBeGreaterThanOrEqual(activeOnly.length) + }) + + it('should revoke a token and exclude it from active list', async () => { + const created = await service.create({ + name: `e2e-revoke-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + await service.revoke(created.id) + + const activeOnly = await service.list(false) + expect(activeOnly.some(t => t.id === created.id)).toBe(false) + + const withRevoked = await service.list(true) + const revokedToken = withRevoked.find(t => t.id === created.id) + expect(revokedToken).toBeTruthy() + expect(revokedToken?.status).toBe('revoked') + }) + + it('should reject creation with expiration date in the past', async () => { + const yesterday = new Date(Date.now() - 86400000).toISOString() + await expect(service.create({ + name: `e2e-expired-${faker.string.uuid()}`, + permissions: '0', + expirationDate: yesterday, + })).rejects.toThrow('Date d\'expiration trop courte') + }) + + it('should persist a sha256 hash of the password in DB', async () => { + const created = await service.create({ + name: `e2e-hash-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + const stored = await prisma.adminToken.findUniqueOrThrow({ + where: { id: created.id }, + select: { hash: true }, + }) + + expect(stored.hash).toMatch(/^[0-9a-f]{64}$/) + expect(stored.hash).not.toContain(created.password) + }) + + it('should omit hash from API responses', async () => { + const created = await service.create({ + name: `e2e-omit-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + expect(created).not.toHaveProperty('hash') + + const listed = await service.list(true) + const found = listed.find(t => t.id === created.id) + expect(found).toBeTruthy() + expect(found).not.toHaveProperty('hash') + }) +}) diff --git a/apps/server-nestjs/test/user-tokens.e2e-spec.ts b/apps/server-nestjs/test/user-tokens.e2e-spec.ts new file mode 100644 index 0000000000..1f9c6d7d2c --- /dev/null +++ b/apps/server-nestjs/test/user-tokens.e2e-spec.ts @@ -0,0 +1,175 @@ +import type { TestingModule } from '@nestjs/testing' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { UserTokensModule } from '../src/modules/user-tokens/user-tokens.module' +import { UserTokensService } from '../src/modules/user-tokens/user-tokens.service' + +const canRunUserTokensE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithUserTokens = describe.runIf(canRunUserTokensE2E) + +describeWithUserTokens('UserTokensService (e2e)', () => { + let moduleRef: TestingModule + let service: UserTokensService + let prisma: PrismaService + + const createdTokenIds: string[] = [] + const createdUserIds: string[] = [] + let ownerId: string + let otherUserId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [UserTokensModule], + }).compile() + + await moduleRef.init() + + service = moduleRef.get(UserTokensService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + otherUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + adminRoleIds: [], + }, + }) + createdUserIds.push(ownerId) + + await prisma.user.create({ + data: { + id: otherUserId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + adminRoleIds: [], + }, + }) + createdUserIds.push(otherUserId) + }) + + afterAll(async () => { + if (prisma) { + await prisma.personalAccessToken.deleteMany({ where: { id: { in: createdTokenIds } } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }).catch(() => {}) + } + + await moduleRef?.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create a personal access token with plaintext password', async () => { + const result = await service.create({ + name: `e2e-pat-${faker.string.uuid()}`, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, ownerId) + + expect(result.id).toBeTruthy() + expect(result.password).toBeTruthy() + expect(result.password.length).toBe(48) + expect(result.name).toMatch(/^e2e-pat-/) + expect(result.expirationDate).toBeTruthy() + + createdTokenIds.push(result.id) + }) + + it('should list only tokens for the requesting user', async () => { + const tokenName = `e2e-pat-list-${faker.string.uuid()}` + const created = await service.create({ + name: tokenName, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, ownerId) + createdTokenIds.push(created.id) + + const otherTokenName = `e2e-pat-other-${faker.string.uuid()}` + const otherToken = await service.create({ + name: otherTokenName, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, otherUserId) + createdTokenIds.push(otherToken.id) + + const userTokens = await service.list(ownerId) + expect(userTokens.some(t => t.id === created.id)).toBe(true) + expect(userTokens.some(t => t.id === otherToken.id)).toBe(false) + }) + + it('should hard-delete token and remove it from list', async () => { + const created = await service.create({ + name: `e2e-pat-delete-${faker.string.uuid()}`, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, ownerId) + createdTokenIds.push(created.id) + + await service.delete(created.id, ownerId) + + const userTokens = await service.list(ownerId) + expect(userTokens.some(t => t.id === created.id)).toBe(false) + + const stored = await prisma.personalAccessToken.findUnique({ where: { id: created.id } }) + expect(stored).toBeNull() + }) + + it('should not delete another user\'s token', async () => { + const otherToken = await service.create({ + name: `e2e-pat-foreign-${faker.string.uuid()}`, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, otherUserId) + createdTokenIds.push(otherToken.id) + + await service.delete(otherToken.id, ownerId) + + const stored = await prisma.personalAccessToken.findUnique({ where: { id: otherToken.id } }) + expect(stored).not.toBeNull() + }) + + it('should reject creation with expiration date in the past', async () => { + const yesterday = new Date(Date.now() - 86400000).toISOString() + await expect(service.create({ + name: `e2e-pat-expired-${faker.string.uuid()}`, + expirationDate: yesterday, + }, ownerId)).rejects.toThrow('Date d\'expiration trop courte') + }) + + it('should persist SHA256 hash of the password in DB', async () => { + const created = await service.create({ + name: `e2e-pat-hash-${faker.string.uuid()}`, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, ownerId) + createdTokenIds.push(created.id) + + const stored = await prisma.personalAccessToken.findUniqueOrThrow({ + where: { id: created.id }, + select: { hash: true }, + }) + + const expectedHash = createHash('sha256').update(created.password).digest('hex') + expect(stored.hash).toBe(expectedHash) + }) + + it('should omit hash from API responses', async () => { + const created = await service.create({ + name: `e2e-pat-omit-${faker.string.uuid()}`, + expirationDate: new Date(Date.now() + 86400000).toISOString(), + }, ownerId) + createdTokenIds.push(created.id) + + expect(created).not.toHaveProperty('hash') + + const listed = await service.list(ownerId) + const found = listed.find(t => t.id === created.id) + expect(found).toBeTruthy() + expect(found).not.toHaveProperty('hash') + }) +})