Skip to content

Commit de5e877

Browse files
committed
refactor(admin-roles): migrate from server
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr> Change-Id: I6e2bf93ddac464c88a82683dc6e6b0846a6a6964 Signed-off-by: Shikanime Deva <william.phetsinorath@shikanime.studio>
1 parent 370bcc3 commit de5e877

9 files changed

Lines changed: 501 additions & 4 deletions

File tree

apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,14 @@ et `user` sont des pre-requis pour la Vague 3 (cluster, project-member).
427427
- `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin)
428428
- `PATCH /api/v1/admin/users` - Modification utilisateurs (admin)
429429

430-
**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert)
430+
**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), EventEmitter (`adminRole.upsert`)
431431
**Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`)
432432

433433
**Points d'attention** :
434434
- Module critique : `logViaSession` et `logViaToken` sont consommes par le
435435
systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0),
436436
donc la dependance entrante est deja resolue
437-
- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter
437+
- Evenement utilise (`adminRole.upsert`) : necessite `@nestjs/event-emitter`
438438
- Pre-requis pour `project-member` (Vague 3)
439439

440440
**Estimation** : 2 jours
@@ -457,11 +457,11 @@ et `user` sont des pre-requis pour la Vague 3 (cluster, project-member).
457457
- `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role
458458
- `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role
459459

460-
**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete)
460+
**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), EventEmitter (`adminRole.upsert`, `adminRole.delete`)
461461
**Dependances entrantes** : Aucune
462462

463463
**Points d'attention** :
464-
- Valide le pattern hooks + EventEmitter dans un contexte admin
464+
- Valide le pattern EventEmitter dans un contexte admin
465465
- Permissions admin (bitmask) : valide `UserGuard`
466466
- Queries a internaliser dans le module
467467

apps/server-nestjs/src/main.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module } from '@nestjs/common'
22
import { ScheduleModule } from '@nestjs/schedule'
3+
import { AdminRoleModule } from './modules/admin-role/admin-role.module'
34
import { DeploymentModule } from './modules/deployment/deployment.module'
45
import { EnvironmentModule } from './modules/environment/environment.module'
56
import { HealthzModule } from './modules/healthz/healthz.module'
@@ -19,6 +20,7 @@ import { VersionModule } from './modules/version/version.module'
1920

