From 0cc00b5d8ec3583d7aa6d60854185508ba49e4f5 Mon Sep 17 00:00:00 2001 From: Shikanime Deva Date: Fri, 26 Jun 2026 15:03:58 +0200 Subject: [PATCH 1/5] refactor(shared): add token pair generation and expiration schema Signed-off-by: William Phetsinorath Change-Id: I00a18a853330301a735cdfcf3fb6955a6a6a6964 --- apps/server-nestjs/src/utils/schemas.ts | 8 ++++++++ packages/shared/src/schemas/index.ts | 1 + 2 files changed, 9 insertions(+) create mode 100644 apps/server-nestjs/src/utils/schemas.ts diff --git a/apps/server-nestjs/src/utils/schemas.ts b/apps/server-nestjs/src/utils/schemas.ts new file mode 100644 index 0000000000..18e34777ff --- /dev/null +++ b/apps/server-nestjs/src/utils/schemas.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const ExpirationDateSchema = z.coerce.date() + .refine((value) => { + const tomorrow = new Date(Date.now()) + tomorrow.setUTCHours(23, 59, 59, 999) + return value.getTime() > tomorrow.getTime() + }, { message: 'Date d\'expiration trop courte' }) diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 40b21bfd32..d2fa04e0d2 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,3 +1,4 @@ +export * from './_utils.js' export * from './cluster.js' export * from './config.js' export * from './deployment.js' From 318548e5fc952c1596fc580c20cc7b7d3b798325 Mon Sep 17 00:00:00 2001 From: Shikanime Deva Date: Fri, 26 Jun 2026 15:03:58 +0200 Subject: [PATCH 2/5] refactor(user-tokens): migrate from server Signed-off-by: William Phetsinorath Change-Id: Ib1b5cbbfcd8274912ed9ffb20d60cbed6a6a6964 --- apps/server-nestjs/src/main.module.ts | 8 +- .../clean-types/controller-body.types.ts | 12 ++ .../src/modules/plugin/plugin.module.spec.ts | 8 + .../project-bulk/project-bulk.controller.ts | 3 +- .../project-bulk/project-bulk.service.ts | 4 +- .../project-roles/project-roles.controller.ts | 5 +- .../modules/project/project-testing.utils.ts | 3 +- .../src/modules/project/project.controller.ts | 5 +- .../src/modules/project/project.service.ts | 7 +- .../user-tokens-queries.utils.spec.ts | 46 +++++ .../user-tokens/user-tokens-queries.utils.ts | 51 +++++ .../user-tokens/user-tokens.controller.ts | 39 ++++ .../modules/user-tokens/user-tokens.module.ts | 12 ++ .../user-tokens/user-tokens.service.spec.ts | 130 +++++++++++++ .../user-tokens/user-tokens.service.ts | 76 ++++++++ .../modules/user-tokens/user-tokens.utils.ts | 9 + .../test/user-tokens.e2e-spec.ts | 175 ++++++++++++++++++ 17 files changed, 580 insertions(+), 13 deletions(-) create mode 100644 apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts create mode 100644 apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.spec.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/modules/user-tokens/user-tokens.utils.ts create mode 100644 apps/server-nestjs/test/user-tokens.e2e-spec.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 4a03fb39d2..87ee94c27c 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -20,7 +20,9 @@ import { ProjectServicesModule } from './modules/project-services/project-servic import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' import { SystemConfigModule } from './modules/system-config/system-config.module' +import { 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' import { getDotenvPaths } from './utils/dotenv.utils' @@ -42,14 +44,16 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectBulkModule, ProjectHooksModule, ProjectMembersModule, - ProjectModule, ProjectRolesModule, ProjectSecretsModule, ProjectServicesModule, RepositoryModule, + ProjectModule, + ServiceChainModule, + SystemSettingsModule, ScheduleModule.forRoot(), SystemConfigModule, - SystemSettingsModule, + UserTokensModule, VersionModule, ], controllers: [], diff --git a/apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts b/apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts new file mode 100644 index 0000000000..ce62a5a9f7 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts @@ -0,0 +1,12 @@ +import type { + projectContract, + projectRoleContract, +} from '@cpn-console/shared' +import type { ClientInferRequest } from '@ts-rest/core' + +export type CreateProjectBody = ClientInferRequest['body'] +export type UpdateProjectBody = ClientInferRequest['body'] +export type BulkActionProjectBody = ClientInferRequest['body'] + +export type CreateProjectRoleBody = ClientInferRequest['body'] +export type PatchProjectRolesBody = ClientInferRequest['body'] diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts index 658e110dae..13d326bfd7 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -13,6 +13,14 @@ describe('pluginModule', () => { vi.stubEnv('USE_HARBOR', 'true') vi.stubEnv('USE_NEXUS', 'true') vi.stubEnv('USE_SONARQUBE', 'true') + vi.stubEnv('DB_URL', 'postgres://db') + vi.stubEnv('KEYCLOAK_DOMAIN', 'keycloak.test') + vi.stubEnv('KEYCLOAK_REALM', 'test') + vi.stubEnv('KEYCLOAK_CLIENT_ID', 'client') + vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') + vi.stubEnv('KEYCLOAK_ADMIN', 'admin') + vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') + vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://console.test/') vi.stubEnv('VAULT_TOKEN', 'test-token') vi.stubEnv('VAULT_URL', 'https://vault.test/') vi.stubEnv('GITLAB_TOKEN', 'test-token') diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts index 82874da42d..664753a1b8 100644 --- a/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -1,5 +1,6 @@ import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BulkActionProjectBody } from '../infrastructure/clean-types/controller-body.types' import { projectContract } from '@cpn-console/shared' import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' @@ -21,7 +22,7 @@ export class ProjectBulkController { @UseGuards(UserGuard) @RequireAdminPermission('Manage') async bulkAction( - @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: BulkActionProjectBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts index 5989764d17..42d8057efc 100644 --- a/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -1,4 +1,4 @@ -import type { projectContract } from '@cpn-console/shared' +import type { BulkActionProjectBody } from '../infrastructure/clean-types/controller-body.types' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -19,7 +19,7 @@ export class ProjectBulkService { @StartActiveSpan() async bulkAction( - data: typeof projectContract.bulkActionProject.body._type, + data: BulkActionProjectBody, requestorUserId?: string, requestId?: string, ): Promise { diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts index c7fb6a706d..13168d0e9f 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -1,3 +1,4 @@ +import type { CreateProjectRoleBody, PatchProjectRolesBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import { projectRoleContract } from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' @@ -30,7 +31,7 @@ export class ProjectRolesController { @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') async create( - @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: CreateProjectRoleBody, @Project() project: ProjectContext, ) { return this.projectRoles.create(project.id, body) @@ -42,7 +43,7 @@ export class ProjectRolesController { @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') async update( - @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: PatchProjectRolesBody, @Project() project: ProjectContext, ) { return this.projectRoles.update(project.id, body) diff --git a/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/apps/server-nestjs/src/modules/project/project-testing.utils.ts index 4205b88470..ccac4a8921 100644 --- a/apps/server-nestjs/src/modules/project/project-testing.utils.ts +++ b/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -1,5 +1,6 @@ import type { projectContract } from '@cpn-console/shared' import type { Prisma, ProjectMembers, User } from '@prisma/client' +import type { CreateProjectBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import type { projectSelect, ProjectWithDetails } from './project-queries.utils' import { PROJECT_PERMS } from '@cpn-console/shared' @@ -138,7 +139,7 @@ export function makeProjectWithMembersResult( return { ...project, members } as ProjectWithMembers } -export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { +export function makeCreateProjectBody(overrides: Partial = {}): CreateProjectBody { return { name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), description: faker.lorem.sentence(), diff --git a/apps/server-nestjs/src/modules/project/project.controller.ts b/apps/server-nestjs/src/modules/project/project.controller.ts index a10afe449f..e673cf3b05 100644 --- a/apps/server-nestjs/src/modules/project/project.controller.ts +++ b/apps/server-nestjs/src/modules/project/project.controller.ts @@ -1,6 +1,7 @@ import type { ProjectV2 } from '@cpn-console/shared' import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { CreateProjectBody, UpdateProjectBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import { AdminAuthorized, projectContract } from '@cpn-console/shared' import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' @@ -47,7 +48,7 @@ export class ProjectController { @UseGuards(UserGuard) @RequireAdminPermission('ManageProjects') async create( - @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: CreateProjectBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { @@ -69,7 +70,7 @@ export class ProjectController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectPermission('Manage') async update( - @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: UpdateProjectBody, @Project() project: ProjectContext, @AuthUser() user: UserContext, @Req() request: FastifyRequest, diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index cbb6a8cf1c..ec54edda49 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -2,6 +2,7 @@ import type { projectContract } from '@cpn-console/shared' import type { ConfigType } from '@nestjs/config' import type { Prisma } from '@prisma/client' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { CreateProjectBody, UpdateProjectBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' import { AdminAuthorized } from '@cpn-console/shared' import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' @@ -90,7 +91,7 @@ export class ProjectService { @StartActiveSpan() async create( - body: typeof projectContract.createProject.body._type, + body: CreateProjectBody, requestorUserId: string, requestId?: string, ): Promise { @@ -134,7 +135,7 @@ export class ProjectService { } async update( - body: typeof projectContract.updateProject.body._type, + body: UpdateProjectBody, user: UserContext, projectId: string, requestId?: string, @@ -266,7 +267,7 @@ export class ProjectService { } private stripProjectUpdateBody( - body: typeof projectContract.updateProject.body._type, + body: UpdateProjectBody, user: UserContext, project: ProjectUpdateContext, ): { effectiveData: Record, strippedKeys: string[] } { diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.spec.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.spec.ts new file mode 100644 index 0000000000..44511f6f95 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.spec.ts @@ -0,0 +1,46 @@ +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { + createUserToken, + listUserTokens, + userTokenSelect, +} from './user-tokens-queries.utils' + +describe('user-tokens-queries.utils', () => { + let tx: DeepMockProxy + + beforeEach(() => { + tx = mockDeep() + }) + + describe('listUserTokens', () => { + it('scopes by userId and uses the shared select', async () => { + const userId = faker.string.uuid() + tx.personalAccessToken.findMany.mockResolvedValue([]) + + await listUserTokens(tx, userId) + + expect(tx.personalAccessToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId }, select: userTokenSelect }), + ) + }) + }) + + describe('createUserToken', () => { + it('creates a personal access token with the provided fields', async () => { + const data = { + name: faker.word.noun(), + expirationDate: faker.date.future(), + hash: faker.string.alphanumeric(64), + userId: faker.string.uuid(), + } + + await createUserToken(tx, data) + + expect(tx.personalAccessToken.create).toHaveBeenCalledWith({ data, select: userTokenSelect }) + }) + }) +}) 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..a9ba48fadb --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.ts @@ -0,0 +1,51 @@ +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, + }) +} 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..e4811a1d85 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.ts @@ -0,0 +1,39 @@ +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { CreatePersonalAccessTokenBody } from './user-tokens.utils' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireUserType } from '../infrastructure/permission/user/user-type.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { UserTokensService } from './user-tokens.service' +import { CreatePersonalAccessTokenBodySchema } from './user-tokens.utils' + +@Controller('api/v1/user/tokens') +@UseGuards(UserGuard) +@RequireUserType('human') +export class UserTokensController { + constructor(@Inject(UserTokensService) private readonly service: UserTokensService) {} + + @Get() + async list(@AuthUser() user: UserContext) { + return this.service.list(user.userId) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @Body(new ZodValidationPipe(CreatePersonalAccessTokenBodySchema)) data: CreatePersonalAccessTokenBody, + @AuthUser() user: UserContext, + ) { + return this.service.create(data, user.userId) + } + + @Delete(':tokenId') + @HttpCode(HttpStatus.NO_CONTENT) + async delete( + @Param('tokenId', ParseUUIDPipe) tokenId: string, + @AuthUser() user: UserContext, + ): Promise { + 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..7570ea7817 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.spec.ts @@ -0,0 +1,130 @@ +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' +import { CreatePersonalAccessTokenBodySchema } from './user-tokens.utils' + +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: faker.date.future(), + status: 'active' as const, + createdAt: faker.date.past(), + 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('rejects a non-parseable expirationDate via the body schema', () => { + const result = CreatePersonalAccessTokenBodySchema.safeParse({ name: 'x', expirationDate: 'not-a-date' }) + expect(result.success).toBe(false) + }) + + it('rejects an expirationDate that is too soon via the body schema', () => { + const today = faker.date.recent() + const result = CreatePersonalAccessTokenBodySchema.safeParse({ name: 'x', expirationDate: today.toISOString() }) + expect(result.success).toBe(false) + }) + + 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: faker.date.future(), + status: 'active' as const, + createdAt: faker.date.past(), + userId, + hash: 'hash-2', + }) + + const result = await service.create({ name: 'my-token', expirationDate: faker.date.future() }, 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 scoped to its owner in a single atomic call', async () => { + const tokenId = faker.string.uuid() + const userId = faker.string.uuid() + prisma.personalAccessToken.deleteMany.mockResolvedValue({ count: 1 }) + + await service.delete(tokenId, userId) + + expect(prisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: tokenId, userId }, + }) + }) + + it('no-ops (count 0) when token is missing or belongs to another user', async () => { + prisma.personalAccessToken.deleteMany.mockResolvedValue({ count: 0 }) + + await service.delete('unknown', 'user-1') + + expect(prisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + where: { id: 'unknown', userId: 'user-1' }, + }) + }) + }) +}) 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..21130bccdf --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts @@ -0,0 +1,76 @@ +import type { CreatePersonalAccessTokenBody } from './user-tokens.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { generateTokenPair } from '../../utils/crypto.utils' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { createUserToken, 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: CreatePersonalAccessTokenBody, 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})`) + + try { + const { password, hash } = generateTokenPair() + + const token = await createUserToken(this.prisma, { + ...data, + hash, + userId, + }) + + 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 { count } = await this.prisma.personalAccessToken.deleteMany({ + where: { id: tokenId, userId }, + }) + + if (count > 0) { + 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/user-tokens/user-tokens.utils.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts new file mode 100644 index 0000000000..d90189e960 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts @@ -0,0 +1,9 @@ +import type { z } from 'zod' +import { PersonalAccessTokenSchema } from '@cpn-console/shared' +import { ExpirationDateSchema } from '../../utils/schemas.js' + +export const CreatePersonalAccessTokenBodySchema = PersonalAccessTokenSchema + .pick({ name: true, expirationDate: true }) + .extend({ expirationDate: ExpirationDateSchema }) + .required() +export type CreatePersonalAccessTokenBody = z.infer 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..10cd2ff95b --- /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), + }, 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), + }, 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), + }, 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), + }, 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), + }, 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) + 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), + }, 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), + }, 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') + }) +}) From 50bbb02e1e2ed6bb394ed30c3bdaa63c21d127d0 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 24 Aug 2026 11:09:28 +0200 Subject: [PATCH 3/5] test(user-tokens): assert orderBy status then createdAt in list Signed-off-by: William Phetsinorath Change-Id: I8b4e24d113e792ba252b2af3c5411a5a6a6a6964 --- .../src/modules/user-tokens/user-tokens.service.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 7570ea7817..7d62b51572 100644 --- 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 @@ -45,7 +45,10 @@ describe('userTokensService', () => { const result = await service.list(userId) expect(prisma.personalAccessToken.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { userId } }), + expect.objectContaining({ + where: { userId }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + }), ) expect(result).toHaveLength(1) expect(result[0].id).toBe(tokenId) From 3a6fbc99f8d615b062f50e5568a6bfbc837fc39b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 3 Sep 2026 11:02:22 +0200 Subject: [PATCH 4/5] refactor(user-tokens): realign module with server-nestjs conventions Signed-off-by: William Phetsinorath Change-Id: I054a5dffba3e6e1d647f1dcfbf7e1ff16a6a6964 --- apps/server-nestjs/src/main.module.ts | 6 +-- .../admin-role/admin-role-testing.utils.ts | 1 - .../clean-types/controller-body.types.ts | 12 ----- .../src/modules/plugin/plugin.module.spec.ts | 8 ---- .../project-bulk/project-bulk.controller.ts | 3 +- .../project-bulk/project-bulk.service.ts | 4 +- .../project-roles/project-roles.controller.ts | 5 +-- .../modules/project/project-testing.utils.ts | 3 +- .../src/modules/project/project.controller.ts | 5 +-- .../src/modules/project/project.service.ts | 7 ++- .../user-tokens/user-tokens-queries.utils.ts | 7 +++ .../modules/user-tokens/user-tokens.module.ts | 6 ++- .../user-tokens/user-tokens.service.spec.ts | 40 ++++++++++++----- .../user-tokens/user-tokens.service.ts | 13 +++--- .../modules/user-tokens/user-tokens.utils.ts | 8 ++-- apps/server-nestjs/src/utils/schemas.ts | 8 ---- .../test/user-tokens.e2e-spec.ts | 45 +++++++++++-------- packages/shared/src/schemas/index.ts | 1 - 18 files changed, 91 insertions(+), 91 deletions(-) delete mode 100644 apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts delete mode 100644 apps/server-nestjs/src/utils/schemas.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 87ee94c27c..1e66f12ee8 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -20,7 +20,6 @@ import { ProjectServicesModule } from './modules/project-services/project-servic import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' import { SystemConfigModule } from './modules/system-config/system-config.module' -import { 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' @@ -44,15 +43,14 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectBulkModule, ProjectHooksModule, ProjectMembersModule, + ProjectModule, ProjectRolesModule, ProjectSecretsModule, ProjectServicesModule, RepositoryModule, - ProjectModule, - ServiceChainModule, - SystemSettingsModule, ScheduleModule.forRoot(), SystemConfigModule, + SystemSettingsModule, UserTokensModule, VersionModule, ], diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts index 89bc4d85e8..1b7f74ee83 100644 --- a/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts +++ b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts @@ -1,5 +1,4 @@ import type { AdminRole } from './admin-role-queries.utils' -import type { AdminRoleService } from './admin-role.service' import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils' export function makeAdminRole(overrides: Partial = {}): AdminRole { diff --git a/apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts b/apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts deleted file mode 100644 index ce62a5a9f7..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/clean-types/controller-body.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - projectContract, - projectRoleContract, -} from '@cpn-console/shared' -import type { ClientInferRequest } from '@ts-rest/core' - -export type CreateProjectBody = ClientInferRequest['body'] -export type UpdateProjectBody = ClientInferRequest['body'] -export type BulkActionProjectBody = ClientInferRequest['body'] - -export type CreateProjectRoleBody = ClientInferRequest['body'] -export type PatchProjectRolesBody = ClientInferRequest['body'] diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts index 13d326bfd7..658e110dae 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -13,14 +13,6 @@ describe('pluginModule', () => { vi.stubEnv('USE_HARBOR', 'true') vi.stubEnv('USE_NEXUS', 'true') vi.stubEnv('USE_SONARQUBE', 'true') - vi.stubEnv('DB_URL', 'postgres://db') - vi.stubEnv('KEYCLOAK_DOMAIN', 'keycloak.test') - vi.stubEnv('KEYCLOAK_REALM', 'test') - vi.stubEnv('KEYCLOAK_CLIENT_ID', 'client') - vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') - vi.stubEnv('KEYCLOAK_ADMIN', 'admin') - vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') - vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://console.test/') vi.stubEnv('VAULT_TOKEN', 'test-token') vi.stubEnv('VAULT_URL', 'https://vault.test/') vi.stubEnv('GITLAB_TOKEN', 'test-token') diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts index 664753a1b8..82874da42d 100644 --- a/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -1,6 +1,5 @@ import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' -import type { BulkActionProjectBody } from '../infrastructure/clean-types/controller-body.types' import { projectContract } from '@cpn-console/shared' import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' @@ -22,7 +21,7 @@ export class ProjectBulkController { @UseGuards(UserGuard) @RequireAdminPermission('Manage') async bulkAction( - @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: BulkActionProjectBody, + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts index 42d8057efc..5989764d17 100644 --- a/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -1,4 +1,4 @@ -import type { BulkActionProjectBody } from '../infrastructure/clean-types/controller-body.types' +import type { projectContract } from '@cpn-console/shared' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -19,7 +19,7 @@ export class ProjectBulkService { @StartActiveSpan() async bulkAction( - data: BulkActionProjectBody, + data: typeof projectContract.bulkActionProject.body._type, requestorUserId?: string, requestId?: string, ): Promise { diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts index 13168d0e9f..c7fb6a706d 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -1,4 +1,3 @@ -import type { CreateProjectRoleBody, PatchProjectRolesBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import { projectRoleContract } from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' @@ -31,7 +30,7 @@ export class ProjectRolesController { @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') async create( - @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: CreateProjectRoleBody, + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, @Project() project: ProjectContext, ) { return this.projectRoles.create(project.id, body) @@ -43,7 +42,7 @@ export class ProjectRolesController { @RequireProjectLocked(false) @RequireProjectPermission('ManageRoles') async update( - @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: PatchProjectRolesBody, + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, @Project() project: ProjectContext, ) { return this.projectRoles.update(project.id, body) diff --git a/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/apps/server-nestjs/src/modules/project/project-testing.utils.ts index ccac4a8921..4205b88470 100644 --- a/apps/server-nestjs/src/modules/project/project-testing.utils.ts +++ b/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -1,6 +1,5 @@ import type { projectContract } from '@cpn-console/shared' import type { Prisma, ProjectMembers, User } from '@prisma/client' -import type { CreateProjectBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import type { projectSelect, ProjectWithDetails } from './project-queries.utils' import { PROJECT_PERMS } from '@cpn-console/shared' @@ -139,7 +138,7 @@ export function makeProjectWithMembersResult( return { ...project, members } as ProjectWithMembers } -export function makeCreateProjectBody(overrides: Partial = {}): CreateProjectBody { +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(), diff --git a/apps/server-nestjs/src/modules/project/project.controller.ts b/apps/server-nestjs/src/modules/project/project.controller.ts index e673cf3b05..a10afe449f 100644 --- a/apps/server-nestjs/src/modules/project/project.controller.ts +++ b/apps/server-nestjs/src/modules/project/project.controller.ts @@ -1,7 +1,6 @@ import type { ProjectV2 } from '@cpn-console/shared' import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' -import type { CreateProjectBody, UpdateProjectBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' import { AdminAuthorized, projectContract } from '@cpn-console/shared' import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' @@ -48,7 +47,7 @@ export class ProjectController { @UseGuards(UserGuard) @RequireAdminPermission('ManageProjects') async create( - @Body(new ZodValidationPipe(projectContract.createProject.body)) body: CreateProjectBody, + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { @@ -70,7 +69,7 @@ export class ProjectController { @RequireProjectStatus('initializing', 'created', 'failed', 'warning') @RequireProjectPermission('Manage') async update( - @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: UpdateProjectBody, + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, @Project() project: ProjectContext, @AuthUser() user: UserContext, @Req() request: FastifyRequest, diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index ec54edda49..cbb6a8cf1c 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -2,7 +2,6 @@ import type { projectContract } from '@cpn-console/shared' import type { ConfigType } from '@nestjs/config' import type { Prisma } from '@prisma/client' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' -import type { CreateProjectBody, UpdateProjectBody } from '../infrastructure/clean-types/controller-body.types' import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' import { AdminAuthorized } from '@cpn-console/shared' import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' @@ -91,7 +90,7 @@ export class ProjectService { @StartActiveSpan() async create( - body: CreateProjectBody, + body: typeof projectContract.createProject.body._type, requestorUserId: string, requestId?: string, ): Promise { @@ -135,7 +134,7 @@ export class ProjectService { } async update( - body: UpdateProjectBody, + body: typeof projectContract.updateProject.body._type, user: UserContext, projectId: string, requestId?: string, @@ -267,7 +266,7 @@ export class ProjectService { } private stripProjectUpdateBody( - body: UpdateProjectBody, + body: typeof projectContract.updateProject.body._type, user: UserContext, project: ProjectUpdateContext, ): { effectiveData: Record, strippedKeys: string[] } { 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 index a9ba48fadb..ba2d3e6115 100644 --- 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 @@ -49,3 +49,10 @@ export function createUserToken(tx: Prisma.TransactionClient, data: { select: userTokenSelect, }) } + +export function deleteUserToken(tx: Prisma.TransactionClient, where: { + id: string + userId: string +}) { + return tx.personalAccessToken.deleteMany({ where }) +} 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 index af7a0028ba..bb068669d7 100644 --- a/apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { UserTokensController } from './user-tokens.controller' import { UserTokensService } from './user-tokens.service' @Module({ - imports: [InfrastructureModule], + imports: [AuthModule, DatabaseModule, UserPermissionModule], controllers: [UserTokensController], providers: [UserTokensService], exports: [UserTokensService], 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 index 7d62b51572..9547729b1f 100644 --- 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 @@ -33,13 +33,13 @@ describe('userTokensService', () => { const tokenId = faker.string.uuid() prisma.personalAccessToken.findMany.mockResolvedValue([{ id: tokenId, - name: 'my-token', + name: faker.word.noun(), lastUse: null, expirationDate: faker.date.future(), status: 'active' as const, createdAt: faker.date.past(), userId, - hash: 'hash-1', + hash: faker.string.alphanumeric(64), }]) const result = await service.list(userId) @@ -60,44 +60,48 @@ describe('userTokensService', () => { await service.list(userId) - const callArgs = prisma.personalAccessToken.findMany.mock.calls[0]?.[0] - expect(callArgs?.select).toEqual(userTokenSelect) + expect(prisma.personalAccessToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: userTokenSelect }), + ) }) }) describe('create', () => { it('rejects a non-parseable expirationDate via the body schema', () => { - const result = CreatePersonalAccessTokenBodySchema.safeParse({ name: 'x', expirationDate: 'not-a-date' }) + const name = faker.word.noun() + const result = CreatePersonalAccessTokenBodySchema.safeParse({ name, expirationDate: 'not-a-date' }) expect(result.success).toBe(false) }) it('rejects an expirationDate that is too soon via the body schema', () => { + const name = faker.word.noun() const today = faker.date.recent() - const result = CreatePersonalAccessTokenBodySchema.safeParse({ name: 'x', expirationDate: today.toISOString() }) + const result = CreatePersonalAccessTokenBodySchema.safeParse({ name, expirationDate: today.toISOString() }) expect(result.success).toBe(false) }) it('returns created token with plaintext password', async () => { const userId = faker.string.uuid() const tokenId = faker.string.uuid() + const tokenName = faker.word.noun() prisma.personalAccessToken.create.mockResolvedValue({ id: tokenId, - name: 'my-token', + name: tokenName, lastUse: null, expirationDate: faker.date.future(), status: 'active' as const, createdAt: faker.date.past(), userId, - hash: 'hash-2', + hash: faker.string.alphanumeric(64), }) - const result = await service.create({ name: 'my-token', expirationDate: faker.date.future() }, userId) + const result = await service.create({ name: tokenName, expirationDate: faker.date.future() }, userId) expect(prisma.personalAccessToken.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId, - name: 'my-token', + name: tokenName, }), select: userTokenSelect, }), @@ -105,6 +109,16 @@ describe('userTokensService', () => { expect(result.id).toBe(tokenId) expect(result.password).toBeTruthy() }) + + it('rejects an expirationDate that is not at least tomorrow', async () => { + const userId = faker.string.uuid() + const tokenName = faker.word.noun() + + await expect( + service.create({ name: tokenName, expirationDate: faker.date.past() }, userId), + ).rejects.toThrow('Date d\'expiration trop courte') + expect(prisma.personalAccessToken.create).not.toHaveBeenCalled() + }) }) describe('delete', () => { @@ -121,12 +135,14 @@ describe('userTokensService', () => { }) it('no-ops (count 0) when token is missing or belongs to another user', async () => { + const tokenId = faker.string.uuid() + const userId = faker.string.uuid() prisma.personalAccessToken.deleteMany.mockResolvedValue({ count: 0 }) - await service.delete('unknown', 'user-1') + await service.delete(tokenId, userId) expect(prisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ - where: { id: 'unknown', userId: 'user-1' }, + where: { id: tokenId, userId }, }) }) }) 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 index 21130bccdf..78a446bb23 100644 --- a/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts @@ -1,10 +1,11 @@ import type { CreatePersonalAccessTokenBody } from './user-tokens.utils' -import { Inject, Injectable, Logger } from '@nestjs/common' +import { isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { generateTokenPair } from '../../utils/crypto.utils' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' -import { createUserToken, listUserTokens } from './user-tokens-queries.utils' +import { createUserToken, deleteUserToken, listUserTokens } from './user-tokens-queries.utils' @Injectable() export class UserTokensService { @@ -32,6 +33,10 @@ export class UserTokensService { span?.setAttribute('userTokens.create.userId', userId) this.logger.log(`userTokens.create started (tokenName=${data.name}, userId=${userId})`) + if (!isAtLeastTomorrow(data.expirationDate)) { + throw new BadRequestException('Date d\'expiration trop courte') + } + try { const { password, hash } = generateTokenPair() @@ -63,9 +68,7 @@ export class UserTokensService { span?.setAttribute('userTokens.delete.userId', userId) this.logger.log(`userTokens.delete started (tokenId=${tokenId}, userId=${userId})`) - const { count } = await this.prisma.personalAccessToken.deleteMany({ - where: { id: tokenId, userId }, - }) + const { count } = await deleteUserToken(this.prisma, { id: tokenId, userId }) if (count > 0) { this.logger.log(`userTokens.delete completed (tokenId=${tokenId})`) diff --git a/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts index d90189e960..80ed512b32 100644 --- a/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts @@ -1,6 +1,8 @@ -import type { z } from 'zod' -import { PersonalAccessTokenSchema } from '@cpn-console/shared' -import { ExpirationDateSchema } from '../../utils/schemas.js' +import { isAtLeastTomorrow, PersonalAccessTokenSchema } from '@cpn-console/shared' +import { z } from 'zod' + +export const ExpirationDateSchema = z.coerce.date() + .refine(value => isAtLeastTomorrow(value), { message: 'Date d\'expiration trop courte' }) export const CreatePersonalAccessTokenBodySchema = PersonalAccessTokenSchema .pick({ name: true, expirationDate: true }) diff --git a/apps/server-nestjs/src/utils/schemas.ts b/apps/server-nestjs/src/utils/schemas.ts deleted file mode 100644 index 18e34777ff..0000000000 --- a/apps/server-nestjs/src/utils/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod' - -export const ExpirationDateSchema = z.coerce.date() - .refine((value) => { - const tomorrow = new Date(Date.now()) - tomorrow.setUTCHours(23, 59, 59, 999) - return value.getTime() > tomorrow.getTime() - }, { message: 'Date d\'expiration trop courte' }) diff --git a/apps/server-nestjs/test/user-tokens.e2e-spec.ts b/apps/server-nestjs/test/user-tokens.e2e-spec.ts index 10cd2ff95b..bae4d48beb 100644 --- a/apps/server-nestjs/test/user-tokens.e2e-spec.ts +++ b/apps/server-nestjs/test/user-tokens.e2e-spec.ts @@ -1,13 +1,20 @@ import type { TestingModule } from '@nestjs/testing' import { createHash } from 'node:crypto' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { UserTokensModule } from '../src/modules/user-tokens/user-tokens.module' import { UserTokensService } from '../src/modules/user-tokens/user-tokens.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' -const canRunUserTokensE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) +const canRunUserTokensE2E = Boolean(process.env.E2E) const describeWithUserTokens = describe.runIf(canRunUserTokensE2E) @@ -23,7 +30,7 @@ describeWithUserTokens('UserTokensService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [UserTokensModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, LoggerModule, PermissionModule, UserTokensModule], }).compile() await moduleRef.init() @@ -72,31 +79,31 @@ describeWithUserTokens('UserTokensService (e2e)', () => { 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), + name: faker.helpers.slugify(`e2e-pat-${faker.string.uuid()}`), + expirationDate: faker.date.future(), }, ownerId) expect(result.id).toBeTruthy() expect(result.password).toBeTruthy() expect(result.password.length).toBe(48) - expect(result.name).toMatch(/^e2e-pat-/) + expect(result.name).toBeTruthy() 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 tokenName = faker.helpers.slugify(`e2e-pat-list-${faker.string.uuid()}`) const created = await service.create({ name: tokenName, - expirationDate: new Date(Date.now() + 86400000), + expirationDate: faker.date.future(), }, ownerId) createdTokenIds.push(created.id) - const otherTokenName = `e2e-pat-other-${faker.string.uuid()}` + const otherTokenName = faker.helpers.slugify(`e2e-pat-other-${faker.string.uuid()}`) const otherToken = await service.create({ name: otherTokenName, - expirationDate: new Date(Date.now() + 86400000), + expirationDate: faker.date.future(), }, otherUserId) createdTokenIds.push(otherToken.id) @@ -107,8 +114,8 @@ describeWithUserTokens('UserTokensService (e2e)', () => { 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), + name: faker.helpers.slugify(`e2e-pat-delete-${faker.string.uuid()}`), + expirationDate: faker.date.future(), }, ownerId) createdTokenIds.push(created.id) @@ -123,8 +130,8 @@ describeWithUserTokens('UserTokensService (e2e)', () => { 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), + name: faker.helpers.slugify(`e2e-pat-foreign-${faker.string.uuid()}`), + expirationDate: faker.date.future(), }, otherUserId) createdTokenIds.push(otherToken.id) @@ -135,17 +142,17 @@ describeWithUserTokens('UserTokensService (e2e)', () => { }) it('should reject creation with expiration date in the past', async () => { - const yesterday = new Date(Date.now() - 86400000) + const yesterday = faker.date.past() await expect(service.create({ - name: `e2e-pat-expired-${faker.string.uuid()}`, + name: faker.helpers.slugify(`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), + name: faker.helpers.slugify(`e2e-pat-hash-${faker.string.uuid()}`), + expirationDate: faker.date.future(), }, ownerId) createdTokenIds.push(created.id) @@ -160,8 +167,8 @@ describeWithUserTokens('UserTokensService (e2e)', () => { 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), + name: faker.helpers.slugify(`e2e-pat-omit-${faker.string.uuid()}`), + expirationDate: faker.date.future(), }, ownerId) createdTokenIds.push(created.id) diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index d2fa04e0d2..40b21bfd32 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,4 +1,3 @@ -export * from './_utils.js' export * from './cluster.js' export * from './config.js' export * from './deployment.js' From a13636bf9dcdc1d5d6e5a7096282b4f7c124b401 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 9 Sep 2026 17:38:02 +0200 Subject: [PATCH 5/5] test(gitlab): pin mirror token age in rotation spec Signed-off-by: William Phetsinorath Change-Id: I1262665a2090906637cf49c90f51fe606a6a6964 --- apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index 33b81f4f48..4b6bc1de5a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -589,7 +589,8 @@ describe('gitlabService', () => { const staleSecret = makeVaultSecret({ data: { MIRROR_USER: accessToken.name, MIRROR_TOKEN: accessToken.token }, metadata: { - created_time: faker.date.past({ years: 2 }).toISOString(), + // Birthday-style age, always decades past the 250d rotation threshold. + created_time: faker.date.birthdate({ min: 45, max: 55, mode: 'age' }).toISOString(), custom_metadata: null, deletion_time: '', destroyed: false,