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..39707f3954 --- /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) + + const call = tx.adminToken.updateMany.mock.calls[0]?.[0] + expect(call?.where).toEqual({ id }) + expect(call?.data.status).toBe('revoked') + expect(call?.data.expirationDate).toBeInstanceOf(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..41d07061c8 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts @@ -0,0 +1,80 @@ +import type { Prisma } from '@prisma/client' + +export const adminTokenOwnerSelect = { + id: true, + email: true, + firstName: true, + lastName: true, + type: true, +} satisfies Prisma.UserSelect + +export const adminTokenSelect = { + id: true, + name: true, + permissions: true, + lastUse: true, + expirationDate: true, + status: true, + createdAt: true, + userId: true, + owner: { + select: adminTokenOwnerSelect, + }, +} satisfies Prisma.AdminTokenSelect + +export type AdminTokenRecord = Prisma.AdminTokenGetPayload<{ + select: typeof adminTokenSelect +}> + +export function listAdminTokens(tx: Prisma.TransactionClient, withRevoked: boolean) { + const where: Prisma.AdminTokenWhereInput = withRevoked + ? { status: { in: ['active', 'revoked'] } } + : { status: 'active' } + + return tx.adminToken.findMany({ + where, + select: adminTokenSelect, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + }) +} + +export function createBotUser(tx: Prisma.TransactionClient, data: { botUserId: string, name: string }) { + return tx.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: data.botUserId, + email: `${data.botUserId}@bot.io`, + }, + }) +} + +export function createAdminToken(tx: Prisma.TransactionClient, data: { + name: string + permissions: bigint + expirationDate: Date | null + hash: string + userId: string +}) { + return tx.adminToken.create({ + data: { + name: data.name, + permissions: data.permissions, + expirationDate: data.expirationDate, + hash: data.hash, + userId: data.userId, + }, + select: adminTokenSelect, + }) +} + +export function revokeAdminToken(tx: Prisma.TransactionClient, id: string) { + return tx.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(), + }, + }) +} diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts new file mode 100644 index 0000000000..c56fca5447 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts @@ -0,0 +1,36 @@ +import type { CreateAdminTokenBody } from './admin-token.utils' +import { CoerceBooleanSchema } 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 { CreateAdminTokenBodySchema } 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)) withRevoked?: boolean) { + return this.service.list(withRevoked === true) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireAdminPermission('ManageAdminToken') + async create( + @Body(new ZodValidationPipe(CreateAdminTokenBodySchema)) data: CreateAdminTokenBody, + ) { + return this.service.create(data) + } + + @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..45976f73af --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts @@ -0,0 +1,129 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { AdminTokenService } from './admin-token.service' +import { CreateAdminTokenBodySchema } from './admin-token.utils' + +describe('adminTokenService', () => { + let module: TestingModule + let service: AdminTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AdminTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(AdminTokenService) + }) + + describe('list', () => { + it('returns active tokens with permissions serialized as string', async () => { + const tokenId = faker.string.uuid() + const userId = faker.string.uuid() + prisma.adminToken.findMany.mockResolvedValue([{ + id: tokenId, + name: 'my-token', + permissions: 4n, + lastUse: null, + expirationDate: null, + status: 'active' as const, + createdAt: faker.date.past(), + userId, + hash: 'hash-1', + }]) + + const result = await service.list() + + expect(prisma.adminToken.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + expect(result[0].id).toBe(tokenId) + expect(result[0].permissions).toBe('4') + }) + + it('includes revoked tokens when withRevoked is true', async () => { + prisma.adminToken.findMany.mockResolvedValue([]) + + await service.list(true) + + const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0] + expect(callArgs?.where).toEqual({ status: { in: ['active', 'revoked'] } }) + }) + + it('filters to active only by default', async () => { + prisma.adminToken.findMany.mockResolvedValue([]) + + await service.list() + + const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0] + expect(callArgs?.where).toEqual({ status: 'active' }) + }) + }) + + describe('create', () => { + it('rejects a non-parseable expirationDate via the body schema', () => { + const result = CreateAdminTokenBodySchema.safeParse({ name: 'x', permissions: '4', 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 = CreateAdminTokenBodySchema.safeParse({ name: 'x', permissions: '4', expirationDate: today.toISOString() }) + expect(result.success).toBe(false) + }) + + 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', type: 'bot', email: 'x@bot.io' } as never) + tx.adminToken.create.mockResolvedValue({ + id: tokenId, + name: 'my-token', + permissions: 2n, + lastUse: null, + expirationDate: null, + status: 'active' as const, + createdAt: faker.date.past(), + userId: botUserId, + hash: 'hash-2', + }) + 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.id).toBe(tokenId) + expect(result.password).toBeTruthy() + expect(result.permissions).toBe('2') + }) + }) + + describe('revoke', () => { + it('sets status to revoked and expiration date to now', async () => { + const tokenId = faker.string.uuid() + prisma.adminToken.updateMany.mockResolvedValue({ count: 1 } as never) + + await service.revoke(tokenId) + + expect(prisma.adminToken.updateMany).toHaveBeenCalledWith({ + where: { id: tokenId }, + data: { + status: 'revoked', + expirationDate: expect.any(Date) as Date, + }, + }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts b/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts new file mode 100644 index 0000000000..abbc6cf184 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.service.ts @@ -0,0 +1,86 @@ +import type { CreateAdminTokenBody } from './admin-token.utils' +import { randomUUID } from 'node:crypto' +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 { + 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.map(({ permissions, ...token }) => ({ + ...token, + permissions: permissions.toString(), + })) + } + + @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 + + 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, + permissions: token.permissions.toString(), + } + } catch (error) { + this.logger.error( + `adminToken.create failed (tokenName=${data.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async revoke(id: string) { + const span = trace.getActiveSpan() + span?.setAttribute('adminToken.revoke.tokenId', id) + this.logger.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..0059f24e99 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-token/admin-token.utils.ts @@ -0,0 +1,9 @@ +import type { z } from 'zod' +import { AdminTokenSchema } from '@cpn-console/shared' +import { ExpirationDateSchema } from '../../utils/schemas.js' + +export const CreateAdminTokenBodySchema = AdminTokenSchema + .pick({ name: true, permissions: true, expirationDate: true }) + .extend({ expirationDate: ExpirationDateSchema.nullable() }) + .required() +export type CreateAdminTokenBody = z.infer diff --git a/apps/server-nestjs/src/utils/crypto.spec.ts b/apps/server-nestjs/src/utils/crypto.spec.ts index 090c58583a..540f7b23de 100644 --- a/apps/server-nestjs/src/utils/crypto.spec.ts +++ b/apps/server-nestjs/src/utils/crypto.spec.ts @@ -1,6 +1,6 @@ import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' import { describe, expect, it } from 'vitest' -import { generateProjectKey } from './crypto.utils' +import { generateProjectKey, generateTokenPair } from './crypto.utils' describe('generateProjectKey', () => { it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { @@ -31,3 +31,34 @@ describe('generateProjectKey', () => { expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) }) }) + +describe('generateTokenPair', () => { + it('returns a plaintext token and a sha256 hex hash', () => { + const { password, hash } = generateTokenPair() + + expect(password).toHaveLength(48) + expect(password).toMatch(/^[a-z0-9-]+$/i) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it('honours a custom length', () => { + const { password, hash } = generateTokenPair(64) + + expect(password).toHaveLength(64) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it('produces distinct tokens on each call', () => { + const first = generateTokenPair() + const second = generateTokenPair() + + expect(first.password).not.toBe(second.password) + expect(first.hash).not.toBe(second.hash) + }) + + it('does not embed the plaintext token in the hash', () => { + const { password, hash } = generateTokenPair() + + expect(hash).not.toContain(password) + }) +}) diff --git a/apps/server-nestjs/src/utils/crypto.utils.ts b/apps/server-nestjs/src/utils/crypto.utils.ts index f12bebcd13..9ef8422131 100644 --- a/apps/server-nestjs/src/utils/crypto.utils.ts +++ b/apps/server-nestjs/src/utils/crypto.utils.ts @@ -1,7 +1,6 @@ -import crypto, { createHmac } from 'node:crypto' +import crypto, { createHash, createHmac } from 'node:crypto' -export function generateRandomPassword(length = 24) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' +export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*') { return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) .join('') } @@ -16,3 +15,25 @@ export function generateProjectKey(projectSlug: string, internalRepoName: string .slice(0, 4) return `${projectSlug}-${internalRepoName}-${repoHash}` } + +const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-' +const TOKEN_LENGTH = 48 + +export interface TokenPair { + /** Plaintext token. Returned to the caller once and never stored. */ + password: string + /** SHA-256 hex digest of the token (64 lowercase hex chars). */ + hash: string +} + +/** + * Generate a secure random access token and its SHA-256 storage hash. + * The plaintext token is shown to the caller exactly once; only the hash is + * persisted. This keeps the storage format compatible with the Fastify server + * (unsalted `sha256(token)` hex digest). + */ +export function generateTokenPair(length: number = TOKEN_LENGTH): TokenPair { + const password = generateRandomPassword(length, TOKEN_ALPHABET) + const hash = createHash('sha256').update(password).digest('hex') + return { password, hash } +} 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/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..ed4a9532ee --- /dev/null +++ b/apps/server-nestjs/test/admin-token.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AdminTokenModule } from '../src/modules/admin-token/admin-token.module' +import { AdminTokenService } from '../src/modules/admin-token/admin-token.service' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' + +const canRunAdminTokenE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithAdminToken = describe.runIf(canRunAdminTokenE2E) + +describeWithAdminToken('AdminTokenService (e2e)', () => { + let moduleRef: TestingModule + let service: AdminTokenService + let prisma: PrismaService + + const createdTokenIds: string[] = [] + const createdBotUserIds: string[] = [] + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [AdminTokenModule], + }).compile() + + await moduleRef.init() + + service = moduleRef.get(AdminTokenService) + prisma = moduleRef.get(PrismaService) + }) + + afterAll(async () => { + if (prisma) { + await prisma.adminToken.deleteMany({ where: { id: { in: createdTokenIds } } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: { in: createdBotUserIds } } }).catch(() => {}) + } + + await moduleRef?.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create a bot user and issue a token with plaintext password', async () => { + const result = await service.create({ + name: `e2e-admin-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + + expect(result.id).toBeTruthy() + expect(result.password).toBeTruthy() + expect(result.password.length).toBe(48) + expect(result.name).toMatch(/^e2e-admin-/) + expect(result.status).toBe('active') + expect(result.permissions).toBe(String(1n << 16n)) + + createdTokenIds.push(result.id) + if (result.owner?.id) { + createdBotUserIds.push(result.owner.id) + } + }) + + it('should list active tokens by default and include revoked when requested', async () => { + const tokenName = `e2e-admin-${faker.string.uuid()}` + const created = await service.create({ + name: tokenName, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + const activeOnly = await service.list(false) + expect(activeOnly.some(t => t.id === created.id)).toBe(true) + expect(activeOnly.every(t => t.status === 'active')).toBe(true) + + const withRevoked = await service.list(true) + expect(withRevoked.length).toBeGreaterThanOrEqual(activeOnly.length) + }) + + it('should revoke a token and exclude it from active list', async () => { + const created = await service.create({ + name: `e2e-revoke-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + await service.revoke(created.id) + + const activeOnly = await service.list(false) + expect(activeOnly.some(t => t.id === created.id)).toBe(false) + + const withRevoked = await service.list(true) + const revokedToken = withRevoked.find(t => t.id === created.id) + expect(revokedToken).toBeTruthy() + expect(revokedToken?.status).toBe('revoked') + }) + + it('should reject creation with expiration date in the past', async () => { + const yesterday = new Date(Date.now() - 86400000) + await expect(service.create({ + name: `e2e-expired-${faker.string.uuid()}`, + permissions: '0', + expirationDate: yesterday, + })).rejects.toThrow('Date d\'expiration trop courte') + }) + + it('should persist a sha256 hash of the password in DB', async () => { + const created = await service.create({ + name: `e2e-hash-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + const stored = await prisma.adminToken.findUniqueOrThrow({ + where: { id: created.id }, + select: { hash: true }, + }) + + expect(stored.hash).toMatch(/^[0-9a-f]{64}$/) + expect(stored.hash).not.toContain(created.password) + }) + + it('should omit hash from API responses', async () => { + const created = await service.create({ + name: `e2e-omit-${faker.string.uuid()}`, + permissions: String(1n << 16n), + expirationDate: null, + }) + createdTokenIds.push(created.id) + if (created.owner?.id) createdBotUserIds.push(created.owner.id) + + expect(created).not.toHaveProperty('hash') + + const listed = await service.list(true) + const found = listed.find(t => t.id === created.id) + expect(found).toBeTruthy() + expect(found).not.toHaveProperty('hash') + }) +}) diff --git a/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'