From 1f25827ed3c8227935c85f5e3b9aae45070440bc Mon Sep 17 00:00:00 2001 From: Arne Gnisa Date: Tue, 17 Oct 2023 14:17:32 +0200 Subject: [PATCH] N21-1207 adds get /groups/:id --- .../authorization/rule-manager.spec.ts | 8 + .../src/modules/authorization/rule-manager.ts | 5 +- .../controller/api-test/group.api.spec.ts | 118 +++++++++++++ .../controller/dto/request/group-id-params.ts | 8 + .../group/controller/dto/request/index.ts | 1 + .../dto/response/external-source.response.ts | 14 ++ .../dto/response/group-type.response.ts | 3 + .../dto/response/group-user.response.ts | 23 +++ .../controller/dto/response/group.response.ts | 33 ++++ .../group/controller/dto/response/index.ts | 4 + .../group/controller/group.controller.ts | 22 ++- .../mapper/group-response.mapper.ts | 42 ++++- apps/server/src/modules/group/domain/group.ts | 4 + apps/server/src/modules/group/uc/dto/index.ts | 1 + .../group/uc/dto/resolved-group.dto.ts | 26 +++ .../src/modules/group/uc/group.uc.spec.ts | 133 +++++++++++++- apps/server/src/modules/group/uc/group.uc.ts | 23 ++- .../group/uc/mapper/group-uc.mapper.ts | 14 +- .../shared/domain/rules/group.rule.spec.ts | 166 ++++++++++++++++++ .../src/shared/domain/rules/group.rule.ts | 25 +++ apps/server/src/shared/domain/rules/index.ts | 3 + .../domainobject/groups/group.factory.ts | 8 +- 22 files changed, 673 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/modules/group/controller/dto/request/group-id-params.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/external-source.response.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/group-type.response.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/group-user.response.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/group.response.ts create mode 100644 apps/server/src/modules/group/uc/dto/resolved-group.dto.ts create mode 100644 apps/server/src/shared/domain/rules/group.rule.spec.ts create mode 100644 apps/server/src/shared/domain/rules/group.rule.ts diff --git a/apps/server/src/modules/authorization/rule-manager.spec.ts b/apps/server/src/modules/authorization/rule-manager.spec.ts index 0a2b90c7639..e8bfb752a35 100644 --- a/apps/server/src/modules/authorization/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/rule-manager.spec.ts @@ -13,6 +13,7 @@ import { TaskRule, TeamRule, UserRule, + GroupRule, } from '@shared/domain/rules'; import { UserLoginMigrationRule } from '@shared/domain/rules/user-login-migration.rule'; import { courseFactory, setupEntities, userFactory } from '@shared/testing'; @@ -33,6 +34,7 @@ describe('RuleManager', () => { let boardDoRule: DeepMocked; let contextExternalToolRule: DeepMocked; let userLoginMigrationRule: DeepMocked; + let groupRule: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -52,6 +54,7 @@ describe('RuleManager', () => { { provide: BoardDoRule, useValue: createMock() }, { provide: ContextExternalToolRule, useValue: createMock() }, { provide: UserLoginMigrationRule, useValue: createMock() }, + { provide: GroupRule, useValue: createMock() }, ], }).compile(); @@ -68,6 +71,7 @@ describe('RuleManager', () => { boardDoRule = await module.get(BoardDoRule); contextExternalToolRule = await module.get(ContextExternalToolRule); userLoginMigrationRule = await module.get(UserLoginMigrationRule); + groupRule = await module.get(GroupRule); }); afterEach(() => { @@ -98,6 +102,7 @@ describe('RuleManager', () => { boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); + groupRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -119,6 +124,7 @@ describe('RuleManager', () => { expect(boardDoRule.isApplicable).toBeCalled(); expect(contextExternalToolRule.isApplicable).toBeCalled(); expect(userLoginMigrationRule.isApplicable).toBeCalled(); + expect(groupRule.isApplicable).toBeCalled(); }); it('should return CourseRule', () => { @@ -148,6 +154,7 @@ describe('RuleManager', () => { boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); + groupRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -177,6 +184,7 @@ describe('RuleManager', () => { boardDoRule.isApplicable.mockReturnValueOnce(false); contextExternalToolRule.isApplicable.mockReturnValueOnce(false); userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); + groupRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; diff --git a/apps/server/src/modules/authorization/rule-manager.ts b/apps/server/src/modules/authorization/rule-manager.ts index 3aece68402a..4d1daa2625a 100644 --- a/apps/server/src/modules/authorization/rule-manager.ts +++ b/apps/server/src/modules/authorization/rule-manager.ts @@ -12,6 +12,7 @@ import { TaskRule, TeamRule, UserRule, + GroupRule, } from '@shared/domain/rules'; import { ContextExternalToolRule } from '@shared/domain/rules/context-external-tool.rule'; import { UserLoginMigrationRule } from '@shared/domain/rules/user-login-migration.rule'; @@ -33,7 +34,8 @@ export class RuleManager { private readonly schoolExternalToolRule: SchoolExternalToolRule, private readonly boardDoRule: BoardDoRule, private readonly contextExternalToolRule: ContextExternalToolRule, - private readonly userLoginMigrationRule: UserLoginMigrationRule + private readonly userLoginMigrationRule: UserLoginMigrationRule, + private readonly groupRule: GroupRule ) { this.rules = [ this.courseRule, @@ -48,6 +50,7 @@ export class RuleManager { this.boardDoRule, this.contextExternalToolRule, this.userLoginMigrationRule, + this.groupRule, ]; } diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index 39bb86a4caa..c1a73fa909f 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -15,6 +15,7 @@ import { import { ClassEntity } from '@src/modules/class/entity'; import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; import { ServerTestModule } from '@src/modules/server'; +import { ObjectId } from 'bson'; import { GroupEntity, GroupEntityTypes } from '../../entity'; import { ClassRootType } from '../../uc/dto/class-root-type'; import { ClassInfoSearchListResponse, ClassSortBy } from '../dto'; @@ -157,4 +158,121 @@ describe('Group (API)', () => { }); }); }); + + describe('getGroup', () => { + describe('when user with the required permission requests a group', () => { + describe('when group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + + const group: GroupEntity = groupEntityFactory.buildWithId({ + users: [ + { + user: teacherUser, + role: teacherUser.roles[0], + }, + ], + }); + + await em.persistAndFlush([teacherAccount, teacherUser, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + group, + teacherUser, + }; + }; + + it('should return the group', async () => { + const { loggedInClient, group, teacherUser } = await setup(); + + const response = await loggedInClient.get(`${group.id}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + id: group.id, + name: group.name, + type: group.type, + users: [ + { + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + role: teacherUser.roles[0].name, + }, + ], + externalSource: { + externalId: group.externalSource?.externalId, + systemId: group.externalSource?.system.id, + }, + }); + }); + }); + + describe('when group does not exist', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + }; + }; + + it('should return not found', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${new ObjectId().toHexString()}`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + expect(response.body).toEqual({ + code: HttpStatus.NOT_FOUND, + message: 'Not Found', + title: 'Not Found', + type: 'NOT_FOUND', + }); + }); + }); + }); + + describe('when user without the required permission requests a group', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const group: GroupEntity = groupEntityFactory.buildWithId(); + + await em.persistAndFlush([studentAccount, studentUser, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + groupId: group.id, + }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, groupId } = await setup(); + + const response = await loggedInClient.get(`${groupId}`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Forbidden', + type: 'FORBIDDEN', + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/controller/dto/request/group-id-params.ts b/apps/server/src/modules/group/controller/dto/request/group-id-params.ts new file mode 100644 index 00000000000..9423966009f --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/group-id-params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class GroupIdParams { + @IsMongoId() + @ApiProperty({ nullable: false, required: true }) + groupId!: string; +} diff --git a/apps/server/src/modules/group/controller/dto/request/index.ts b/apps/server/src/modules/group/controller/dto/request/index.ts index 2255e9aac09..17ecd658b7d 100644 --- a/apps/server/src/modules/group/controller/dto/request/index.ts +++ b/apps/server/src/modules/group/controller/dto/request/index.ts @@ -1 +1,2 @@ export * from './class-sort-params'; +export * from './group-id-params'; diff --git a/apps/server/src/modules/group/controller/dto/response/external-source.response.ts b/apps/server/src/modules/group/controller/dto/response/external-source.response.ts new file mode 100644 index 00000000000..f03327c8a8c --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/external-source.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ExternalSourceResponse { + @ApiProperty() + externalId: string; + + @ApiProperty() + systemId: string; + + constructor(props: ExternalSourceResponse) { + this.externalId = props.externalId; + this.systemId = props.systemId; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-type.response.ts b/apps/server/src/modules/group/controller/dto/response/group-type.response.ts new file mode 100644 index 00000000000..54c32148ca1 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group-type.response.ts @@ -0,0 +1,3 @@ +export enum GroupTypeResponse { + CLASS = 'class', +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-user.response.ts b/apps/server/src/modules/group/controller/dto/response/group-user.response.ts new file mode 100644 index 00000000000..de62074cd52 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group-user.response.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RoleName } from '@shared/domain'; + +export class GroupUserResponse { + @ApiProperty() + id: string; + + @ApiProperty() + firstName: string; + + @ApiProperty() + lastName: string; + + @ApiProperty() + role: RoleName; + + constructor(user: GroupUserResponse) { + this.id = user.id; + this.firstName = user.firstName; + this.lastName = user.lastName; + this.role = user.role; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/group.response.ts b/apps/server/src/modules/group/controller/dto/response/group.response.ts new file mode 100644 index 00000000000..9a1a02bf132 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ExternalSourceResponse } from './external-source.response'; +import { GroupTypeResponse } from './group-type.response'; +import { GroupUserResponse } from './group-user.response'; + +export class GroupResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + type: GroupTypeResponse; + + @ApiProperty({ type: [GroupUserResponse] }) + users: GroupUserResponse[]; + + @ApiPropertyOptional() + externalSource?: ExternalSourceResponse; + + @ApiPropertyOptional() + organizationId?: string; + + constructor(group: GroupResponse) { + this.id = group.id; + this.name = group.name; + this.type = group.type; + this.users = group.users; + this.externalSource = group.externalSource; + this.organizationId = group.organizationId; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/index.ts b/apps/server/src/modules/group/controller/dto/response/index.ts index 1ec8a62f0d4..9593930f21e 100644 --- a/apps/server/src/modules/group/controller/dto/response/index.ts +++ b/apps/server/src/modules/group/controller/dto/response/index.ts @@ -1,2 +1,6 @@ export * from './class-info.response'; export * from './class-info-search-list.response'; +export * from './external-source.response'; +export * from './group.response'; +export * from './group-type.response'; +export * from './group-user.response'; diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index e810e200d85..1fbad3705f5 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpStatus, Query } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Param, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PaginationParams } from '@shared/controller'; import { Page } from '@shared/domain'; @@ -6,8 +6,8 @@ import { ErrorResponse } from '@src/core/error/dto'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { GroupUc } from '../uc'; -import { ClassInfoDto } from '../uc/dto'; -import { ClassInfoSearchListResponse, ClassSortParams } from './dto'; +import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; +import { ClassInfoSearchListResponse, ClassSortParams, GroupIdParams, GroupResponse } from './dto'; import { GroupResponseMapper } from './mapper'; @ApiTags('Group') @@ -43,4 +43,20 @@ export class GroupController { return response; } + + @Get('/:groupId') + @ApiOperation({ summary: 'Get a group by id.' }) + @ApiResponse({ status: HttpStatus.OK, type: GroupResponse }) + @ApiResponse({ status: '4XX', type: ErrorResponse }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + public async getGroup( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: GroupIdParams + ): Promise { + const group: ResolvedGroupDto = await this.groupUc.getGroup(currentUser.userId, params.groupId); + + const response: GroupResponse = GroupResponseMapper.mapToGroupResponse(group); + + return response; + } } diff --git a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts index 958aeee2c6b..c200ae70c5f 100644 --- a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -1,6 +1,18 @@ import { Page } from '@shared/domain'; -import { ClassInfoDto } from '../../uc/dto'; -import { ClassInfoResponse, ClassInfoSearchListResponse } from '../dto'; +import { GroupTypes } from '../../domain'; +import { ClassInfoDto, ResolvedGroupDto } from '../../uc/dto'; +import { + ClassInfoResponse, + ClassInfoSearchListResponse, + ExternalSourceResponse, + GroupResponse, + GroupTypeResponse, + GroupUserResponse, +} from '../dto'; + +const typeMapping: Record = { + [GroupTypes.CLASS]: GroupTypeResponse.CLASS, +}; export class GroupResponseMapper { static mapToClassInfosToListResponse( @@ -34,4 +46,30 @@ export class GroupResponseMapper { return mapped; } + + static mapToGroupResponse(resolvedGroup: ResolvedGroupDto): GroupResponse { + const mapped: GroupResponse = new GroupResponse({ + id: resolvedGroup.id, + name: resolvedGroup.name, + type: typeMapping[resolvedGroup.type], + externalSource: resolvedGroup.externalSource + ? new ExternalSourceResponse({ + externalId: resolvedGroup.externalSource.externalId, + systemId: resolvedGroup.externalSource.systemId, + }) + : undefined, + users: resolvedGroup.users.map( + (user) => + new GroupUserResponse({ + id: user.user.id as string, + role: user.role.name, + firstName: user.user.firstName, + lastName: user.user.lastName, + }) + ), + organizationId: resolvedGroup.organizationId, + }); + + return mapped; + } } diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 826bbd36b22..3d1f19bc312 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -38,6 +38,10 @@ export class Group extends DomainObject { return this.props.organizationId; } + get type(): GroupTypes { + return this.props.type; + } + removeUser(user: UserDO): void { this.props.users = this.props.users.filter((groupUser: GroupUser): boolean => groupUser.userId !== user.id); } diff --git a/apps/server/src/modules/group/uc/dto/index.ts b/apps/server/src/modules/group/uc/dto/index.ts index 389a31da162..d795f1c30d3 100644 --- a/apps/server/src/modules/group/uc/dto/index.ts +++ b/apps/server/src/modules/group/uc/dto/index.ts @@ -1,2 +1,3 @@ export * from './class-info.dto'; export * from './resolved-group-user'; +export * from './resolved-group.dto'; diff --git a/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts b/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts new file mode 100644 index 00000000000..4d288f936a0 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/resolved-group.dto.ts @@ -0,0 +1,26 @@ +import { ExternalSource } from '@shared/domain'; +import { GroupTypes } from '../../domain'; +import { ResolvedGroupUser } from './resolved-group-user'; + +export class ResolvedGroupDto { + id: string; + + name: string; + + type: GroupTypes; + + users: ResolvedGroupUser[]; + + externalSource?: ExternalSource; + + organizationId?: string; + + constructor(group: ResolvedGroupDto) { + this.id = group.id; + this.name = group.name; + this.type = group.type; + this.users = group.users; + this.externalSource = group.externalSource; + this.organizationId = group.organizationId; + } +} diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index ed089007a72..727cda1fe6e 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo, Page, Permission, SchoolYearEntity, SortOrder, User, UserDO } from '@shared/domain'; import { groupFactory, @@ -22,9 +23,9 @@ import { RoleService } from '@src/modules/role'; import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { SystemDto, SystemService } from '@src/modules/system'; import { UserService } from '@src/modules/user'; -import { Group } from '../domain'; +import { Group, GroupTypes } from '../domain'; import { GroupService } from '../service'; -import { ClassInfoDto } from './dto'; +import { ClassInfoDto, ResolvedGroupDto } from './dto'; import { ClassRootType } from './dto/class-root-type'; import { GroupUc } from './group.uc'; @@ -338,4 +339,132 @@ describe('GroupUc', () => { }); }); }); + + describe('getGroup', () => { + describe('when the user has no permission', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + error, + }; + }; + + it('should throw forbidden', async () => { + const { user, error } = setup(); + + const func = () => uc.getGroup(user.id, 'groupId'); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when the group is not found', () => { + const setup = () => { + groupService.findById.mockRejectedValue(new NotFoundLoggableException(Group.name, 'id', 'groupId')); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + + return { + teacherId: teacherUser.id, + }; + }; + + it('should throw not found', async () => { + const { teacherId } = setup(); + + const func = () => uc.getGroup(teacherId, 'groupId'); + + await expect(func).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the group is found', () => { + const setup = () => { + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const group: Group = groupFactory.build({ + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + + groupService.findById.mockResolvedValueOnce(group); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + + return { + teacherId: teacherUser.id, + teacherUser, + studentUser, + group, + expectedExternalId: group.externalSource?.externalId as string, + expectedSystemId: group.externalSource?.systemId as string, + }; + }; + + it('should return the resolved group', async () => { + const { teacherId, teacherUser, studentUser, group, expectedExternalId, expectedSystemId } = setup(); + + const result: ResolvedGroupDto = await uc.getGroup(teacherId, group.id); + + expect(result).toEqual({ + id: group.id, + name: group.name, + type: GroupTypes.CLASS, + externalSource: { + externalId: expectedExternalId, + systemId: expectedSystemId, + }, + users: [ + { + user: { + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + roles: [ + { + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }, + ], + schoolId: teacherUser.school.id, + email: teacherUser.email, + }, + role: { + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }, + }, + { + user: { + id: studentUser.id, + firstName: studentUser.firstName, + lastName: studentUser.lastName, + roles: [ + { + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }, + ], + schoolId: studentUser.school.id, + email: studentUser.email, + }, + role: { + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }, + }, + ], + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 1d884c5a325..6413b6a5ed1 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -11,7 +11,7 @@ import { UserService } from '@src/modules/user'; import { Group, GroupUser } from '../domain'; import { GroupService } from '../service'; import { SortHelper } from '../util'; -import { ClassInfoDto, ResolvedGroupUser } from './dto'; +import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; @Injectable() @@ -153,4 +153,25 @@ export class GroupUc { return page; } + + public async getGroup(userId: EntityId, groupId: string): Promise { + const group: Group = await this.groupService.findById(groupId); + + await this.checkPermission(userId, group); + + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); + + return resolvedGroup; + } + + private async checkPermission(userId: EntityId, group: Group): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + return this.authorizationService.checkPermission( + user, + group, + // TODO: change permission, adapt rule test and write script + AuthorizationContextBuilder.read([Permission.CLASS_LIST]) + ); + } } diff --git a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts index 596302c4a5c..8ddd262c645 100644 --- a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -2,7 +2,7 @@ import { RoleName, SchoolYearEntity, UserDO } from '@shared/domain'; import { Class } from '@src/modules/class/domain'; import { SystemDto } from '@src/modules/system'; import { Group } from '../../domain'; -import { ClassInfoDto, ResolvedGroupUser } from '../dto'; +import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from '../dto'; import { ClassRootType } from '../dto/class-root-type'; export class GroupUcMapper { @@ -38,4 +38,16 @@ export class GroupUcMapper { return mapped; } + + public static mapToResolvedGroupDto(group: Group, resolvedGroupUsers: ResolvedGroupUser[]): ResolvedGroupDto { + const mapped: ResolvedGroupDto = new ResolvedGroupDto({ + id: group.id, + name: group.name, + type: group.type, + externalSource: group.externalSource, + users: resolvedGroupUsers, + }); + + return mapped; + } } diff --git a/apps/server/src/shared/domain/rules/group.rule.spec.ts b/apps/server/src/shared/domain/rules/group.rule.spec.ts new file mode 100644 index 00000000000..34b58f93bef --- /dev/null +++ b/apps/server/src/shared/domain/rules/group.rule.spec.ts @@ -0,0 +1,166 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { groupFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; +import { Action, AuthorizationContext } from '@src/modules/authorization'; +import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; +import { Group } from '@src/modules/group'; +import { Role, User } from '../entity'; +import { Permission } from '../interface'; +import { GroupRule } from './group.rule'; + +describe('GroupRule', () => { + let module: TestingModule; + let rule: GroupRule; + + let authorizationHelper: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + GroupRule, + { + provide: AuthorizationHelper, + useValue: createMock(), + }, + ], + }).compile(); + + rule = module.get(GroupRule); + authorizationHelper = module.get(AuthorizationHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('isApplicable', () => { + describe('when the entity is applicable', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId(); + const user: User = userFactory.buildWithId({ roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + }); + + return { + user, + group, + }; + }; + + it('should return true', () => { + const { user, group } = setup(); + + const result = rule.isApplicable(user, group); + + expect(result).toEqual(true); + }); + }); + + describe('when the entity is not applicable', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId(); + const userNotInGroup: User = userFactory.buildWithId({ roles: [role] }); + + return { + userNotInGroup, + }; + }; + + it('should return false', () => { + const { userNotInGroup } = setup(); + + const result = rule.isApplicable(userNotInGroup, {} as unknown as Group); + + expect(result).toEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when the user has all required permissions', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId(); + const user: User = userFactory.buildWithId({ roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.CLASS_LIST], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(true); + + return { + user, + group, + context, + }; + }; + + it('should check all permissions', () => { + const { user, group, context } = setup(); + + rule.hasPermission(user, group, context); + + expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith(user, context.requiredPermissions); + }); + + it('should return true', () => { + const { user, group, context } = setup(); + + const result = rule.hasPermission(user, group, context); + + expect(result).toEqual(true); + }); + }); + + describe('when the user has not the required permission', () => { + const setup = () => { + const role: Role = roleFactory.buildWithId({ permissions: [] }); + const user: User = userFactory.buildWithId({ roles: [role] }); + const group: Group = groupFactory.build({ + users: [ + { + userId: user.id, + roleId: user.roles[0].id, + }, + ], + }); + const context: AuthorizationContext = { + action: Action.write, + requiredPermissions: [Permission.CLASS_LIST], + }; + + authorizationHelper.hasAllPermissions.mockReturnValue(false); + + return { + user, + group, + context, + }; + }; + + it('should return false', () => { + const { user, group, context } = setup(); + + const result = rule.hasPermission(user, group, context); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/rules/group.rule.ts b/apps/server/src/shared/domain/rules/group.rule.ts new file mode 100644 index 00000000000..287e3b48c10 --- /dev/null +++ b/apps/server/src/shared/domain/rules/group.rule.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; +import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { Group } from '@src/modules/group'; +import { User } from '../entity'; + +@Injectable() +export class GroupRule implements Rule { + constructor(private readonly authorizationHelper: AuthorizationHelper) {} + + public isApplicable(user: User, domainObject: Group): boolean { + const isMatched: boolean = domainObject instanceof Group; + + return isMatched; + } + + // TODO: ask if this rule "user has to be also in the group" correct? should admin be able to see a group? + public hasPermission(user: User, domainObject: Group, context: AuthorizationContext): boolean { + const hasPermission: boolean = + this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions) && + domainObject.users.some((groupUser) => groupUser.userId === user.id); + + return hasPermission; + } +} diff --git a/apps/server/src/shared/domain/rules/index.ts b/apps/server/src/shared/domain/rules/index.ts index 888b2ee8501..ef01af9126c 100644 --- a/apps/server/src/shared/domain/rules/index.ts +++ b/apps/server/src/shared/domain/rules/index.ts @@ -2,6 +2,7 @@ import { BoardDoRule } from './board-do.rule'; import { ContextExternalToolRule } from './context-external-tool.rule'; import { CourseGroupRule } from './course-group.rule'; import { CourseRule } from './course.rule'; +import { GroupRule } from './group.rule'; import { LessonRule } from './lesson.rule'; import { SchoolExternalToolRule } from './school-external-tool.rule'; import { LegacySchoolRule } from './legacy-school.rule'; @@ -22,6 +23,7 @@ export * from './task.rule'; export * from './team.rule'; export * from './user.rule'; export * from './context-external-tool.rule'; +export * from './group.rule'; export const ALL_RULES = [ LessonRule, @@ -36,4 +38,5 @@ export const ALL_RULES = [ BoardDoRule, ContextExternalToolRule, UserLoginMigrationRule, + GroupRule, ]; diff --git a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts index a65d5141b61..fb9ab73d8a4 100644 --- a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts @@ -1,4 +1,4 @@ -import { ExternalSource } from '@shared/domain'; +import { ExternalSource, RoleName } from '@shared/domain'; import { Group, GroupProps, GroupTypes } from '@src/modules/group/domain'; import { ObjectId } from 'bson'; import { DomainObjectFactory } from '../domain-object.factory'; @@ -12,6 +12,12 @@ export const groupFactory = DomainObjectFactory.define(Group, { userId: new ObjectId().toHexString(), roleId: new ObjectId().toHexString(), + firstName: `firstName-${sequence}`, + lastName: `lastName-${sequence}`, + roleRef: { + id: new ObjectId().toHexString(), + name: RoleName.TEACHER, + }, }, ], validFrom: new Date(2023, 1),