diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 88fb9a02c1..7cd81abb3c 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule' import { TerminusModule } from '@nestjs/terminus' import { baseConfigFactory } from './config/base.config' import { AdminRoleModule } from './modules/admin-role/admin-role.module' +import { AdminTokenModule } from './modules/admin-token/admin-token.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -33,6 +34,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' }), TerminusModule.forRoot(), AdminRoleModule, + AdminTokenModule, DeploymentModule, EnvironmentModule, HealthzModule, diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.spec.ts b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.spec.ts new file mode 100644 index 0000000000..8b8e061479 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.spec.ts @@ -0,0 +1,90 @@ +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 { + adminTokenSelect, + createAdminToken, + createBotUser, + listAdminTokens, + revokeAdminToken, +} from './admin-token-queries.utils' + +describe('admin-token-queries.utils', () => { + let tx: DeepMockProxy + + beforeEach(() => { + tx = mockDeep() + }) + + describe('listAdminTokens', () => { + it('filters to active tokens by default', async () => { + tx.adminToken.findMany.mockResolvedValue([]) + + await listAdminTokens(tx, false) + + expect(tx.adminToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { status: 'active' }, select: adminTokenSelect }), + ) + }) + + it('includes revoked tokens when withRevoked is true', async () => { + tx.adminToken.findMany.mockResolvedValue([]) + + await listAdminTokens(tx, true) + + expect(tx.adminToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { status: { in: ['active', 'revoked'] } }, select: adminTokenSelect }), + ) + }) + }) + + describe('createBotUser', () => { + it('creates a bot user with a derived email', async () => { + const botUserId = faker.string.uuid() + const name = faker.person.fullName() + + await createBotUser(tx, { botUserId, name }) + + expect(tx.user.create).toHaveBeenCalledWith({ + data: { + firstName: 'Bot Admin', + lastName: name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) + }) + }) + + describe('createAdminToken', () => { + it('creates an admin token with the provided fields', async () => { + const data = { + name: faker.word.noun(), + permissions: 4n, + expirationDate: null, + hash: faker.string.alphanumeric(64), + userId: faker.string.uuid(), + } + + await createAdminToken(tx, data) + + expect(tx.adminToken.create).toHaveBeenCalledWith({ data, select: adminTokenSelect }) + }) + }) + + describe('revokeAdminToken', () => { + it('marks the token revoked with a fresh expiration date', async () => { + const id = faker.string.uuid() + + await revokeAdminToken(tx, id) + + expect(tx.adminToken.updateMany).toHaveBeenCalledWith({ + where: { id }, + data: { status: 'revoked', expirationDate: expect.any(Date) }, + }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts new file mode 100644 index 0000000000..c08392b695 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts @@ -0,0 +1,82 @@ +import type { Prisma } from '@prisma/client' + +export const adminTokenOwnerSelect = { + id: true, + email: true, + firstName: true, + lastName: true, + type: true, +} satisfies Prisma.UserSelect + +export const adminTokenSelect = { + id: true, + name: true, + permissions: true, + lastUse: true, + expirationDate: true, + status: true, + createdAt: true, + userId: true, + owner: { + select: adminTokenOwnerSelect, + }, +} satisfies Prisma.AdminTokenSelect + +export type AdminTokenRecord = Prisma.AdminTokenGetPayload<{ + select: typeof adminTokenSelect +}> + +function adminTokenWhere(withRevoked: boolean): Prisma.AdminTokenWhereInput { + return withRevoked + ? { status: { in: ['active', 'revoked'] } } + : { status: 'active' } +} + +export function listAdminTokens(tx: Prisma.TransactionClient, withRevoked: boolean) { + return tx.adminToken.findMany({ + where: adminTokenWhere(withRevoked), + select: adminTokenSelect, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + }) +} + +export function createBotUser(tx: Prisma.TransactionClient, data: { botUserId: string, name: string }) { + return tx.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: data.botUserId, + email: `${data.botUserId}@bot.io`, + }, + }) +} + +export function createAdminToken(tx: Prisma.TransactionClient, data: { + name: string + permissions: bigint + expirationDate: Date | null + hash: string + userId: string +}) { + return tx.adminToken.create({ + data: { + name: data.name, + permissions: data.permissions, + expirationDate: data.expirationDate, + hash: data.hash, + userId: data.userId, + }, + select: adminTokenSelect, + }) +} + +export function revokeAdminToken(tx: Prisma.TransactionClient, id: string) { + return tx.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(), + }, + }) +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token-testing.utils.ts b/apps/server-nestjs/src/modules/admin-token/admin-token-testing.utils.ts new file mode 100644 index 0000000000..c9858600b6 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token-testing.utils.ts @@ -0,0 +1,35 @@ +import type { AdminTokenRecord } from './admin-token-queries.utils' +import { faker } from '@faker-js/faker' + +export function makeAdminTokenOwner(overrides: Partial = {}): AdminTokenRecord['owner'] { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'bot', + ...overrides, + } +} + +export function makeAdminTokenRecord(overrides: Partial = {}): AdminTokenRecord { + const owner = overrides.owner ?? makeAdminTokenOwner() + return { + id: faker.string.uuid(), + name: 'my-token', + permissions: 4n, + lastUse: null, + expirationDate: null, + status: 'active', + createdAt: faker.date.past(), + userId: owner.id, + owner, + ...overrides, + } +} + +export type AdminTokenRecordWithHash = AdminTokenRecord & { hash: string } + +export function makeAdminTokenRecordWithHash(overrides: Partial = {}): AdminTokenRecordWithHash { + return { ...makeAdminTokenRecord(overrides), hash: faker.string.hexadecimal({ length: 64 }) } +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.controller.spec.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.spec.ts new file mode 100644 index 0000000000..9998ef0a07 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.spec.ts @@ -0,0 +1,58 @@ +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { makeAdminTokenRecord } from './admin-token-testing.utils' +import { AdminTokenController } from './admin-token.controller' +import { AdminTokenService } from './admin-token.service' + +describe('adminTokenController', () => { + let module: TestingModule + let controller: AdminTokenController + let service: MockProxy + + const token = makeAdminTokenRecord({ permissions: 0n }) + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [AdminTokenController], + providers: [ + { provide: AdminTokenService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(AdminTokenController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('delegates list with withRevoked coerced to boolean and serializes permissions', async () => { + service.list.mockResolvedValue([token]) + + const [listed] = await controller.list(true) + expect(listed.permissions).toBe('0') + expect(service.list).toHaveBeenCalledWith(true) + + await controller.list(false) + expect(service.list).toHaveBeenLastCalledWith(false) + }) + + it('delegates create and assembles the exposed token', async () => { + service.create.mockResolvedValue({ token, password: 'plain-password' }) + + const result = await controller.create({ name: token.name, permissions: '0', expirationDate: null }) + + expect(service.create).toHaveBeenCalledWith({ name: token.name, permissions: '0', expirationDate: null }) + expect(result.permissions).toBe('0') + expect(result.password).toBe('plain-password') + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts new file mode 100644 index 0000000000..29a2c98025 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts @@ -0,0 +1,38 @@ +import type { CreateAdminTokenBody } from '@cpn-console/shared' +import { CoerceBooleanSchema, CreateAdminTokenBodySchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Query, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { AdminTokenService } from './admin-token.service' +import { toAdminToken, toExposedAdminToken } from './admin-token.utils' + +@Controller('api/v1/admin/tokens') +@UseGuards(UserGuard) +export class AdminTokenController { + constructor(@Inject(AdminTokenService) private readonly service: AdminTokenService) {} + + @Get() + @RequireAdminPermission('ListAdminToken') + async list(@Query('withRevoked', new ZodValidationPipe(CoerceBooleanSchema.optional())) withRevoked?: boolean) { + const tokens = await this.service.list(withRevoked === true) + return tokens.map(toAdminToken) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireAdminPermission('ManageAdminToken') + async create( + @Body(new ZodValidationPipe(CreateAdminTokenBodySchema)) data: CreateAdminTokenBody, + ) { + const { token, password } = await this.service.create(data) + return toExposedAdminToken(token, password) + } + + @Delete(':tokenId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireAdminPermission('ManageAdminToken') + async revoke(@Param('tokenId', ParseUUIDPipe) tokenId: string): Promise { + return this.service.revoke(tokenId) + } +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.module.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.module.ts new file mode 100644 index 0000000000..176543d961 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AdminTokenController } from './admin-token.controller' +import { AdminTokenService } from './admin-token.service' + +@Module({ + imports: [InfrastructureModule], + controllers: [AdminTokenController], + providers: [AdminTokenService], + exports: [AdminTokenService], +}) +export class AdminTokenModule {} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts new file mode 100644 index 0000000000..fde506a8ff --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts @@ -0,0 +1,114 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException } from '@nestjs/common' +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 { makeAdminTokenRecordWithHash } from './admin-token-testing.utils' +import { AdminTokenService } from './admin-token.service' + +describe('adminTokenService', () => { + let module: TestingModule + let service: AdminTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AdminTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(AdminTokenService) + }) + + describe('list', () => { + it('returns raw token records', async () => { + const token = makeAdminTokenRecordWithHash() + prisma.adminToken.findMany.mockResolvedValue([token]) + + const result = await service.list() + + expect(prisma.adminToken.findMany).toHaveBeenCalled() + expect(result).toEqual([token]) + }) + + it('includes revoked tokens when withRevoked is true', async () => { + prisma.adminToken.findMany.mockResolvedValue([]) + + await service.list(true) + + expect(prisma.adminToken.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { status: { in: ['active', 'revoked'] } }, + })) + }) + + it('filters to active only by default', async () => { + prisma.adminToken.findMany.mockResolvedValue([]) + + await service.list() + + expect(prisma.adminToken.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { status: 'active' }, + })) + }) + }) + + describe('create', () => { + it('rejects an expiration date before tomorrow', async () => { + await expect(service.create({ name: 'my-token', permissions: '4', expirationDate: new Date() })) + .rejects.toThrow(BadRequestException) + await expect(service.create({ name: 'my-token', permissions: '4', expirationDate: new Date() })) + .rejects.toThrow('Date d\'expiration trop courte') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('returns created token with plaintext password and serialized permissions', async () => { + const tokenId = faker.string.uuid() + const botUserId = faker.string.uuid() + const tx = mockDeep() + tx.user.create.mockResolvedValue({ + id: botUserId, + firstName: 'Bot Admin', + lastName: 'my-token', + email: `${botUserId}@bot.io`, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + adminRoleIds: [], + type: 'bot', + }) + const created = makeAdminTokenRecordWithHash({ id: tokenId, userId: botUserId, permissions: 2n }) + tx.adminToken.create.mockResolvedValue(created) + prisma.$transaction.mockImplementation(async fn => fn(tx)) + + const result = await service.create({ name: 'my-token', permissions: '2', expirationDate: null }) + + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function)) + expect(tx.user.create).toHaveBeenCalled() + expect(tx.adminToken.create).toHaveBeenCalled() + expect(result.token).toEqual(created) + expect(result.token.permissions).toBe(2n) + expect(result.password).toBeTruthy() + }) + }) + + describe('revoke', () => { + it('sets status to revoked and expiration date to now', async () => { + const tokenId = faker.string.uuid() + prisma.adminToken.updateMany.mockResolvedValue({ count: 1 }) + + await service.revoke(tokenId) + + expect(prisma.adminToken.updateMany).toHaveBeenCalledWith({ + where: { id: tokenId }, + data: { status: 'revoked', expirationDate: expect.any(Date) }, + }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts new file mode 100644 index 0000000000..b04cc6802b --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts @@ -0,0 +1,83 @@ +import type { CreateAdminTokenBody } from '@cpn-console/shared' +import { randomUUID } from 'node:crypto' +import { isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { generateTokenPair } from '../../utils/crypto.utils' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { + createAdminToken, + createBotUser, + listAdminTokens, + revokeAdminToken, +} from './admin-token-queries.utils' + +@Injectable() +export class AdminTokenService { + private readonly logger = new Logger(AdminTokenService.name) + + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + @StartActiveSpan() + async list(withRevoked = false) { + const span = trace.getActiveSpan() + this.logger.log(`adminToken.list requested (withRevoked=${withRevoked})`) + span?.setAttribute('adminToken.list.withRevoked', withRevoked) + + const tokens = await listAdminTokens(this.prisma, withRevoked) + + span?.setAttribute('adminToken.list.count', tokens.length) + this.logger.log(`adminToken.list completed (count=${tokens.length})`) + return tokens + } + + @StartActiveSpan() + async create(data: CreateAdminTokenBody) { + const span = trace.getActiveSpan() + span?.setAttribute('adminToken.create.name', data.name) + this.logger.log(`adminToken.create started (tokenName=${data.name})`) + + const expirationDate = data.expirationDate ? new Date(data.expirationDate) : null + if (expirationDate && !isAtLeastTomorrow(expirationDate)) { + throw new BadRequestException('Date d\'expiration trop courte') + } + + try { + const { password, hash } = generateTokenPair() + + const token = await this.prisma.$transaction(async (tx) => { + const botUserId = randomUUID() + await createBotUser(tx, { botUserId, name: data.name }) + return createAdminToken(tx, { + name: data.name, + permissions: BigInt(data.permissions), + expirationDate, + hash, + userId: botUserId, + }) + }) + + span?.setAttribute('adminToken.create.tokenId', token.id) + this.logger.log(`adminToken.create completed (adminTokenId=${token.id}, botUserId=${token.userId}, status=${token.status})`) + return { token, password } + } catch (error) { + this.logger.error( + `adminToken.create failed (tokenName=${data.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async revoke(id: string) { + const span = trace.getActiveSpan() + span?.setAttribute('adminToken.revoke.tokenId', id) + this.logger.log(`adminToken.revoke started (adminTokenId=${id})`) + + await revokeAdminToken(this.prisma, id) + + this.logger.log(`adminToken.revoke completed (adminTokenId=${id})`) + } +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.utils.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.utils.ts new file mode 100644 index 0000000000..2037da632c --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.utils.ts @@ -0,0 +1,12 @@ +import type { AdminTokenRecord } from './admin-token-queries.utils' + +export function toAdminToken({ permissions, ...token }: AdminTokenRecord) { + return { + ...token, + permissions: permissions.toString(), + } +} + +export function toExposedAdminToken(record: AdminTokenRecord, password: string) { + return { ...toAdminToken(record), password } +} diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts index d170ff59ee..2070cc8ada 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -53,22 +53,35 @@ export function makePersonalAccessToken(overrides: { } } -export function makeAdminToken(overrides: { - id?: string +export function makeAdminTokenOwner(overrides: { userId?: string adminRoleIds?: string[] type?: User['type'] +} = {}): { id: string, adminRoleIds: string[], type: string } { + return { + id: overrides.userId ?? 'owner-id', + adminRoleIds: overrides.adminRoleIds ?? [], + type: overrides.type ?? 'human', + } +} + +export function makeAdminToken(overrides: { + id?: string + userId?: string + owner?: { + id?: string + adminRoleIds?: string[] + type?: User['type'] + } status?: AdminToken['status'] permissions?: bigint expirationDate?: AdminToken['expirationDate'] lastUse?: AdminToken['lastUse'] } = {}): AdminToken & { owner: { id: string, adminRoleIds: string[], type: string } } { - const id = overrides.id ?? 'admin-token-id' const ownerId = overrides.userId ?? 'owner-id' - const type = overrides.type ?? 'human' return { - id, + id: overrides.id ?? 'admin-token-id', name: 'admin-token', status: overrides.status ?? 'active', expirationDate: overrides.expirationDate ?? null, @@ -77,10 +90,10 @@ export function makeAdminToken(overrides: { createdAt: faker.date.past(), hash: 'hash', userId: ownerId, - owner: { - id: ownerId, - adminRoleIds: overrides.adminRoleIds ?? [], - type, - }, + owner: makeAdminTokenOwner({ + userId: overrides.userId ?? overrides.owner?.id, + adminRoleIds: overrides.owner?.adminRoleIds, + type: overrides.owner?.type, + }), } } diff --git a/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts index 066fa255d4..d4695fd3b2 100644 --- a/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts +++ b/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -9,7 +9,10 @@ export class ZodValidationPipe implements PipeTransform { const result = this.schema.safeParse(value) if (!result.success) { - throw new BadRequestException(result.error.flatten()) + const messages = result.error.issues.map(issue => issue.message) + throw new BadRequestException( + messages.every(message => message === messages[0]) ? messages[0] : result.error.flatten(), + ) } return result.data diff --git a/apps/server-nestjs/test/admin-token.e2e-spec.ts b/apps/server-nestjs/test/admin-token.e2e-spec.ts new file mode 100644 index 0000000000..ead110615a --- /dev/null +++ b/apps/server-nestjs/test/admin-token.e2e-spec.ts @@ -0,0 +1,55 @@ +import type { INestApplication } from '@nestjs/common' +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { AdminTokenController } from '../src/modules/admin-token/admin-token.controller' +import { AdminTokenService } from '../src/modules/admin-token/admin-token.service' +import { UserGuard } from '../src/modules/infrastructure/permission/user/user.guard' + +describe('AdminToken HTTP validation', () => { + let app: INestApplication + let baseUrl: string + + let service: ReturnType> + + beforeAll(async () => { + service = mock() + service.list.mockResolvedValue([]) + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [AdminTokenController], + providers: [{ provide: AdminTokenService, useValue: service }], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + app = moduleRef.createNestApplication() + await app.listen(0) + const address = app.getHttpServer().address() + if (address === null || typeof address === 'string') throw new Error('HTTP server did not report a port') + baseUrl = `http://localhost:${address.port}` + }) + + afterAll(async () => { + await app.close() + }) + + it('accepts GET without withRevoked', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/tokens`) + + expect(response.status).toBe(200) + }) + + it('maps a too-close expiration date to a legacy-compatible 400', async () => { + const response = await fetch(`${baseUrl}/api/v1/admin/tokens`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'legacy-parity', permissions: '4', expirationDate: new Date().toISOString() }), + }) + + expect(response.status).toBe(400) + const body: { message?: unknown } = await response.json() + expect(body.message).toBe('Date d\'expiration trop courte') + }) +}) diff --git a/packages/shared/src/schemas/_utils.ts b/packages/shared/src/schemas/_utils.ts index 91cea1b16a..08449058e8 100644 --- a/packages/shared/src/schemas/_utils.ts +++ b/packages/shared/src/schemas/_utils.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { isAtLeastTomorrow } from '../utils/functions.js' export const CoerceBooleanSchema = z.boolean() .or(z.enum(['true', 'false']) @@ -19,3 +20,6 @@ export const UuidOrCsvUuidSchema = z.string() .some(uuid => !z.string().uuid().safeParse(uuid).success) }) .transform(value => value.split(',')) + +export const ExpirationDateSchema = z.coerce.date() + .refine(value => isAtLeastTomorrow(value), { 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' diff --git a/packages/shared/src/schemas/token.spec.ts b/packages/shared/src/schemas/token.spec.ts new file mode 100644 index 0000000000..36ef022520 --- /dev/null +++ b/packages/shared/src/schemas/token.spec.ts @@ -0,0 +1,17 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { CreateAdminTokenBodySchema } from './index.js' + +describe('token body schemas', () => { + it('accepts an admin token body with nullable expiration', () => { + const result = CreateAdminTokenBodySchema.safeParse({ name: faker.string.alpha({ length: 8, casing: 'lower' }), permissions: '4', expirationDate: null }) + expect(result.success).toBe(true) + }) + + it('rejects an admin token expiration before tomorrow', () => { + const result = CreateAdminTokenBodySchema.safeParse({ name: faker.string.alpha({ length: 8, casing: 'lower' }), permissions: '4', expirationDate: new Date().toISOString() }) + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.issues[0].message).toBe('Date d\'expiration trop courte') + }) +}) diff --git a/packages/shared/src/schemas/token.ts b/packages/shared/src/schemas/token.ts index 36cd518ac5..03776685f9 100644 --- a/packages/shared/src/schemas/token.ts +++ b/packages/shared/src/schemas/token.ts @@ -1,6 +1,6 @@ import type Zod from 'zod' import { z } from 'zod' -import { dateToString, permissionLevelSchema } from './_utils.js' +import { dateToString, ExpirationDateSchema, permissionLevelSchema } from './_utils.js' import { UserSchema } from './user.js' export const TokenSchema = z.object({ @@ -29,6 +29,11 @@ export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ password: z.string(), }) +export const CreateAdminTokenBodySchema = AdminTokenSchema + .pick({ name: true, permissions: true, expirationDate: true }) + .extend({ expirationDate: ExpirationDateSchema.nullable() }) + .required() + export type AdminToken = Zod.infer export type ExposedAdminToken = Zod.infer