Skip to content
Draft
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: 1 addition & 1 deletion .github/workflows/job-tests-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ jobs:
-Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }}
-Dsonar.sources=apps,packages
-Dsonar.javascript.node.maxspace=4096
-Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info
-Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/apps/server-nestjs/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info
-Dsonar.coverage.exclusions=**/*.spec.js,**/*.spec.ts,**/*.vue,**/assets/**,**/packages/test-utils/**,apps/server/src/plugins/**
-Dsonar.cpd.exclusions=**/*.spec.js,**/*.spec.ts
-Dsonar.scm.provider=git
Expand Down
4 changes: 4 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
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'
Expand All @@ -15,11 +16,13 @@ import { ProjectServicesModule } from './modules/project-services/project-servic
import { ProjectModule } from './modules/project/project.module'
import { ServiceChainModule } from './modules/service-chain/service-chain.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { UserTokensModule } from './modules/user-tokens/user-tokens.module'
import { VersionModule } from './modules/version/version.module'

@Module({
imports: [
InfrastructureModule,
AdminTokenModule,
HealthzModule,
KeycloakModule,
ScheduleModule.forRoot(),
Expand All @@ -35,6 +38,7 @@ import { VersionModule } from './modules/version/version.module'
LogModule,
DeploymentModule,
EnvironmentModule,
UserTokensModule,
VersionModule,
],
controllers: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Prisma.TransactionClient>

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

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)
})
})
})
Original file line number Diff line number Diff line change
@@ -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(),
},
})
}
Original file line number Diff line number Diff line change
@@ -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<void> {
return this.service.revoke(tokenId)
}
}
12 changes: 12 additions & 0 deletions apps/server-nestjs/src/modules/admin-token/admin-token.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
129 changes: 129 additions & 0 deletions apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<PrismaService>

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

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<PrismaService>()
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,
},
})
})
})
})
Loading
Loading