Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0626db1
fix(admin-token): optional withRevoked, service-level expiration chec…
shikanime Sep 24, 2026
cfaacc4
test(admin-token): lock controller permission matrix
shikanime Sep 25, 2026
35c9251
fix(admin-token): drop invalid guard metadata specs
shikanime Sep 29, 2026
c521713
test(admin-token): assert prisma calls via toHaveBeenCalledWith
shikanime Sep 30, 2026
1fa931a
refactor(admin-token): move permissions mapping to controller
shikanime Sep 30, 2026
cc66d44
refactor(shared): drop duplicate CreateAdminTokenBody export in favor…
shikanime Oct 1, 2026
9b4d31f
test(admin-token): stub list in e2e mock to return an empty page
shikanime Oct 1, 2026
8ca2a5b
fix(admin-token): coerce contract expirationDate to Date at the servi…
shikanime Oct 1, 2026
2ad29bf
test(admin-token): drop controller delegation specs, keep coercion guard
shikanime Oct 1, 2026
be63f4a
test(admin-token): drop unused type alias after delegation-spec removal
shikanime Oct 1, 2026
5052d83
refactor(admin-token): share spec factories in testing utils, drop Re…
shikanime Oct 1, 2026
cd1c173
refactor(admin-token): assemble exposed token in controller
shikanime Oct 2, 2026
1052f68
chore(admin-token): drop tooling tag from test comment
shikanime Oct 2, 2026
1a64017
refactor(admin-token): inline transaction client type
shikanime Oct 2, 2026
e13557e
refactor(admin-token): extract status filter into helper
shikanime Oct 2, 2026
fdab474
refactor(admin-token): rename record type to AdminToken
shikanime Oct 2, 2026
e9a633a
refactor(admin-token): rename test factory to makeAdminToken
shikanime Oct 2, 2026
56a8d37
refactor(admin-token): inline listed-token fixture at its single call…
shikanime Oct 2, 2026
3a60976
fix(admin-token): update fixture import after inlining
shikanime Oct 2, 2026
7dd88b9
refactor(admin-token): split row and selected-payload token fixtures
shikanime Oct 2, 2026
2098928
refactor(admin-token): name the token row fixture type
shikanime Oct 2, 2026
b4e5850
refactor(admin-token): extract makeAdminTokenOwner from makeAdminToken
shikanime Oct 2, 2026
5c5e483
refactor(admin-token): accept owner overrides in makeAdminToken
shikanime Oct 2, 2026
225ee62
refactor(admin-token): extract makeAdminTokenOwner from makeAdminToken
shikanime Oct 2, 2026
a4b0fa4
refactor(admin-token): align queries types with Record naming
shikanime Oct 2, 2026
f530b9e
refactor(admin-token): simplify makeAdminTokenRecord overrides to Par…
shikanime Oct 2, 2026
a1d29e6
refactor(admin-token): rename AdminTokenRow to AdminTokenRecordWithHash
shikanime Oct 2, 2026
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 @@ -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'
Comment thread
shikanime marked this conversation as resolved.
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 Down Expand Up @@ -33,6 +34,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
}),
TerminusModule.forRoot(),
AdminRoleModule,
AdminTokenModule,
DeploymentModule,
EnvironmentModule,
HealthzModule,
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)

expect(tx.adminToken.updateMany).toHaveBeenCalledWith({
where: { id },
data: { status: 'revoked', expirationDate: expect.any(Date) },
})
})
})
})
Original file line number Diff line number Diff line change
@@ -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(),
},
})
}
Original file line number Diff line number Diff line change
@@ -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']> = {}): 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> = {}): 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<AdminTokenRecord> = {}): AdminTokenRecordWithHash {
return { ...makeAdminTokenRecord(overrides), hash: faker.string.hexadecimal({ length: 64 }) }
}
Original file line number Diff line number Diff line change
@@ -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<AdminTokenService>

const token = makeAdminTokenRecord({ permissions: 0n })

beforeEach(async () => {
service = mock<AdminTokenService>()

module = await Test.createTestingModule({
controllers: [AdminTokenController],
providers: [
{ provide: AdminTokenService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<AdminTokenController>(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')
})
})
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
shikanime marked this conversation as resolved.
@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<void> {
return this.service.revoke(tokenId)
}
}
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 {}
Loading
Loading