From 137af883b22364da386b45d9513ef58407231e6b Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:56:06 +0200 Subject: [PATCH] refactor(user-tokens): map records to contract in controller Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I241a395b8533631173b04f556b97bf666a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/client/src/stores/token.ts | 4 +- .../views/profile/PersonalAccessTokens.vue | 2 +- apps/server-nestjs/src/main.module.ts | 2 + .../src/modules/gitlab/gitlab.service.ts | 4 +- .../src/modules/gitlab/gitlab.utils.ts | 4 - .../auth/dso-token/dso-token.service.spec.ts | 4 +- .../auth/dso-token/dso-token.service.ts | 4 +- .../src/modules/registry/registry.service.ts | 7 +- .../user-tokens-queries.utils.spec.ts | 60 ++++++ .../user-tokens/user-tokens-queries.utils.ts | 58 ++++++ .../user-tokens.controller.spec.ts | 113 +++++++++++ .../user-tokens/user-tokens.controller.ts | 42 +++++ .../modules/user-tokens/user-tokens.module.ts | 13 ++ .../user-tokens/user-tokens.service.spec.ts | 126 +++++++++++++ .../user-tokens/user-tokens.service.ts | 66 +++++++ .../modules/user-tokens/user-tokens.utils.ts | 21 +++ apps/server-nestjs/src/utils/crypto.utils.ts | 14 +- .../test/user-tokens.e2e-spec.ts | 175 ++++++++++++++++++ .../src/contracts/personal-access-token.ts | 7 +- packages/shared/src/schemas/token.spec.ts | 35 +++- packages/shared/src/schemas/token.ts | 6 + packages/shared/src/utils/date.ts | 18 ++ packages/shared/src/utils/functions.ts | 6 +- 23 files changed, 762 insertions(+), 29 deletions(-) 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.spec.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/client/src/stores/token.ts b/apps/client/src/stores/token.ts index d19b832333..1af589aeef 100644 --- a/apps/client/src/stores/token.ts +++ b/apps/client/src/stores/token.ts @@ -1,6 +1,6 @@ import type { + CreatePersonalAccessTokenRequest, PersonalAccessToken, - personalAccessTokenContract, } from '@cpn-console/shared' import { defineStore } from 'pinia' import { apiClient, extractData } from '../api/xhr-client.js' @@ -13,7 +13,7 @@ export const useTokenStore = defineStore('token', () => { } const createPersonalAccessToken = async ( - body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, + body: CreatePersonalAccessTokenRequest, ) => { return apiClient.PersonalAccessTokens.createPersonalAccessToken({ body, diff --git a/apps/client/src/views/profile/PersonalAccessTokens.vue b/apps/client/src/views/profile/PersonalAccessTokens.vue index f97b54cb59..96b38a5118 100644 --- a/apps/client/src/views/profile/PersonalAccessTokens.vue +++ b/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -38,7 +38,7 @@ async function getAllTokens() { const formProps = ref<{ exposedToken?: string }>({ exposedToken: undefined }) async function createToken(token: SimpleToken) { - const exposedToken = await tokenStore.createPersonalAccessToken(token) + const exposedToken = await tokenStore.createPersonalAccessToken({ ...token, expirationDate: new Date(token.expirationDate) }) formProps.value.exposedToken = exposedToken.password getAllTokens() } diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0fdc0dce02..d46e8e6146 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -25,6 +25,7 @@ import { StageModule } from './modules/stage/stage.module' import { SystemConfigModule } from './modules/system-config/system-config.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { UserModule } from './modules/user/user.module' +import { UserTokensModule } from './modules/user-tokens/user-tokens.module' import { VersionModule } from './modules/version/version.module' import { ZoneModule } from './modules/zone/zone.module' import { getDotenvPaths } from './utils/dotenv.utils' @@ -60,6 +61,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' SystemConfigModule, SystemSettingsModule, UserModule, + UserTokensModule, VersionModule, ZoneModule, ], diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 2b54ae6062..cc0d41d40d 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -12,6 +12,7 @@ import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { gitlabConfigFactory } from '../../config/gitlab.config' import { getAll } from '../../utils/iterable.utils' +import { daysAgo } from '@cpn-console/shared' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -35,7 +36,6 @@ import { } from './gitlab.constants' import { adminRoleFlag, - daysAgoFromNow, generateAccessLevelMapping, generateAdminRoleMapping, generateName, @@ -530,7 +530,7 @@ export class GitlabService { const createdTimeRaw = vaultSecret?.metadata?.created_time if (!createdTimeRaw) return false const createdTime = new Date(createdTimeRaw) - return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays + return daysAgo(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays } @StartActiveSpan() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts index 63853a9123..482c33bc1d 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -241,10 +241,6 @@ export function isDeclaredRepo(project: ProjectWithDetails, repo: ProjectSchema) export function getProjectPluginConfig(project: ProjectWithDetails, key: string) { return project.plugins?.find(p => p.key === key)?.value } -export function daysAgoFromNow(date: Date) { - return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) -} - export function adminRoleFlag(user: Pick, adminRoleIds: string[]): boolean | undefined { if (adminRoleIds.length === 0) return undefined return user.adminRoleIds?.some(id => adminRoleIds.includes(id)) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts index f9ab618442..4e8afa0bbb 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -1,11 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' -import { createHash } from 'node:crypto' import { faker } from '@faker-js/faker' import { UnauthorizedException } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { hashToken } from '../../../../utils/crypto.utils' import { PrismaService } from '../../database/prisma.service' import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils' import { DsoTokenService } from './dso-token.service' @@ -40,7 +40,7 @@ describe('dsoTokenService', () => { const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true }) expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }), + expect.objectContaining({ where: { hash: hashToken(rawToken) } }), ) expect(result).toBeDefined() if (result?.kind !== 'personal') throw new Error('Expected personal token result') diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts index 1e27c96d63..90e2b22a75 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts @@ -2,9 +2,9 @@ import type { FastifyRequest } from 'fastify' import type { UserContext } from '../auth-user.decorator' import type { AuthProvider, AuthRequirements } from '../auth.utils' import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils' -import { createHash } from 'node:crypto' import { tokenHeaderName } from '@cpn-console/shared' import { Inject, Injectable, Logger } from '@nestjs/common' +import { hashToken } from '../../../../utils/crypto.utils' import { PrismaService } from '../../database/prisma.service' import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils' @@ -54,7 +54,7 @@ export class DsoTokenService implements AuthProvider { requirements: Required, ): Promise { this.logger.debug(`validateToken started`) - const hash = createHash('sha256').update(rawToken).digest('hex') + const hash = hashToken(rawToken) const result = await this.findAndValidateToken(hash, requirements) if (!result) { this.logger.warn(`validateToken token not found`) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index bd0ce56dac..d553efbcb5 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -18,6 +18,7 @@ import { trace } from '@opentelemetry/api' import { baseConfigFactory } from '../../config/base.config' import { harborConfigFactory } from '../../config/harbor.config' import { find } from '../../utils/iterable.utils' +import { daysAgo } from '@cpn-console/shared' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -138,7 +139,7 @@ export class RegistryService { const createdTimeRaw = vaultSecret?.metadata?.created_time if (!createdTimeRaw) return false const createdTime = new Date(createdTimeRaw) - return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays + return daysAgo(createdTime) > this.harborConfig.robotRotationThresholdDays } private async ensureProjectGroupMember( @@ -427,10 +428,6 @@ function generateHarborAccessLevelMapping(args: { guest: string[], developer: st return byGroupName } -function daysAgoFromNow(date: Date) { - return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) -} - function generateRobotFullName(project: ProjectWithDetails, robotName: string) { return `robot$${project.slug}+${robotName}` } 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..5df773e6df --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.spec.ts @@ -0,0 +1,60 @@ +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, + deleteUserToken, + 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 }) + }) + }) + + describe('deleteUserToken', () => { + it('scopes the delete to both id and userId', async () => { + const id = faker.string.uuid() + const userId = faker.string.uuid() + tx.personalAccessToken.deleteMany.mockResolvedValue({ count: 1 }) + + const result = await deleteUserToken(tx, { id, userId }) + + expect(result).toEqual({ count: 1 }) + expect(tx.personalAccessToken.deleteMany).toHaveBeenCalledWith({ where: { id, userId } }) + }) + }) +}) 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..612af7a587 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens-queries.utils.ts @@ -0,0 +1,58 @@ +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 UserToken = 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 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.controller.spec.ts b/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.spec.ts new file mode 100644 index 0000000000..bd60b0cfce --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.spec.ts @@ -0,0 +1,113 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { endOfToday } from '@cpn-console/shared' +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 { UserGuard } from '../infrastructure/permission/user/user.guard' +import { UserTokensController } from './user-tokens.controller' +import { UserTokensService } from './user-tokens.service' + +describe('userTokensController', () => { + let module: TestingModule + let controller: UserTokensController + let service: DeepMockProxy + + const user: UserContext = { userId: faker.string.uuid() } + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [UserTokensController], + providers: [{ provide: UserTokensService, useValue: service }], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(UserTokensController) + }) + + it('delegates creation to the service with the body and the caller id', async () => { + const expirationDate = faker.date.soon({ days: 1, refDate: endOfToday().getTime() }) + service.create.mockResolvedValueOnce({ + token: { + id: faker.string.uuid(), + name: 'my-token', + lastUse: null, + expirationDate, + status: 'active' as const, + createdAt: new Date(), + userId: user.userId, + owner: { id: user.userId, email: faker.internet.email(), firstName: 'a', lastName: 'b', type: 'human' as const }, + }, + password: faker.string.alphanumeric(48), + }) + + await controller.create({ name: 'my-token', expirationDate }, user) + + expect(service.create).toHaveBeenCalledWith({ name: 'my-token', expirationDate }, user.userId) + }) + + it('maps creation result to the exposed contract shape', async () => { + const expirationDate = faker.date.soon({ days: 1, refDate: endOfToday().getTime() }) + const createdAt = faker.date.past() + const password = faker.string.alphanumeric(48) + service.create.mockResolvedValueOnce({ + token: { + id: faker.string.uuid(), + name: 'my-token', + lastUse: null, + expirationDate, + status: 'active' as const, + createdAt, + userId: user.userId, + owner: { id: user.userId, email: faker.internet.email(), firstName: 'a', lastName: 'b', type: 'human' as const }, + }, + password, + }) + + const result = await controller.create({ name: 'my-token', expirationDate }, user) + + expect(result).toEqual({ + id: expect.any(String), + name: 'my-token', + lastUse: null, + expirationDate: expirationDate.toISOString(), + status: 'active', + createdAt: createdAt.toISOString(), + owner: expect.any(Object), + password, + }) + expect('userId' in result).toBe(false) + }) + + it('maps listed records to the contract shape', async () => { + const record = { + id: faker.string.uuid(), + name: 'my-token', + lastUse: null, + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + status: 'active' as const, + createdAt: faker.date.past(), + userId: user.userId, + owner: { id: user.userId, email: faker.internet.email(), firstName: 'a', lastName: 'b', type: 'human' as const }, + } + service.list.mockResolvedValueOnce([record]) + + const result = await controller.list(user) + + expect(result).toEqual([{ + id: record.id, + name: record.name, + lastUse: null, + expirationDate: record.expirationDate.toISOString(), + status: record.status, + createdAt: record.createdAt.toISOString(), + owner: record.owner, + }]) + }) +}) 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..1496e61084 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.controller.ts @@ -0,0 +1,42 @@ +import type { CreatePersonalAccessTokenBody } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { CreatePersonalAccessTokenBodySchema } from '@cpn-console/shared' +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 { toExposedPersonalAccessToken, toPersonalAccessToken } 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) { + const tokens = await this.service.list(user.userId) + return tokens.map(toPersonalAccessToken) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @Body(new ZodValidationPipe(CreatePersonalAccessTokenBodySchema)) data: CreatePersonalAccessTokenBody, + @AuthUser() user: UserContext, + ) { + const { token, password } = await this.service.create(data, user.userId) + return toExposedPersonalAccessToken(token, password) + } + + @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..b1ad9568fc --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { UserTokensController } from './user-tokens.controller' +import { UserTokensService } from './user-tokens.service' + +@Module({ + imports: [AuthModule, DatabaseModule, UserPermissionModule], + controllers: [UserTokensController], + providers: [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..6b13e825fd --- /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 { endOfToday } from '@cpn-console/shared' +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: faker.word.noun(), + lastUse: null, + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + status: 'active' as const, + createdAt: faker.date.past(), + userId, + hash: faker.string.alphanumeric(64), + }]) + + const result = await service.list(userId) + + expect(prisma.personalAccessToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + }), + ) + 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) + + expect(prisma.personalAccessToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: userTokenSelect }), + ) + }) + }) + + describe('create', () => { + 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: tokenName, + lastUse: null, + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + status: 'active' as const, + createdAt: faker.date.past(), + userId, + hash: faker.string.alphanumeric(64), + }) + + const result = await service.create({ name: tokenName, expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }) }, userId) + + expect(prisma.personalAccessToken.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId, + name: tokenName, + }), + select: userTokenSelect, + }), + ) + expect(result.token.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 () => { + const tokenId = faker.string.uuid() + const userId = faker.string.uuid() + prisma.personalAccessToken.deleteMany.mockResolvedValue({ count: 0 }) + + await service.delete(tokenId, userId) + + expect(prisma.personalAccessToken.deleteMany).toHaveBeenCalledWith({ + 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 new file mode 100644 index 0000000000..4fc3c6fc20 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.service.ts @@ -0,0 +1,66 @@ +import type { CreatePersonalAccessTokenBody } from '@cpn-console/shared' +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, deleteUserToken, 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})`) + + 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, + } + } + + @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 deleteUserToken(this.prisma, { 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..f8416e0719 --- /dev/null +++ b/apps/server-nestjs/src/modules/user-tokens/user-tokens.utils.ts @@ -0,0 +1,21 @@ +import type { ExposedPersonalAccessToken, PersonalAccessToken } from '@cpn-console/shared' +import type { UserToken } from './user-tokens-queries.utils' + +export function toPersonalAccessToken(record: UserToken): PersonalAccessToken { + return { + id: record.id, + name: record.name, + lastUse: record.lastUse?.toISOString() ?? null, + expirationDate: record.expirationDate.toISOString(), + status: record.status, + createdAt: record.createdAt.toISOString(), + owner: record.owner, + } +} + +export function toExposedPersonalAccessToken(record: UserToken, password: string): ExposedPersonalAccessToken { + return { + ...toPersonalAccessToken(record), + password, + } +} diff --git a/apps/server-nestjs/src/utils/crypto.utils.ts b/apps/server-nestjs/src/utils/crypto.utils.ts index 38b9510b5b..19c9147938 100644 --- a/apps/server-nestjs/src/utils/crypto.utils.ts +++ b/apps/server-nestjs/src/utils/crypto.utils.ts @@ -26,12 +26,18 @@ export interface TokenPair { hash: string } +// Unsalting SHA-256 hex digest used to store and look up access tokens. +// Single source of truth for the token hash format: creation, auth lookup and +// tests must all go through it. Must stay byte-for-byte identical to the +// legacy @cpn-console/server implementation (unsalted `sha256(token)`). +export function hashToken(password: string): string { + return createHash('sha256').update(password).digest('hex') +} + // 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). +// persisted. export function generateTokenPair(length: number = TOKEN_LENGTH): TokenPair { const password = generateRandomPassword(length, TOKEN_ALPHABET) - const hash = createHash('sha256').update(password).digest('hex') - return { password, hash } + return { password, hash: hashToken(password) } } 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..bb7f98e826 --- /dev/null +++ b/apps/server-nestjs/test/user-tokens.e2e-spec.ts @@ -0,0 +1,175 @@ +import type { TestingModule } from '@nestjs/testing' +import { endOfToday } from '@cpn-console/shared' +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 { hashToken } from '../src/utils/crypto.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunUserTokensE2E = Boolean(process.env.E2E) + +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: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, LoggerModule, PermissionModule, 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: faker.helpers.slugify(`e2e-pat-${faker.string.uuid()}`), + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, ownerId) + + expect(result.id).toBeTruthy() + expect(result.password).toBeTruthy() + expect(result.password.length).toBe(48) + expect(result.name).toBeTruthy() + expect(result.expirationDate).toBeTruthy() + + createdTokenIds.push(result.id) + }) + + it('should list only tokens for the requesting user', async () => { + const tokenName = faker.helpers.slugify(`e2e-pat-list-${faker.string.uuid()}`) + const created = await service.create({ + name: tokenName, + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, ownerId) + createdTokenIds.push(created.id) + + const otherTokenName = faker.helpers.slugify(`e2e-pat-other-${faker.string.uuid()}`) + const otherToken = await service.create({ + name: otherTokenName, + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, 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: faker.helpers.slugify(`e2e-pat-delete-${faker.string.uuid()}`), + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, 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: faker.helpers.slugify(`e2e-pat-foreign-${faker.string.uuid()}`), + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, 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 persist SHA256 hash of the password in DB', async () => { + const created = await service.create({ + name: faker.helpers.slugify(`e2e-pat-hash-${faker.string.uuid()}`), + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, ownerId) + createdTokenIds.push(created.id) + + const stored = await prisma.personalAccessToken.findUniqueOrThrow({ + where: { id: created.id }, + select: { hash: true }, + }) + + const expectedHash = hashToken(created.password) + expect(stored.hash).toBe(expectedHash) + }) + + it('should omit hash from API responses', async () => { + const created = await service.create({ + name: faker.helpers.slugify(`e2e-pat-omit-${faker.string.uuid()}`), + expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }), + }, 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') + }) +}) diff --git a/packages/shared/src/contracts/personal-access-token.ts b/packages/shared/src/contracts/personal-access-token.ts index 72e9012596..041bc2c06e 100644 --- a/packages/shared/src/contracts/personal-access-token.ts +++ b/packages/shared/src/contracts/personal-access-token.ts @@ -1,7 +1,8 @@ +import type { ClientInferRequest } from '@ts-rest/core' import { ContractNoBody } from '@ts-rest/core' import { z } from 'zod' import { apiPrefix, contractInstance } from '../api-client.js' -import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' +import { CreatePersonalAccessTokenBodySchema, ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' import { baseHeaders, ErrorSchema } from './_utils.js' export const personalAccessTokenContract = contractInstance.router({ @@ -20,7 +21,7 @@ export const personalAccessTokenContract = contractInstance.router({ createPersonalAccessToken: { method: 'POST', path: '', - body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + body: CreatePersonalAccessTokenBodySchema, responses: { 201: ExposedPersonalAccessTokenSchema, 400: ErrorSchema, @@ -47,3 +48,5 @@ export const personalAccessTokenContract = contractInstance.router({ baseHeaders, pathPrefix: `${apiPrefix}/user/tokens`, }) + +export type CreatePersonalAccessTokenRequest = ClientInferRequest['body'] diff --git a/packages/shared/src/schemas/token.spec.ts b/packages/shared/src/schemas/token.spec.ts index 36ef022520..bfe135ce25 100644 --- a/packages/shared/src/schemas/token.spec.ts +++ b/packages/shared/src/schemas/token.spec.ts @@ -1,6 +1,7 @@ import { faker } from '@faker-js/faker' import { describe, expect, it } from 'vitest' -import { CreateAdminTokenBodySchema } from './index.js' +import { endOfToday, tomorrowNoon } from '../utils/date.js' +import { CreateAdminTokenBodySchema, CreatePersonalAccessTokenBodySchema } from './index.js' describe('token body schemas', () => { it('accepts an admin token body with nullable expiration', () => { @@ -15,3 +16,35 @@ describe('token body schemas', () => { expect(result.error.issues[0].message).toBe('Date d\'expiration trop courte') }) }) + +describe('createPersonalAccessTokenBodySchema', () => { + it('rejects an invalid expiration date', () => { + const result = CreatePersonalAccessTokenBodySchema.safeParse({ + name: 'my-token', + expirationDate: 'not-a-date', + }) + + expect(result.success).toBe(false) + }) + + it('rejects an expiration date before tomorrow', () => { + const result = CreatePersonalAccessTokenBodySchema.safeParse({ + name: 'my-token', + expirationDate: endOfToday().toISOString(), + }) + + expect(result.success).toBe(false) + }) + + it('accepts an expiration date tomorrow', () => { + const result = CreatePersonalAccessTokenBodySchema.safeParse({ + name: 'my-token', + expirationDate: tomorrowNoon().toISOString(), + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.expirationDate).toBeInstanceOf(Date) + } + }) +}) diff --git a/packages/shared/src/schemas/token.ts b/packages/shared/src/schemas/token.ts index 03776685f9..617ecca234 100644 --- a/packages/shared/src/schemas/token.ts +++ b/packages/shared/src/schemas/token.ts @@ -45,5 +45,11 @@ export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ password: z.string(), }) +export const CreatePersonalAccessTokenBodySchema = PersonalAccessTokenSchema + .pick({ name: true, expirationDate: true }) + .extend({ expirationDate: ExpirationDateSchema }) + .required() + export type PersonalAccessToken = Zod.infer export type ExposedPersonalAccessToken = Zod.infer +export type CreatePersonalAccessTokenBody = Zod.infer diff --git a/packages/shared/src/utils/date.ts b/packages/shared/src/utils/date.ts index 1bc7428288..433357f07d 100644 --- a/packages/shared/src/utils/date.ts +++ b/packages/shared/src/utils/date.ts @@ -7,3 +7,21 @@ export function formatDateTime(dateTimeString: string) { const date = new Date(dateTimeString) return new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }).format(date) } + +export function tomorrowNoon() { + const date = new Date() + date.setUTCDate(date.getUTCDate() + 1) + date.setUTCHours(12, 0, 0, 0) + return date +} + +export function daysAgo(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +export function endOfToday() { + const end = new Date() + end.setUTCHours(23, 59, 59, 999) + + return end +} diff --git a/packages/shared/src/utils/functions.ts b/packages/shared/src/utils/functions.ts index f38778f36d..a1dd029661 100644 --- a/packages/shared/src/utils/functions.ts +++ b/packages/shared/src/utils/functions.ts @@ -1,6 +1,7 @@ import type { ResourceById, ResourceByKey } from './types.js' import shortUUID from 'short-uuid' import { longestEnvironmentName } from './const.js' +import { endOfToday } from './date.js' /** * @param {*} value Value wanted to be return as is @@ -252,10 +253,7 @@ export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrs } export function isAtLeastTomorrow(actualTime: Date) { - const tomorrow = new Date(Date.now()) - tomorrow.setUTCHours(23, 59, 59, 999) - - return actualTime.getTime() > tomorrow.getTime() + return actualTime.getTime() > endOfToday().getTime() } export function insert(pseudoArray: T[] | undefined, element: T): T[] {