2021
@Module({
2122
imports: [
23+
AdminRoleModule,
2224
InfrastructureModule,
2325
HealthzModule,
2426
KeycloakModule,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Prisma } from '@prisma/client'
2+
3+
export const adminRoleSelect = {
4+
id: true,
5+
name: true,
6+
permissions: true,
7+
position: true,
8+
oidcGroup: true,
9+
type: true,
10+
} satisfies Prisma.AdminRoleSelect
11+
12+
export type AdminRole = Prisma.AdminRoleGetPayload<{
13+
select: typeof adminRoleSelect
14+
}>
15+
16+
export async function getAdminRoleMaxPosition(tx: Prisma.TransactionClient): Promise<number> {
17+
const role = await tx.adminRole.findFirst({
18+
orderBy: { position: 'desc' },
19+
select: { position: true },
20+
})
21+
22+
return role?.position ?? -1
23+
}
24+
25+
export async function createAdminRole(tx: Prisma.TransactionClient, name: string): Promise<{ role: AdminRole, members: { id: string, email: string, firstName: string, lastName: string }[] }> {
26+
const maxPosition = await getAdminRoleMaxPosition(tx)
27+
const role = await tx.adminRole.create({
28+
data: {
29+
name,
30+
permissions: 0n,
31+
position: maxPosition + 1,
32+
},
33+
select: adminRoleSelect,
34+
})
35+
36+
const members = await tx.user.findMany({
37+
where: { adminRoleIds: { has: role.id } },
38+
select: { id: true, email: true, firstName: true, lastName: true },
39+
})
40+
41+
return { role, members }
42+
}
43+
44+
export async function getAdminRoleMemberCounts(tx: Prisma.TransactionClient): Promise<Record<string, number>> {
45+
const roles = await tx.adminRole.findMany({
46+
where: { oidcGroup: { equals: '' } },
47+
select: { id: true },
48+
})
49+
const roleIds = roles.map(role => role.id)
50+
const users = await tx.user.findMany({
51+
where: { adminRoleIds: { hasSome: roleIds } },
52+
select: { adminRoleIds: true },
53+
})
54+
55+
const counts: Record<string, number> = Object.fromEntries(roleIds.map(roleId => [roleId, 0]))
56+
for (const { adminRoleIds } of users) {
57+
for (const roleId of adminRoleIds) {
58+
if (typeof counts[roleId] === 'number') counts[roleId]++
59+
}
60+
}
61+
62+
return counts
63+
}
64+
65+
export async function getRoles(tx: Prisma.TransactionClient): Promise<AdminRole[]> {
66+
return await tx.adminRole.findMany({
67+
orderBy: { position: 'asc' },
68+
select: adminRoleSelect,
69+
})
70+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { AdminRole } from './admin-role-queries.utils'
2+
import type { AdminRoleService } from './admin-role.service'
3+
import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils'
4+
5+
export type AdminRoleContract = Parameters<AdminRoleService['patch']>[0][number]
6+
export type AdminRoleResponse = NonNullable<Awaited<ReturnType<AdminRoleService['list']>>>[number]
7+
8+
export interface AdminRoleMember {
9+
id: string
10+
email: string
11+
firstName: string
12+
lastName: string
13+
}
14+
15+
export function makeAdminRole(overrides: Partial<AdminRole> = {}): AdminRole {
16+
return {
17+
id: overrides.id ?? crypto.randomUUID(),
18+
name: overrides.name ?? 'New role',
19+
permissions: overrides.permissions ?? 0n,
20+
position: overrides.position ?? 0,
21+
oidcGroup: overrides.oidcGroup ?? '',
22+
type: overrides.type ?? 'managed',
23+
...overrides,
24+
}
25+
}
26+
27+
export function makeAdminRoleMember(overrides: Partial<AdminRoleMember> = {}): AdminRoleMember {
28+
return {
29+
id: crypto.randomUUID(),
30+
email: 'user@example.com',
31+
firstName: 'First',
32+
lastName: 'Last',
33+
...overrides,
34+
}
35+
}
36+
37+
export function makeCreateAdminRoleBody(overrides: { name?: string } = {}): CreateAdminRoleBody {
38+
return {
39+
name: overrides.name ?? 'New role',
40+
}
41+
}
42+
43+
export function makePatchAdminRoleBody(
44+
role: AdminRole,
45+
overrides: Partial<PatchAdminRolesBody[number]> = {},
46+
): PatchAdminRolesBody[number] {
47+
return {
48+
id: role.id,
49+
name: overrides.name ?? role.name,
50+
permissions:
51+
overrides.permissions
52+
?? (typeof role.permissions === 'bigint' ? role.permissions.toString() : String(role.permissions)),
53+
position: overrides.position ?? role.position,
54+
oidcGroup: overrides.oidcGroup ?? role.oidcGroup,
55+
type: overrides.type ?? role.type,
56+
...overrides,
57+
}
58+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { AdminRole } from '@cpn-console/shared'
2+
import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils'
3+
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Patch, Post, 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 { AdminRoleService } from './admin-role.service'
8+
import { CreateAdminRoleBodySchema, PatchAdminRolesBodySchema } from './admin-role.utils'
9+
10+
@Controller('api/v1/admin/roles')
11+
export class AdminRoleController {
12+
constructor(
13+
@Inject(AdminRoleService) private readonly adminRoleService: AdminRoleService,
14+
) {}
15+
16+
@Get('')
17+
@UseGuards(UserGuard)
18+
// TODO: ListRoles is intentionally not protected by admin permission because of
19+
// certain behaviours of the legacy client
20+
// @RequireAdminPermission('ListRoles')
21+
async listAdminRoles(): Promise<AdminRole[]> {
22+
return this.adminRoleService.list()
23+
}
24+
25+
@Post('')
26+
@HttpCode(HttpStatus.CREATED)
27+
@UseGuards(UserGuard)
28+
@RequireAdminPermission('ManageRoles')
29+
async createAdminRole(
30+
@Body(new ZodValidationPipe(CreateAdminRoleBodySchema)) body: CreateAdminRoleBody,
31+
): Promise<AdminRole> {
32+
return this.adminRoleService.create(body)
33+
}
34+
35+
@Patch('')
36+
@HttpCode(HttpStatus.OK)
37+
@UseGuards(UserGuard)
38+
@RequireAdminPermission('ManageRoles')
39+
async patchAdminRoles(
40+
@Body(new ZodValidationPipe(PatchAdminRolesBodySchema)) body: PatchAdminRolesBody,
41+
): Promise<AdminRole[]> {
42+
return this.adminRoleService.patch(body)
43+
}
44+
45+
@Get('member-counts')
46+
@UseGuards(UserGuard)
47+
@RequireAdminPermission('ManageRoles')
48+
async adminRoleMemberCounts(): Promise<Record<string, number>> {
49+
return this.adminRoleService.memberCounts()
50+
}
51+
52+
@Delete(':roleId')
53+
@HttpCode(HttpStatus.NO_CONTENT)
54+
@UseGuards(UserGuard)
55+
@RequireAdminPermission('ManageRoles')
56+
async deleteAdminRole(
57+
@Param('roleId', ParseUUIDPipe) roleId: string,
58+
): Promise<void> {
59+
await this.adminRoleService.delete(roleId)
60+
}
61+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common'
2+
import { AuthModule } from '../infrastructure/auth/auth.module'
3+
import { InfrastructureModule } from '../infrastructure/infrastructure.module'
4+
import { AdminRoleController } from './admin-role.controller'
5+
import { AdminRoleService } from './admin-role.service'
6+
7+
@Module({
8+
imports: [InfrastructureModule, AuthModule],
9+
controllers: [AdminRoleController],
10+
providers: [AdminRoleService],
11+
exports: [AdminRoleService],
12+
})
13+
export class AdminRoleModule {}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { EventEmitter2 } from '@nestjs/event-emitter'
2+
import type { PrismaService } from '../infrastructure/database/prisma.service'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { mockDeep } from 'vitest-mock-extended'
5+
import {
6+
makeAdminRole,
7+
makeCreateAdminRoleBody,
8+
} from './admin-role-testing.utils'
9+
import { AdminRoleService } from './admin-role.service'
10+
11+
describe('adminRoleService', () => {
12+
let prisma: ReturnType<typeof mockDeep<PrismaService>>
13+
let eventEmitter: ReturnType<typeof mockDeep<EventEmitter2>>
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks()
17+
prisma = mockDeep<PrismaService>()
18+
eventEmitter = mockDeep<EventEmitter2>()
19+
eventEmitter.emitAsync.mockResolvedValue([])
20+
})
21+
22+
it('creates a role at the next position and returns the created role', async () => {
23+
const existingRole = makeAdminRole({
24+
position: 5,
25+
permissions: 4n,
26+
type: 'managed',
27+
})
28+
29+
const createdRole = makeAdminRole({
30+
...existingRole,
31+
name: 'New role',
32+
position: 6,
33+
permissions: 0n,
34+
})
35+
36+
prisma.adminRole.findFirst.mockResolvedValue(existingRole)
37+
prisma.adminRole.create.mockResolvedValue(createdRole)
38+
prisma.adminRole.findUnique.mockResolvedValue(createdRole)
39+
prisma.user.findMany.mockResolvedValue([])
40+
prisma.$transaction.mockImplementation(async callback => callback(prisma))
41+
42+
const createBody = makeCreateAdminRoleBody({ name: 'New role' })
43+
const service = new AdminRoleService(prisma, eventEmitter)
44+
const result = await service.create(createBody)
45+
46+
expect(result).toEqual(
47+
expect.objectContaining({
48+
id: existingRole.id,
49+
permissions: '0',
50+
position: 6,
51+
}),
52+
)
53+
expect(prisma.adminRole.create).toHaveBeenCalledWith({
54+
data: {
55+
name: 'New role',
56+
permissions: 0n,
57+
position: 6,
58+
},
59+
select: {
60+
id: true,
61+
name: true,
62+
oidcGroup: true,
63+
permissions: true,
64+
position: true,
65+
type: true,
66+
},
67+
})
68+
expect(eventEmitter.emitAsync).toHaveBeenCalledWith('adminRole.upsert', {
69+
id: existingRole.id,
70+
name: 'New role',
71+
oidcGroup: '',
72+
permissions: 0n,
73+
position: 6,
74+
type: 'managed',
75+
members: [],
76+
})
77+
})
78+
})

0 commit comments

Comments
 (0)