Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ProjectModule } from './modules/project/project.module'
import { RepositoryModule } from './modules/repository/repository.module'
import { SystemConfigModule } from './modules/system-config/system-config.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { UserTokensModule } from './modules/user-tokens/user-tokens.module'
import { VersionModule } from './modules/version/version.module'
import { getDotenvPaths } from './utils/dotenv.utils'

Expand Down Expand Up @@ -50,6 +51,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
ScheduleModule.forRoot(),
SystemConfigModule,
SystemSettingsModule,
UserTokensModule,
VersionModule,
],
controllers: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { AdminRole } from './admin-role-queries.utils'
import type { AdminRoleService } from './admin-role.service'
import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils'

export function makeAdminRole(overrides: Partial<AdminRole> = {}): AdminRole {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,8 @@ describe('gitlabService', () => {
const staleSecret = makeVaultSecret({
data: { MIRROR_USER: accessToken.name, MIRROR_TOKEN: accessToken.token },
metadata: {
created_time: faker.date.past({ years: 2 }).toISOString(),
// Birthday-style age, always decades past the 250d rotation threshold.
created_time: faker.date.birthdate({ min: 45, max: 55, mode: 'age' }).toISOString(),
custom_metadata: null,
deletion_time: '',
destroyed: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Prisma } from '@prisma/client'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import {
createUserToken,
listUserTokens,
userTokenSelect,
} from './user-tokens-queries.utils'

describe('user-tokens-queries.utils', () => {
let tx: DeepMockProxy<Prisma.TransactionClient>

beforeEach(() => {
tx = mockDeep<Prisma.TransactionClient>()
})

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 })
})
})
})
Original file line number Diff line number Diff line change
@@ -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 UserTokenRecord = Prisma.PersonalAccessTokenGetPayload<{
select: typeof userTokenSelect
}>

export function listUserTokens(tx: Prisma.TransactionClient, userId: string) {
return tx.personalAccessToken.findMany({
where: { userId },
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
select: userTokenSelect,
})
}

export function createUserToken(tx: Prisma.TransactionClient, data: {
name: string
expirationDate: Date
hash: string
userId: string
}) {
return tx.personalAccessToken.create({
data: {
name: data.name,
hash: data.hash,
expirationDate: data.expirationDate,
userId: data.userId,
},
select: userTokenSelect,
})
}

export function deleteUserToken(tx: Prisma.TransactionClient, where: {
id: string
userId: string
}) {
return tx.personalAccessToken.deleteMany({ where })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { UserContext } from '../infrastructure/auth/auth-user.decorator'
import type { CreatePersonalAccessTokenBody } from './user-tokens.utils'
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'
import { AuthUser } from '../infrastructure/auth/auth-user.decorator'
import { RequireUserType } from '../infrastructure/permission/user/user-type.decorator'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
import { UserTokensService } from './user-tokens.service'
import { CreatePersonalAccessTokenBodySchema } from './user-tokens.utils'

@Controller('api/v1/user/tokens')
@UseGuards(UserGuard)
@RequireUserType('human')
export class UserTokensController {
constructor(@Inject(UserTokensService) private readonly service: UserTokensService) {}

@Get()
async list(@AuthUser() user: UserContext) {
return this.service.list(user.userId)
}

@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@Body(new ZodValidationPipe(CreatePersonalAccessTokenBodySchema)) data: CreatePersonalAccessTokenBody,
@AuthUser() user: UserContext,
) {
return this.service.create(data, user.userId)
}

@Delete(':tokenId')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(
@Param('tokenId', ParseUUIDPipe) tokenId: string,
@AuthUser() user: UserContext,
): Promise<void> {
return this.service.delete(tokenId, user.userId)
}
}
Comment thread
shikanime marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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],
exports: [UserTokensService],
})
export class UserTokensModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { userTokenSelect } from './user-tokens-queries.utils'
import { UserTokensService } from './user-tokens.service'
import { CreatePersonalAccessTokenBodySchema } from './user-tokens.utils'

describe('userTokensService', () => {
let module: TestingModule
let service: UserTokensService
let prisma: DeepMockProxy<PrismaService>

beforeEach(async () => {
prisma = mockDeep<PrismaService>()

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 () => {
Comment thread
shikanime marked this conversation as resolved.
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.future(),
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('rejects a non-parseable expirationDate via the body schema', () => {
const name = faker.word.noun()
const result = CreatePersonalAccessTokenBodySchema.safeParse({ name, expirationDate: 'not-a-date' })
expect(result.success).toBe(false)
})

it('rejects an expirationDate that is too soon via the body schema', () => {
const name = faker.word.noun()
const today = faker.date.recent()
const result = CreatePersonalAccessTokenBodySchema.safeParse({ name, expirationDate: today.toISOString() })
expect(result.success).toBe(false)
})

it('returns created token with plaintext password', async () => {
const userId = faker.string.uuid()
const tokenId = faker.string.uuid()
const tokenName = faker.word.noun()
prisma.personalAccessToken.create.mockResolvedValue({
id: tokenId,
name: tokenName,
lastUse: null,
expirationDate: faker.date.future(),
status: 'active' as const,
createdAt: faker.date.past(),
userId,
hash: faker.string.alphanumeric(64),
})

const result = await service.create({ name: tokenName, expirationDate: faker.date.future() }, userId)

expect(prisma.personalAccessToken.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId,
name: tokenName,
}),
select: userTokenSelect,
}),
)
expect(result.id).toBe(tokenId)
expect(result.password).toBeTruthy()
})

it('rejects an expirationDate that is not at least tomorrow', async () => {
const userId = faker.string.uuid()
const tokenName = faker.word.noun()

await expect(
service.create({ name: tokenName, expirationDate: faker.date.past() }, userId),
).rejects.toThrow('Date d\'expiration trop courte')
expect(prisma.personalAccessToken.create).not.toHaveBeenCalled()
})
})

describe('delete', () => {
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 },
})
})
})
})
Loading