Skip to content

Commit 89babfe

Browse files
committed
refactor(admin-token): migrate from server
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr> Change-Id: I533af72eddbb6eae404c53c73610fd4c6a6a6964
1 parent 64072bd commit 89babfe

8 files changed

Lines changed: 586 additions & 0 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { Prisma } from '@prisma/client'
2+
import type { DeepMockProxy } from 'vitest-mock-extended'
3+
import { faker } from '@faker-js/faker'
4+
import { beforeEach, describe, expect, it } from 'vitest'
5+
import { mockDeep } from 'vitest-mock-extended'
6+
import {
7+
adminTokenSelect,
8+
createAdminToken,
9+
createBotUser,
10+
listAdminTokens,
11+
revokeAdminToken,
12+
} from './admin-token-queries.utils'
13+
14+
describe('admin-token-queries.utils', () => {
15+
let tx: DeepMockProxy<Prisma.TransactionClient>
16+
17+
beforeEach(() => {
18+
tx = mockDeep<Prisma.TransactionClient>()
19+
})
20+
21+
describe('listAdminTokens', () => {
22+
it('filters to active tokens by default', async () => {
23+
tx.adminToken.findMany.mockResolvedValue([])
24+
25+
await listAdminTokens(tx, false)
26+
27+
expect(tx.adminToken.findMany).toHaveBeenCalledWith(
28+
expect.objectContaining({ where: { status: 'active' }, select: adminTokenSelect }),
29+
)
30+
})
31+
32+
it('includes revoked tokens when withRevoked is true', async () => {
33+
tx.adminToken.findMany.mockResolvedValue([])
34+
35+
await listAdminTokens(tx, true)
36+
37+
expect(tx.adminToken.findMany).toHaveBeenCalledWith(
38+
expect.objectContaining({ where: { status: { in: ['active', 'revoked'] } }, select: adminTokenSelect }),
39+
)
40+
})
41+
})
42+
43+
describe('createBotUser', () => {
44+
it('creates a bot user with a derived email', async () => {
45+
const botUserId = faker.string.uuid()
46+
const name = faker.person.fullName()
47+
48+
await createBotUser(tx, { botUserId, name })
49+
50+
expect(tx.user.create).toHaveBeenCalledWith({
51+
data: {
52+
firstName: 'Bot Admin',
53+
lastName: name,
54+
type: 'bot',
55+
id: botUserId,
56+
email: `${botUserId}@bot.io`,
57+
},
58+
})
59+
})
60+
})
61+
62+
describe('createAdminToken', () => {
63+
it('creates an admin token with the provided fields', async () => {
64+
const data = {
65+
name: faker.word.noun(),
66+
permissions: 4n,
67+
expirationDate: null,
68+
hash: faker.string.alphanumeric(64),
69+
userId: faker.string.uuid(),
70+
}
71+
72+
await createAdminToken(tx, data)
73+
74+
expect(tx.adminToken.create).toHaveBeenCalledWith({ data, select: adminTokenSelect })
75+
})
76+
})
77+
78+
describe('revokeAdminToken', () => {
79+
it('marks the token revoked with a fresh expiration date', async () => {
80+
const id = faker.string.uuid()
81+
82+
await revokeAdminToken(tx, id)
83+
84+
const call = tx.adminToken.updateMany.mock.calls[0]?.[0]
85+
expect(call?.where).toEqual({ id })
86+
expect(call?.data.status).toBe('revoked')
87+
expect(call?.data.expirationDate).toBeInstanceOf(Date)
88+
})
89+
})
90+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Prisma } from '@prisma/client'
2+
3+
export const adminTokenOwnerSelect = {
4+
id: true,
5+
email: true,
6+
firstName: true,
7+
lastName: true,
8+
type: true,
9+
} satisfies Prisma.UserSelect
10+
11+
export const adminTokenSelect = {
12+
id: true,
13+
name: true,
14+
permissions: true,
15+
lastUse: true,
16+
expirationDate: true,
17+
status: true,
18+
createdAt: true,
19+
userId: true,
20+
owner: {
21+
select: adminTokenOwnerSelect,
22+
},
23+
} satisfies Prisma.AdminTokenSelect
24+
25+
export type AdminTokenRecord = Prisma.AdminTokenGetPayload<{
26+
select: typeof adminTokenSelect
27+
}>
28+
29+
export function listAdminTokens(tx: Prisma.TransactionClient, withRevoked: boolean) {
30+
const where: Prisma.AdminTokenWhereInput = withRevoked
31+
? { status: { in: ['active', 'revoked'] } }
32+
: { status: 'active' }
33+
34+
return tx.adminToken.findMany({
35+
where,
36+
select: adminTokenSelect,
37+
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
38+
})
39+
}
40+
41+
export function createBotUser(tx: Prisma.TransactionClient, data: { botUserId: string, name: string }) {
42+
return tx.user.create({
43+
data: {
44+
firstName: 'Bot Admin',
45+
lastName: data.name,
46+
type: 'bot',
47+
id: data.botUserId,
48+
email: `${data.botUserId}@bot.io`,
49+
},
50+
})
51+
}
52+
53+
export function createAdminToken(tx: Prisma.TransactionClient, data: {
54+
name: string
55+
permissions: bigint
56+
expirationDate: Date | null
57+
hash: string
58+
userId: string
59+
}) {
60+
return tx.adminToken.create({
61+
data: {
62+
name: data.name,
63+
permissions: data.permissions,
64+
expirationDate: data.expirationDate,
65+
hash: data.hash,
66+
userId: data.userId,
67+
},
68+
select: adminTokenSelect,
69+
})
70+
}
71+
72+
export function revokeAdminToken(tx: Prisma.TransactionClient, id: string) {
73+
return tx.adminToken.updateMany({
74+
where: { id },
75+
data: {
76+
status: 'revoked',
77+
expirationDate: new Date(),
78+
},
79+
})
80+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { CreateAdminTokenBody } from './admin-token.utils'
2+
import { CoerceBooleanSchema } from '@cpn-console/shared'
3+
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Query, UseGuards } from '@nestjs/common'
4+
import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator'
5+
import { UserGuard } from '../infrastructure/permission/user/user.guard'
6+
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
7+
import { AdminTokenService } from './admin-token.service'
8+
import { CreateAdminTokenBodySchema } from './admin-token.utils'
9+
10+
@Controller('api/v1/admin/tokens')
11+
@UseGuards(UserGuard)
12+
export class AdminTokenController {
13+
constructor(@Inject(AdminTokenService) private readonly service: AdminTokenService) {}
14+
15+
@Get()
16+
@RequireAdminPermission('ListAdminToken')
17+
async list(@Query('withRevoked', new ZodValidationPipe(CoerceBooleanSchema)) withRevoked?: boolean) {
18+
return this.service.list(withRevoked === true)
19+
}
20+
21+
@Post()
22+
@HttpCode(HttpStatus.CREATED)
23+
@RequireAdminPermission('ManageAdminToken')
24+
async create(
25+
@Body(new ZodValidationPipe(CreateAdminTokenBodySchema)) data: CreateAdminTokenBody,
26+
) {
27+
return this.service.create(data)
28+
}
29+
30+
@Delete(':tokenId')
31+
@HttpCode(HttpStatus.NO_CONTENT)
32+
@RequireAdminPermission('ManageAdminToken')
33+
async revoke(@Param('tokenId', ParseUUIDPipe) tokenId: string): Promise<void> {
34+
return this.service.revoke(tokenId)
35+
}
36+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common'
2+
import { InfrastructureModule } from '../infrastructure/infrastructure.module'
3+
import { AdminTokenController } from './admin-token.controller'
4+
import { AdminTokenService } from './admin-token.service'
5+
6+
@Module({
7+
imports: [InfrastructureModule],
8+
controllers: [AdminTokenController],
9+
providers: [AdminTokenService],
10+
exports: [AdminTokenService],
11+
})
12+
export class AdminTokenModule {}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { TestingModule } from '@nestjs/testing'
2+
import type { DeepMockProxy } from 'vitest-mock-extended'
3+
import { faker } from '@faker-js/faker'
4+
import { Test } from '@nestjs/testing'
5+
import { beforeEach, describe, expect, it } from 'vitest'
6+
import { mockDeep } from 'vitest-mock-extended'
7+
import { PrismaService } from '../infrastructure/database/prisma.service'
8+
import { AdminTokenService } from './admin-token.service'
9+
import { CreateAdminTokenBodySchema } from './admin-token.utils'
10+
11+
describe('adminTokenService', () => {
12+
let module: TestingModule
13+
let service: AdminTokenService
14+
let prisma: DeepMockProxy<PrismaService>
15+
16+
beforeEach(async () => {
17+
prisma = mockDeep<PrismaService>()
18+
19+
module = await Test.createTestingModule({
20+
providers: [
21+
AdminTokenService,
22+
{ provide: PrismaService, useValue: prisma },
23+
],
24+
}).compile()
25+
26+
service = module.get(AdminTokenService)
27+
})
28+
29+
describe('list', () => {
30+
it('returns active tokens with permissions serialized as string', async () => {
31+
const tokenId = faker.string.uuid()
32+
const userId = faker.string.uuid()
33+
prisma.adminToken.findMany.mockResolvedValue([{
34+
id: tokenId,
35+
name: 'my-token',
36+
permissions: 4n,
37+
lastUse: null,
38+
expirationDate: null,
39+
status: 'active' as const,
40+
createdAt: faker.date.past(),
41+
userId,
42+
hash: 'hash-1',
43+
}])
44+
45+
const result = await service.list()
46+
47+
expect(prisma.adminToken.findMany).toHaveBeenCalled()
48+
expect(result).toHaveLength(1)
49+
expect(result[0].id).toBe(tokenId)
50+
expect(result[0].permissions).toBe('4')
51+
})
52+
53+
it('includes revoked tokens when withRevoked is true', async () => {
54+
prisma.adminToken.findMany.mockResolvedValue([])
55+
56+
await service.list(true)
57+
58+
const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0]
59+
expect(callArgs?.where).toEqual({ status: { in: ['active', 'revoked'] } })
60+
})
61+
62+
it('filters to active only by default', async () => {
63+
prisma.adminToken.findMany.mockResolvedValue([])
64+
65+
await service.list()
66+
67+
const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0]
68+
expect(callArgs?.where).toEqual({ status: 'active' })
69+
})
70+
})
71+
72+
describe('create', () => {
73+
it('rejects a non-parseable expirationDate via the body schema', () => {
74+
const result = CreateAdminTokenBodySchema.safeParse({ name: 'x', permissions: '4', expirationDate: 'not-a-date' })
75+
expect(result.success).toBe(false)
76+
})
77+
78+
it('rejects an expirationDate that is too soon via the body schema', () => {
79+
const today = faker.date.recent()
80+
const result = CreateAdminTokenBodySchema.safeParse({ name: 'x', permissions: '4', expirationDate: today.toISOString() })
81+
expect(result.success).toBe(false)
82+
})
83+
84+
it('returns created token with plaintext password and serialized permissions', async () => {
85+
const tokenId = faker.string.uuid()
86+
const botUserId = faker.string.uuid()
87+
const tx = mockDeep<PrismaService>()
88+
tx.user.create.mockResolvedValue({ id: botUserId, firstName: 'Bot Admin', lastName: 'my-token', type: 'bot', email: 'x@bot.io' } as never)
89+
tx.adminToken.create.mockResolvedValue({
90+
id: tokenId,
91+
name: 'my-token',
92+
permissions: 2n,
93+
lastUse: null,
94+
expirationDate: null,
95+
status: 'active' as const,
96+
createdAt: faker.date.past(),
97+
userId: botUserId,
98+
hash: 'hash-2',
99+
})
100+
prisma.$transaction.mockImplementation(async fn => fn(tx))
101+
102+
const result = await service.create({ name: 'my-token', permissions: '2', expirationDate: null })
103+
104+
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function))
105+
expect(tx.user.create).toHaveBeenCalled()
106+
expect(tx.adminToken.create).toHaveBeenCalled()
107+
expect(result.id).toBe(tokenId)
108+
expect(result.password).toBeTruthy()
109+
expect(result.permissions).toBe('2')
110+
})
111+
})
112+
113+
describe('revoke', () => {
114+
it('sets status to revoked and expiration date to now', async () => {
115+
const tokenId = faker.string.uuid()
116+
prisma.adminToken.updateMany.mockResolvedValue({ count: 1 } as never)
117+
118+
await service.revoke(tokenId)
119+
120+
expect(prisma.adminToken.updateMany).toHaveBeenCalledWith({
121+
where: { id: tokenId },
122+
data: {
123+
status: 'revoked',
124+
expirationDate: expect.any(Date) as Date,
125+
},
126+
})
127+
})
128+
})
129+
})

0 commit comments

Comments
 (0)