-
Notifications
You must be signed in to change notification settings - Fork 8
refactor(user): migrate admin routes from server #2738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
deac280
fix(user): legacy 400 on unknown admin role, matching take:5, hook un…
shikanime 8c0601a
chore(user): drop section divider comments
shikanime 75db766
refactor(user): realigner structure et style du module
shikanime 777b376
refactor(user): retirer l'alias AllUsersQuery au profit du schéma
shikanime 105596c
test(user): lock controller permission matrix
shikanime c80ddfc
fix(user): purge dead create path and lock legacy parity
shikanime 862e6d7
refactor(user): remove reflection-only permissions metadata check
shikanime 2936564
fix(user): emit canonical AdminRoleEventPayload and type controller i…
shikanime e210617
style(user): drop mock-wiring narration comment
shikanime e305dc0
refactor(user): batch AND filters into single pushes
shikanime 63345cb
fix(user): use the projectMembers relation key in memberOfIds filter
shikanime a2cf7f4
fix(user): legacy 400 on unknown admin role, matching take:5, hook un…
shikanime c00e206
chore(user): drop section divider comments
shikanime effb5d6
refactor(user): realigner structure et style du module
shikanime cac74d2
refactor(user): retirer l'alias AllUsersQuery au profit du schéma
shikanime caecf77
test(user): lock controller permission matrix
shikanime 651e6d6
fix(user): purge dead create path and lock legacy parity
shikanime 8d0dc83
refactor(user): remove reflection-only permissions metadata check
shikanime 74133da
fix(user): emit canonical AdminRoleEventPayload and type controller i…
shikanime ba5e73d
style(user): drop mock-wiring narration comment
shikanime 9d8cef6
refactor(user): move the letters filter inside its guard
shikanime 8c1a923
fix(user): restrict UserGuard to list and patch routes
shikanime 8731b8a
test(user): drop guard metadata specs
shikanime 5a117f4
fix(user): use the projectMembers relation key in memberOfIds filter
shikanime File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' | ||
| import type { Prisma, User } from '@prisma/client' | ||
| import type { z } from 'zod' | ||
| import type { PrismaService } from '../infrastructure/database/prisma.service' | ||
| import { BadRequestException } from '@nestjs/common' | ||
|
|
||
| export function getUsers(tx: Prisma.TransactionClient, where?: Prisma.UserWhereInput) { | ||
| return tx.user.findMany({ where }) | ||
| } | ||
|
|
||
| export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.UserWhereInput) { | ||
| return tx.user.findMany({ | ||
| where, | ||
| take: 5, | ||
| }) | ||
| } | ||
|
|
||
| export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { | ||
| return tx.adminRole.findMany({ where: { name: { in: names } } }) | ||
| } | ||
|
|
||
| export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['id'], adminRoleIds: string[]) { | ||
| return tx.user.update({ | ||
| where: { id }, | ||
| data: { adminRoleIds }, | ||
| }) | ||
| } | ||
|
|
||
| export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) { | ||
| return Promise.all(users | ||
| .filter((user): user is typeof user & { adminRoleIds: string[] } => user.adminRoleIds !== null) | ||
| .map(user => updateUserAdminRoleIds(tx, user.id, user.adminRoleIds))) | ||
| } | ||
|
|
||
| export async function buildAllUsersWhere( | ||
| prisma: PrismaService, | ||
| { relationType: relation, ...query }: z.infer<typeof AllUsersQuerySchema>, | ||
| ): Promise<Prisma.UserWhereInput> { | ||
| const relationType = relation ?? 'AND' | ||
| const whereInputs: Prisma.UserWhereInput[] = [] | ||
| if (query.adminRoleIds?.length) { | ||
| whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) | ||
| } | ||
| if (query.adminRoles?.length) { | ||
| const roles = await getAdminRolesByName(prisma, query.adminRoles) | ||
| const adminRoleNameNotFound = query.adminRoles.find(nameQueried => !roles.some(({ name }) => name === nameQueried)) | ||
| if (adminRoleNameNotFound) { | ||
| throw new BadRequestException(`Unable to find adminRole ${adminRoleNameNotFound}`) | ||
| } | ||
| whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } }) | ||
| } | ||
| if (query.memberOfIds) { | ||
| whereInputs.push({ | ||
| AND: query.memberOfIds.map(id => ({ | ||
| OR: [ | ||
| { projectsOwned: { some: { id } } }, | ||
| { projectMembers: { some: { project: { id } } } }, | ||
| ], | ||
| })), | ||
| }) | ||
| } | ||
| return { [relationType]: whereInputs } | ||
| } | ||
|
|
||
| export function buildMatchingUsersWhere(query: LettersQuery): Prisma.UserWhereInput { | ||
| const AND: Prisma.UserWhereInput[] = [] | ||
| if (query.notInProjectId) { | ||
| AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) | ||
| AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) | ||
| } | ||
| if (query.letters) { | ||
| const filter = { contains: query.letters, mode: 'insensitive' } as const | ||
| AND.push({ | ||
| OR: [{ | ||
| email: filter, | ||
| }, { | ||
| firstName: filter, | ||
| }, { | ||
| lastName: filter, | ||
| }], | ||
| }) | ||
| AND.push({ type: 'human' }) | ||
| } | ||
| return { AND } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { AdminRole, User } from '@prisma/client' | ||
| import { faker } from '@faker-js/faker' | ||
|
|
||
| export function makeUser(overrides: Partial<User> = {}): User { | ||
| return { | ||
| id: faker.string.uuid(), | ||
| email: faker.internet.email(), | ||
| firstName: faker.person.firstName(), | ||
| lastName: faker.person.lastName(), | ||
| type: 'human', | ||
| adminRoleIds: [], | ||
| lastLogin: faker.date.past(), | ||
| createdAt: faker.date.past(), | ||
| updatedAt: faker.date.recent(), | ||
| ...overrides, | ||
| } satisfies User | ||
| } | ||
|
|
||
| export function makeAdminRole(overrides: Partial<AdminRole> = {}): AdminRole { | ||
| return { | ||
| id: faker.string.uuid(), | ||
| name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(), | ||
| permissions: 0n, | ||
| position: faker.number.int({ min: 0, max: 100 }), | ||
| oidcGroup: '', | ||
| type: 'managed', | ||
| ...overrides, | ||
| } satisfies AdminRole | ||
| } |
65 changes: 65 additions & 0 deletions
65
apps/server-nestjs/src/modules/user/user.controller.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import type { TestingModule } from '@nestjs/testing' | ||
| import type { MockProxy } from 'vitest-mock-extended' | ||
| import { AllUsersQuerySchema } from '@cpn-console/shared' | ||
| 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 { makeUser } from './user-testing.utils' | ||
| import { UserController } from './user.controller' | ||
| import { UserService } from './user.service' | ||
| import { toContractUser } from './user.utils' | ||
|
|
||
| describe('userController', () => { | ||
| let module: TestingModule | ||
| let controller: UserController | ||
| let service: MockProxy<UserService> | ||
|
|
||
| beforeEach(async () => { | ||
| service = mock<UserService>() | ||
|
|
||
| module = await Test.createTestingModule({ | ||
| controllers: [UserController], | ||
| providers: [ | ||
| { provide: UserService, useValue: service }, | ||
| ], | ||
| }) | ||
| .overrideGuard(UserGuard) | ||
| .useValue({ canActivate: () => true }) | ||
| .compile() | ||
|
|
||
| controller = module.get<UserController>(UserController) | ||
| }) | ||
|
|
||
| it('should be defined', () => { | ||
| expect(controller).toBeDefined() | ||
| }) | ||
|
|
||
| it('delegates list with relationType defaulted to AND and stripped from the query', async () => { | ||
| const users = [makeUser()] | ||
| service.getAllUsers.mockResolvedValue(users) | ||
|
|
||
| const result = await controller.getAllUsers(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) | ||
|
|
||
| expect(result).toEqual(users.map(toContractUser)) | ||
| expect(service.getAllUsers).toHaveBeenCalledWith(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) | ||
| }) | ||
|
|
||
| it('delegates matching query to the service', async () => { | ||
| const users = [makeUser()] | ||
| service.getMatchingUsers.mockResolvedValue(users) | ||
| const query = { letters: 'ab' } | ||
|
|
||
| expect(await controller.getMatchingUsers(query)).toEqual(users.map(toContractUser)) | ||
| expect(service.getMatchingUsers).toHaveBeenCalledWith(query) | ||
| }) | ||
|
|
||
| it('delegates patch body to the service', async () => { | ||
| const users = [makeUser()] | ||
| service.patchUsers.mockResolvedValue(users) | ||
| const body = [{ id: users[0].id, adminRoleIds: ['r1'] }] | ||
|
|
||
| expect(await controller.patchUsers(body)).toEqual(users.map(toContractUser)) | ||
| expect(service.patchUsers).toHaveBeenCalledWith(body) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import type { AllUsers, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared' | ||
| import type { z } from 'zod' | ||
| import { AllUsersQuerySchema, MatchingUsersQuerySchema, PatchUsersBodySchema } from '@cpn-console/shared' | ||
| import { Body, Controller, Get, Inject, Patch, 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 { UserService } from './user.service' | ||
| import { toContractUser } from './user.utils' | ||
|
|
||
| @Controller('api/v1/users') | ||
| export class UserController { | ||
| constructor(@Inject(UserService) private readonly userService: UserService) {} | ||
|
|
||
| @Get() | ||
| @UseGuards(UserGuard) | ||
| @RequireAdminPermission('ManageUsers') | ||
| async getAllUsers( | ||
| @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer<typeof AllUsersQuerySchema>, | ||
| ): Promise<AllUsers> { | ||
| const users = await this.userService.getAllUsers(query) | ||
| return users.map(toContractUser) | ||
| } | ||
|
|
||
| @Get('matching') | ||
| async getMatchingUsers( | ||
| @Query(new ZodValidationPipe(MatchingUsersQuerySchema)) query: LettersQuery, | ||
| ): Promise<MatchingUsers> { | ||
| const users = await this.userService.getMatchingUsers(query) | ||
| return users.map(toContractUser) | ||
| } | ||
|
|
||
| @Patch() | ||
| @UseGuards(UserGuard) | ||
| @RequireAdminPermission('ManageUsers') | ||
| async patchUsers( | ||
| @Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody, | ||
| ): Promise<PatchUsers> { | ||
| const updatedUsers = await this.userService.patchUsers(users) | ||
| return updatedUsers.map(toContractUser) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { UserController } from './user.controller' | ||
| import { UserService } from './user.service' | ||
|
|
||
| @Module({ | ||
| imports: [InfrastructureModule], | ||
| controllers: [UserController], | ||
| providers: [UserService], | ||
| exports: [UserService], | ||
| }) | ||
| export class UserModule {} |
110 changes: 110 additions & 0 deletions
110
apps/server-nestjs/src/modules/user/user.service.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import type { Prisma } from '@prisma/client' | ||
| import type { DeepMockProxy } from 'vitest-mock-extended' | ||
| import { BadRequestException } from '@nestjs/common' | ||
| import { EventEmitter2 } from '@nestjs/event-emitter' | ||
| 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 { makeAdminRole, makeUser } from './user-testing.utils' | ||
| import { UserService } from './user.service' | ||
|
|
||
| describe('userService', () => { | ||
| let service: UserService | ||
| let prisma: DeepMockProxy<PrismaService> | ||
| let events: DeepMockProxy<EventEmitter2> | ||
|
|
||
| beforeEach(async () => { | ||
| prisma = mockDeep<PrismaService>() | ||
| events = mockDeep<EventEmitter2>() | ||
|
|
||
| const moduleRef = await Test.createTestingModule({ | ||
| providers: [ | ||
| UserService, | ||
| { provide: PrismaService, useValue: prisma }, | ||
| { provide: EventEmitter2, useValue: events }, | ||
| ], | ||
| }).compile() | ||
|
|
||
| service = moduleRef.get(UserService) | ||
| }) | ||
|
|
||
| it('lists all users with the query filters', async () => { | ||
| const users = [makeUser(), makeUser()] | ||
| prisma.user.findMany.mockResolvedValue(users) | ||
|
|
||
| const result = await service.getAllUsers({}) | ||
|
|
||
| expect(result).toEqual(users) | ||
| expect(prisma.user.findMany).toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('filters users by admin roles', async () => { | ||
| prisma.adminRole.findMany.mockResolvedValue([makeAdminRole({ name: 'admin' })]) | ||
| prisma.user.findMany.mockResolvedValue([makeUser()]) | ||
|
|
||
| await service.getAllUsers({ adminRoles: ['admin'] }) | ||
|
|
||
| expect(prisma.adminRole.findMany).toHaveBeenCalled() | ||
| expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ | ||
| where: { AND: expect.any(Array) }, | ||
| })) | ||
| }) | ||
|
|
||
| it('rejects an unknown admin role name with the legacy 400', async () => { | ||
| prisma.adminRole.findMany.mockResolvedValue([]) | ||
|
|
||
| await expect( | ||
| service.getAllUsers({ adminRoles: ['ghost-role'] }), | ||
| ).rejects.toThrow(BadRequestException) | ||
|
|
||
| await expect( | ||
| service.getAllUsers({ adminRoles: ['ghost-role'] }), | ||
| ).rejects.toThrow('Unable to find adminRole ghost-role') | ||
| }) | ||
|
|
||
| it('returns matching users by letters, capped at 5 like the legacy query', async () => { | ||
| const users = [makeUser()] | ||
| prisma.user.findMany.mockResolvedValue(users) | ||
|
|
||
| const result = await service.getMatchingUsers({ letters: 'joh' }) | ||
|
|
||
| expect(result).toHaveLength(1) | ||
| expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ | ||
| take: 5, | ||
| where: { AND: expect.arrayContaining([expect.objectContaining({ type: 'human' })]) }, | ||
| })) | ||
| }) | ||
|
|
||
| it('patches users and emits the union of before and after admin roles', async () => { | ||
| const user = makeUser({ adminRoleIds: ['role-0'] }) | ||
| prisma.user.findMany.mockImplementation(async (_args) => { | ||
| return [_args.select?.adminRoleIds ? { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, adminRoleIds: ['role-0', 'role-1'] } : user] | ||
| }) | ||
| prisma.adminRole.findMany.mockResolvedValue([ | ||
| makeAdminRole({ id: 'role-0', oidcGroup: 'group-0' }), | ||
| makeAdminRole({ id: 'role-1', oidcGroup: 'group-1' }), | ||
| ]) | ||
| const tx = mockDeep<Prisma.TransactionClient>() | ||
| tx.user.update.mockResolvedValue(user) | ||
| prisma.$transaction.mockImplementation(async cb => cb(tx)) | ||
|
|
||
| const result = await service.patchUsers([{ id: user.id, adminRoleIds: ['role-1'] }]) | ||
|
|
||
| expect(tx.user.update).toHaveBeenCalledWith({ | ||
| where: { id: user.id }, | ||
| data: { adminRoleIds: ['role-1'] }, | ||
| }) | ||
| expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { | ||
| id: 'role-0', | ||
| oidcGroup: 'group-0', | ||
| members: [{ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName }], | ||
| }) | ||
| expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { | ||
| id: 'role-1', | ||
| oidcGroup: 'group-1', | ||
| members: [{ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName }], | ||
| }) | ||
| expect(result).toHaveLength(1) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.