From 67704f55c4a7a48708cc0a9498c7e6c8aca60de1 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:06:03 +0200 Subject: [PATCH 01/34] BC-3475 - fix bug by update of height (#4430) --- .../src/shared/domain/domainobject/board/file-element.do.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/shared/domain/domainobject/board/file-element.do.ts b/apps/server/src/shared/domain/domainobject/board/file-element.do.ts index 4008c2bf3db..ab209fbe346 100644 --- a/apps/server/src/shared/domain/domainobject/board/file-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/file-element.do.ts @@ -11,7 +11,7 @@ export class FileElement extends BoardComposite { } get alternativeText(): string { - return this.props.alternativeText; + return this.props.alternativeText || ''; } set alternativeText(value: string) { From 251981f1e9045caa0135706805fdf72a4f1fa3dc Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Fri, 22 Sep 2023 12:27:05 +0200 Subject: [PATCH 02/34] N21-1269 roster adaptation (#4412) * implement feathers-roster-service * add pseudonyms endpoint * moved iframe code from id-token service to pseudonym service --------- Co-authored-by: Arne Gnisa --- apps/server/src/apps/server.app.ts | 3 + .../learnroom/service/course.service.spec.ts | 27 +- .../learnroom/service/course.service.ts | 6 + .../service/id-token.service.spec.ts | 52 +- .../service/id-token.service.ts | 20 +- .../controller/api-test/pseudonym.api.spec.ts | 157 +++++ .../modules/pseudonym/controller/dto/index.ts | 1 + .../controller/dto/pseudonym-params.ts | 8 + .../controller/dto/pseudonym.response.ts | 18 + .../controller/pseudonym.controller.ts | 38 ++ .../src/modules/pseudonym/domain/index.ts | 1 + .../domain/pseudonym-search-query.ts | 5 + .../pseudonym/entity/pseudonym.scope.spec.ts | 59 ++ .../pseudonym/entity/pseudonym.scope.ts | 26 + .../pseudonym/mapper/pseudonym.mapper.ts | 14 + .../modules/pseudonym/pseudonym-api.module.ts | 13 + .../src/modules/pseudonym/pseudonym.module.ts | 13 +- ...l-tool-pseudonym.repo.integration.spec.ts} | 169 ++++- .../repo/external-tool-pseudonym.repo.ts | 42 +- .../pseudonym/repo/pseudonyms.repo.spec.ts | 6 +- .../service/feathers-roster.service.spec.ts | 634 ++++++++++++++++++ .../service/feathers-roster.service.ts | 242 +++++++ .../src/modules/pseudonym/service/index.ts | 1 + .../service/pseudonym.service.spec.ts | 128 +++- .../pseudonym/service/pseudonym.service.ts | 26 +- apps/server/src/modules/pseudonym/uc/index.ts | 1 + .../modules/pseudonym/uc/pseudonym.uc.spec.ts | 141 ++++ .../src/modules/pseudonym/uc/pseudonym.uc.ts | 33 + .../src/modules/server/server.module.ts | 2 + .../strategy/lti11-tool-launch.strategy.ts | 2 +- .../nextcloud/nextcloud.strategy.spec.ts | 16 +- .../strategy/nextcloud/nextcloud.strategy.ts | 2 +- .../external-tool.repo.integration.spec.ts | 10 +- src/services/roster/index.js | 53 +- test/services/roster/index.test.js | 232 ++++++- 35 files changed, 2074 insertions(+), 127 deletions(-) create mode 100644 apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts create mode 100644 apps/server/src/modules/pseudonym/controller/dto/index.ts create mode 100644 apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts create mode 100644 apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts create mode 100644 apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts create mode 100644 apps/server/src/modules/pseudonym/domain/index.ts create mode 100644 apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts create mode 100644 apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts create mode 100644 apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts create mode 100644 apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts create mode 100644 apps/server/src/modules/pseudonym/pseudonym-api.module.ts rename apps/server/src/modules/pseudonym/repo/{external-tool-pseudonym.repo.spec.ts => external-tool-pseudonym.repo.integration.spec.ts} (62%) create mode 100644 apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts create mode 100644 apps/server/src/modules/pseudonym/service/feathers-roster.service.ts create mode 100644 apps/server/src/modules/pseudonym/uc/index.ts create mode 100644 apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts create mode 100644 apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 81a35f6bfa6..6452ca1cd47 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -19,6 +19,7 @@ import { join } from 'path'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; +import { FeathersRosterService } from '@src/modules/pseudonym'; import legacyAppPromise = require('../../../../src/app'); import { AppStartLoggable } from './helpers/app-start-loggable'; @@ -80,6 +81,8 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-team-service'] = nestApp.get(TeamService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + feathersExpress.services['nest-feathers-roster-service'] = nestApp.get(FeathersRosterService); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; // mount instances diff --git a/apps/server/src/modules/learnroom/service/course.service.spec.ts b/apps/server/src/modules/learnroom/service/course.service.spec.ts index 56d5bb359b4..67c581d6818 100644 --- a/apps/server/src/modules/learnroom/service/course.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course.service.spec.ts @@ -40,12 +40,18 @@ describe('CourseService', () => { }); describe('findById', () => { - it('should call findById from course repository', async () => { + const setup = () => { const courseId = 'courseId'; courseRepo.findById.mockResolvedValueOnce({} as Course); + return { courseId }; + }; + + it('should call findById from course repository', async () => { + const { courseId } = setup(); + await expect(courseService.findById(courseId)).resolves.not.toThrow(); - expect(courseRepo.findById).toBeCalledTimes(1); + expect(courseRepo.findById).toBeCalledWith(courseId); }); }); @@ -78,4 +84,21 @@ describe('CourseService', () => { expect(result).toEqual(3); }); }); + + describe('findAllByUserId', () => { + const setup = () => { + const userId = 'userId'; + courseRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); + + return { userId }; + }; + + it('should call findAllByUserId from course repository', async () => { + const { userId } = setup(); + + await expect(courseService.findAllByUserId(userId)).resolves.not.toThrow(); + + expect(courseRepo.findAllByUserId).toBeCalledWith(userId); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/service/course.service.ts b/apps/server/src/modules/learnroom/service/course.service.ts index ef14fcfabb2..c2e7364ffe9 100644 --- a/apps/server/src/modules/learnroom/service/course.service.ts +++ b/apps/server/src/modules/learnroom/service/course.service.ts @@ -19,4 +19,10 @@ export class CourseService { return count; } + + async findAllByUserId(userId: EntityId): Promise { + const [courses] = await this.repo.findAllByUserId(userId); + + return courses; + } } diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts b/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts index 454874571f9..48f21de4077 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts +++ b/apps/server/src/modules/oauth-provider/service/id-token.service.spec.ts @@ -1,5 +1,4 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Test, TestingModule } from '@nestjs/testing'; import { Pseudonym, TeamEntity, UserDO } from '@shared/domain'; import { TeamsRepo } from '@shared/repo'; @@ -24,18 +23,7 @@ describe('IdTokenService', () => { let teamsRepo: DeepMocked; let userService: DeepMocked; - const hostUrl = 'https://host.de'; - beforeAll(async () => { - jest.spyOn(Configuration, 'get').mockImplementation((key: string) => { - switch (key) { - case 'HOST': - return hostUrl; - default: - return null; - } - }); - module = await Test.createTestingModule({ providers: [ IdTokenService, @@ -90,24 +78,26 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { user, displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user } = setup(); + const { user, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, }); }); @@ -129,7 +119,9 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { team, @@ -137,17 +129,17 @@ describe('IdTokenService', () => { displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user, team } = setup(); + const { user, team, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [OauthScope.GROUPS], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, groups: [ { @@ -172,24 +164,26 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { user, displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user } = setup(); + const { user, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [OauthScope.EMAIL], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, email: user.email, }); @@ -209,24 +203,26 @@ describe('IdTokenService', () => { userService.findById.mockResolvedValue(user); userService.getDisplayName.mockResolvedValue(displayName); oauthProviderLoginFlowService.findToolByClientId.mockResolvedValue(tool); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); + const iframeSubject = 'iframeSubject'; + pseudonymService.getIframeSubject.mockReturnValueOnce(iframeSubject); return { user, displayName, tool, pseudonym, + iframeSubject, }; }; it('should return the correct id token', async () => { - const { user, displayName } = setup(); + const { user, displayName, iframeSubject } = setup(); const result: IdToken = await service.createIdToken('userId', [OauthScope.PROFILE], 'clientId'); expect(result).toEqual({ - iframe: - '', + iframe: iframeSubject, schoolId: user.schoolId, name: displayName, userId: user.id, diff --git a/apps/server/src/modules/oauth-provider/service/id-token.service.ts b/apps/server/src/modules/oauth-provider/service/id-token.service.ts index 8eec9407d6c..dbe1b2c54fc 100644 --- a/apps/server/src/modules/oauth-provider/service/id-token.service.ts +++ b/apps/server/src/modules/oauth-provider/service/id-token.service.ts @@ -1,29 +1,21 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Injectable } from '@nestjs/common'; import { LtiToolDO, Pseudonym, TeamEntity, UserDO } from '@shared/domain'; import { TeamsRepo } from '@shared/repo'; import { PseudonymService } from '@src/modules/pseudonym'; -import { UserService } from '@src/modules/user'; import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { UserService } from '@src/modules/user'; +import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; import { GroupNameIdTuple, IdToken, OauthScope } from '../interface'; import { OauthProviderLoginFlowService } from './oauth-provider.login-flow.service'; -import { IdTokenCreationLoggableException } from '../error/id-token-creation-exception.loggable'; @Injectable() export class IdTokenService { - private readonly host: string; - - protected iFrameProperties: string; - constructor( private readonly oauthProviderLoginFlowService: OauthProviderLoginFlowService, private readonly pseudonymService: PseudonymService, private readonly teamsRepo: TeamsRepo, private readonly userService: UserService - ) { - this.host = Configuration.get('HOST') as string; - this.iFrameProperties = 'title="username" style="height: 26px; width: 180px; border: none;"'; - } + ) {} async createIdToken(userId: string, scopes: string[], clientId: string): Promise { let teams: TeamEntity[] = []; @@ -63,8 +55,10 @@ export class IdTokenService { throw new IdTokenCreationLoggableException(clientId, user.id); } - const pseudonym: Pseudonym = await this.pseudonymService.findByUserAndTool(user, tool); + const pseudonym: Pseudonym = await this.pseudonymService.findByUserAndToolOrThrow(user, tool); + + const iframeSubject: string = this.pseudonymService.getIframeSubject(pseudonym.pseudonym); - return ``; + return iframeSubject; } } diff --git a/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts b/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts new file mode 100644 index 00000000000..05bd5d4f2f5 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts @@ -0,0 +1,157 @@ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { + cleanupCollections, + externalToolEntityFactory, + externalToolPseudonymEntityFactory, + schoolFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'supertest'; +import { SchoolEntity } from '@shared/domain'; +import { ServerTestModule } from '@src/modules/server'; +import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; +import { UUID } from 'bson'; +import { ExternalToolPseudonymEntity } from '../../entity'; +import { PseudonymResponse } from '../dto'; + +describe('PseudonymController (API)', () => { + let app: INestApplication; + let em: EntityManager; + + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + + await app.init(); + + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'pseudonyms'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('[GET] pseudonyms/:pseudonym', () => { + describe('when user is not authenticated', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get(new ObjectId().toHexString()); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when valid params are given', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }, []); + const pseudonymString: string = new UUID().toString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const pseudonym: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + pseudonym: pseudonymString, + toolId: externalToolEntity.id, + userId: studentUser.id, + }); + + await em.persistAndFlush([studentAccount, studentUser, pseudonym, externalToolEntity, school]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, pseudonym, pseudonymString }; + }; + + it('should return a pseudonymResponse', async () => { + const { loggedInClient, pseudonymString, pseudonym } = await setup(); + + const response: Response = await loggedInClient.get(pseudonymString); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + id: pseudonym.id, + userId: pseudonym.userId.toString(), + toolId: pseudonym.toolId.toString(), + }); + }); + }); + + describe('when pseudonym is not connected to the users school', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }); + const pseudonymString: string = new UUID().toString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const pseudonym: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + pseudonym: pseudonymString, + toolId: externalToolEntity.id, + userId: teacherUser.id, + }); + + await em.persistAndFlush([ + studentAccount, + studentUser, + teacherUser, + teacherAccount, + pseudonym, + externalToolEntity, + school, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, pseudonymString }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, pseudonymString } = await setup(); + + const response: Response = await loggedInClient.get(pseudonymString); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when pseudonym does not exist in db', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); + const pseudonymString: string = new UUID().toString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const pseudonym: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId({ + pseudonym: new UUID().toString(), + toolId: externalToolEntity.id, + userId: studentUser.id, + }); + + await em.persistAndFlush([studentAccount, studentUser, pseudonym, externalToolEntity, school]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, pseudonymString }; + }; + + it('should return not found', async () => { + const { loggedInClient, pseudonymString } = await setup(); + + const response: Response = await loggedInClient.get(pseudonymString); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/controller/dto/index.ts b/apps/server/src/modules/pseudonym/controller/dto/index.ts new file mode 100644 index 00000000000..0c30123c6b6 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/dto/index.ts @@ -0,0 +1 @@ +export * from './pseudonym.response'; diff --git a/apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts b/apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts new file mode 100644 index 00000000000..172e7845b9f --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/dto/pseudonym-params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class PseudonymParams { + @IsString() + @ApiProperty({ nullable: false, required: true }) + pseudonym!: string; +} diff --git a/apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts b/apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts new file mode 100644 index 00000000000..3ff77b5b3a4 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/dto/pseudonym.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PseudonymResponse { + @ApiProperty() + id: string; + + @ApiProperty() + toolId: string; + + @ApiProperty() + userId: string; + + constructor(response: PseudonymResponse) { + this.id = response.id; + this.toolId = response.toolId; + this.userId = response.userId; + } +} diff --git a/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts b/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts new file mode 100644 index 00000000000..02aade8a446 --- /dev/null +++ b/apps/server/src/modules/pseudonym/controller/pseudonym.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { + ApiForbiddenResponse, + ApiFoundResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { Pseudonym } from '@shared/domain'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { ICurrentUser } from '@src/modules/authentication'; +import { PseudonymMapper } from '../mapper/pseudonym.mapper'; +import { PseudonymUc } from '../uc'; +import { PseudonymResponse } from './dto'; +import { PseudonymParams } from './dto/pseudonym-params'; + +@ApiTags('Pseudonym') +@Authenticate('jwt') +@Controller('pseudonyms') +export class PseudonymController { + constructor(private readonly pseudonymUc: PseudonymUc) {} + + @Get(':pseudonym') + @ApiFoundResponse({ description: 'Pseudonym has been found.', type: PseudonymResponse }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + @ApiOperation({ summary: 'Returns the related user and tool information to a pseudonym' }) + async getPseudonym( + @Param() params: PseudonymParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const pseudonym: Pseudonym = await this.pseudonymUc.findPseudonymByPseudonym(currentUser.userId, params.pseudonym); + + const pseudonymResponse: PseudonymResponse = PseudonymMapper.mapToResponse(pseudonym); + + return pseudonymResponse; + } +} diff --git a/apps/server/src/modules/pseudonym/domain/index.ts b/apps/server/src/modules/pseudonym/domain/index.ts new file mode 100644 index 00000000000..9ff491df8d0 --- /dev/null +++ b/apps/server/src/modules/pseudonym/domain/index.ts @@ -0,0 +1 @@ +export * from './pseudonym-search-query'; diff --git a/apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts b/apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts new file mode 100644 index 00000000000..2706b0b7cca --- /dev/null +++ b/apps/server/src/modules/pseudonym/domain/pseudonym-search-query.ts @@ -0,0 +1,5 @@ +export interface PseudonymSearchQuery { + pseudonym?: string; + toolId?: string; + userId?: string; +} diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts new file mode 100644 index 00000000000..28b020f68e8 --- /dev/null +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts @@ -0,0 +1,59 @@ +import { ObjectId, UUID } from 'bson'; +import { PseudonymScope } from './pseudonym.scope'; + +describe('PseudonymScope', () => { + let scope: PseudonymScope; + + beforeEach(() => { + scope = new PseudonymScope(); + scope.allowEmptyQuery(true); + }); + + describe('byPseudonym', () => { + it('should return scope with added pseudonym to query', () => { + const param = UUID.generate().toString(); + + scope.byPseudonym(param); + + expect(scope.query).toEqual({ pseudonym: param }); + }); + + it('should return scope without added pseudonym to query', () => { + scope.byPseudonym(undefined); + + expect(scope.query).toEqual({}); + }); + }); + + describe('byUserId', () => { + it('should return scope with added userId to query', () => { + const param = new ObjectId().toHexString(); + + scope.byUserId(param); + + expect(scope.query).toEqual({ userId: new ObjectId(param) }); + }); + + it('should return scope without added userId to query', () => { + scope.byUserId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + + describe('byToolId', () => { + it('should return scope with added toolId to query', () => { + const param = new ObjectId().toHexString(); + + scope.byToolId(param); + + expect(scope.query).toEqual({ toolId: new ObjectId(param) }); + }); + + it('should return scope without added toolId to query', () => { + scope.byToolId(undefined); + + expect(scope.query).toEqual({}); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts new file mode 100644 index 00000000000..cba4e3b6247 --- /dev/null +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts @@ -0,0 +1,26 @@ +import { Scope } from '@shared/repo'; +import { ObjectId } from 'bson'; +import { ExternalToolPseudonymEntity } from './external-tool-pseudonym.entity'; + +export class PseudonymScope extends Scope { + byPseudonym(pseudonym: string | undefined): this { + if (pseudonym) { + this.addQuery({ pseudonym }); + } + return this; + } + + byUserId(userId: string | undefined): this { + if (userId) { + this.addQuery({ userId: new ObjectId(userId) }); + } + return this; + } + + byToolId(toolId: string | undefined): this { + if (toolId) { + this.addQuery({ toolId: new ObjectId(toolId) }); + } + return this; + } +} diff --git a/apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts b/apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts new file mode 100644 index 00000000000..a12ecdfea70 --- /dev/null +++ b/apps/server/src/modules/pseudonym/mapper/pseudonym.mapper.ts @@ -0,0 +1,14 @@ +import { Pseudonym } from '@shared/domain'; +import { PseudonymResponse } from '../controller/dto'; + +export class PseudonymMapper { + static mapToResponse(pseudonym: Pseudonym): PseudonymResponse { + const response: PseudonymResponse = new PseudonymResponse({ + id: pseudonym.id, + toolId: pseudonym.toolId, + userId: pseudonym.userId, + }); + + return response; + } +} diff --git a/apps/server/src/modules/pseudonym/pseudonym-api.module.ts b/apps/server/src/modules/pseudonym/pseudonym-api.module.ts new file mode 100644 index 00000000000..8ba18f1cb72 --- /dev/null +++ b/apps/server/src/modules/pseudonym/pseudonym-api.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '@src/modules/authorization'; +import { LegacySchoolModule } from '@src/modules/legacy-school'; +import { PseudonymModule } from './pseudonym.module'; +import { PseudonymController } from './controller/pseudonym.controller'; +import { PseudonymUc } from './uc'; + +@Module({ + imports: [PseudonymModule, AuthorizationModule, LegacySchoolModule], + providers: [PseudonymUc], + controllers: [PseudonymController], +}) +export class PseudonymApiModule {} diff --git a/apps/server/src/modules/pseudonym/pseudonym.module.ts b/apps/server/src/modules/pseudonym/pseudonym.module.ts index 01e649d8374..21da4ef3c59 100644 --- a/apps/server/src/modules/pseudonym/pseudonym.module.ts +++ b/apps/server/src/modules/pseudonym/pseudonym.module.ts @@ -1,10 +1,15 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { LegacyLogger } from '@src/core/logger'; +import { LearnroomModule } from '@src/modules/learnroom'; +import { UserModule } from '@src/modules/user'; +import { ToolModule } from '@src/modules/tool'; +import { AuthorizationModule } from '@src/modules/authorization'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from './repo'; -import { PseudonymService } from './service'; +import { FeathersRosterService, PseudonymService } from './service'; @Module({ - providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger], - exports: [PseudonymService], + imports: [UserModule, LearnroomModule, forwardRef(() => ToolModule), forwardRef(() => AuthorizationModule)], + providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger, FeathersRosterService], + exports: [PseudonymService, FeathersRosterService], }) export class PseudonymModule {} diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts similarity index 62% rename from apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts rename to apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts index b30506114fb..9d3711aff02 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts @@ -2,12 +2,13 @@ import { createMock } from '@golevelup/ts-jest'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { Page, Pseudonym } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, pseudonymFactory, externalToolPseudonymEntityFactory, userFactory } from '@shared/testing'; +import { cleanupCollections, externalToolPseudonymEntityFactory, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; import { LegacyLogger } from '@src/core/logger'; import { v4 as uuidv4 } from 'uuid'; -import { Pseudonym } from '@shared/domain'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymEntity } from '../entity'; import { ExternalToolPseudonymRepo } from './external-tool-pseudonym.repo'; @@ -280,4 +281,168 @@ describe('ExternalToolPseudonymRepo', () => { }); }); }); + + describe('findPseudonymByPseudonym', () => { + describe('when pseudonym is existing', () => { + const setup = async () => { + const entity: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.buildWithId(); + await em.persistAndFlush(entity); + em.clear(); + + return { + entity, + }; + }; + + it('should return a pseudonym', async () => { + const { entity } = await setup(); + + const result: Pseudonym | null = await repo.findPseudonymByPseudonym(entity.pseudonym); + + expect(result?.id).toEqual(entity.id); + }); + }); + + describe('when pseudonym not exists', () => { + it('should return null', async () => { + const pseudonym: Pseudonym | null = await repo.findPseudonymByPseudonym(uuidv4()); + + expect(pseudonym).toBeNull(); + }); + }); + }); + + describe('findPseudonym', () => { + describe('when query with all parameters is given', () => { + const setup = async () => { + const query: PseudonymSearchQuery = { + userId: new ObjectId().toHexString(), + }; + + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym1', + }); + + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym2', + }); + + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym3', + }); + + const pseudonyms: ExternalToolPseudonymEntity[] = [pseudonym1, pseudonym2, pseudonym3]; + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3]); + em.clear(); + + return { + query, + pseudonyms, + }; + }; + + it('should return all three pseudonyms', async () => { + const { query, pseudonyms } = await setup(); + + const page: Page = await repo.findPseudonym(query); + + expect(page.data.length).toEqual(pseudonyms.length); + }); + }); + + describe('when pagination has a limit of 1', () => { + const setup = async () => { + const query: PseudonymSearchQuery = { + userId: new ObjectId().toHexString(), + }; + + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym1', + }); + + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym2', + }); + + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym3', + }); + + const pseudonyms: ExternalToolPseudonymEntity[] = [pseudonym1, pseudonym2, pseudonym3]; + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3]); + em.clear(); + + return { + query, + pseudonyms, + }; + }; + + it('should return one pseudonym', async () => { + const { query } = await setup(); + + const page: Page = await repo.findPseudonym(query, { pagination: { limit: 1 } }); + + expect(page.data.length).toEqual(1); + }); + }); + + describe('when pagination has a limit of 1 and skip is set to 2', () => { + const setup = async () => { + const query: PseudonymSearchQuery = { + userId: new ObjectId().toHexString(), + }; + + const pseudonym1: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym1', + }); + + const pseudonym2: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym2', + }); + + const pseudonym3: ExternalToolPseudonymEntity = externalToolPseudonymEntityFactory.build({ + userId: query.userId, + toolId: new ObjectId().toHexString(), + pseudonym: 'pseudonym3', + }); + + const pseudonyms: ExternalToolPseudonymEntity[] = [pseudonym1, pseudonym2, pseudonym3]; + + await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3]); + em.clear(); + + return { + query, + pseudonyms, + }; + }; + + it('should return the third element', async () => { + const { query, pseudonyms } = await setup(); + + const page: Page = await repo.findPseudonym(query, { pagination: { skip: 2 } }); + + expect(page.data[0].id).toEqual(pseudonyms[2].id); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts index f35f5037ea9..79a17a80541 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts @@ -1,7 +1,10 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { EntityId, Pseudonym } from '@shared/domain'; +import { EntityId, IFindOptions, IPagination, Page, Pseudonym } from '@shared/domain'; +import { Scope } from '@shared/repo'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymEntity, IExternalToolPseudonymEntityProps } from '../entity'; +import { PseudonymScope } from '../entity/pseudonym.scope'; @Injectable() export class ExternalToolPseudonymRepo { @@ -71,8 +74,22 @@ export class ExternalToolPseudonymRepo { return promise; } + async findPseudonymByPseudonym(pseudonym: string): Promise { + const entities: ExternalToolPseudonymEntity | null = await this.em.findOne(ExternalToolPseudonymEntity, { + pseudonym, + }); + + if (!entities) { + return null; + } + + const domainObject: Pseudonym = this.mapEntityToDomainObject(entities); + + return domainObject; + } + protected mapEntityToDomainObject(entity: ExternalToolPseudonymEntity): Pseudonym { - return new Pseudonym({ + const pseudonym = new Pseudonym({ id: entity.id, pseudonym: entity.pseudonym, toolId: entity.toolId.toHexString(), @@ -80,6 +97,8 @@ export class ExternalToolPseudonymRepo { createdAt: entity.createdAt, updatedAt: entity.updatedAt, }); + + return pseudonym; } protected mapDomainObjectToEntityProperties(entityDO: Pseudonym): IExternalToolPseudonymEntityProps { @@ -89,4 +108,23 @@ export class ExternalToolPseudonymRepo { userId: new ObjectId(entityDO.userId), }; } + + async findPseudonym(query: PseudonymSearchQuery, options?: IFindOptions): Promise> { + const pagination: IPagination = options?.pagination ?? {}; + const scope: Scope = new PseudonymScope() + .byPseudonym(query.pseudonym) + .byToolId(query.toolId) + .byUserId(query.userId) + .allowEmptyQuery(true); + + const [entities, total] = await this.em.findAndCount(ExternalToolPseudonymEntity, scope.query, { + offset: pagination?.skip, + limit: pagination?.limit, + }); + + const entityDos: Pseudonym[] = entities.map((entity) => this.mapEntityToDomainObject(entity)); + const page: Page = new Page(entityDos, total); + + return page; + } } diff --git a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts index 63440796960..548ba1b0512 100644 --- a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts @@ -2,14 +2,14 @@ import { createMock } from '@golevelup/ts-jest'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; +import { Pseudonym } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { cleanupCollections, pseudonymFactory, userFactory } from '@shared/testing'; import { pseudonymEntityFactory } from '@shared/testing/factory/pseudonym.factory'; -import { v4 as uuidv4 } from 'uuid'; import { LegacyLogger } from '@src/core/logger'; -import { Pseudonym } from '@shared/domain'; -import { PseudonymsRepo } from './pseudonyms.repo'; +import { v4 as uuidv4 } from 'uuid'; import { PseudonymEntity } from '../entity'; +import { PseudonymsRepo } from './pseudonyms.repo'; describe('PseudonymRepo', () => { let module: TestingModule; diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts new file mode 100644 index 00000000000..6c55067552d --- /dev/null +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts @@ -0,0 +1,634 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { DatabaseObjectNotFoundException } from '@mikro-orm/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Course, Pseudonym, RoleName, LegacySchoolDo, UserDO, SchoolEntity } from '@shared/domain'; +import { + contextExternalToolFactory, + courseFactory, + externalToolFactory, + pseudonymFactory, + legacySchoolDoFactory, + schoolExternalToolFactory, + schoolFactory, + setupEntities, + UserAndAccountTestFactory, + userDoFactory, +} from '@shared/testing'; +import { CourseService } from '@src/modules/learnroom/service/course.service'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalTool, ContextRef } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@src/modules/tool/context-external-tool/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolService } from '@src/modules/tool/school-external-tool/service'; +import { UserService } from '@src/modules/user'; +import { ObjectId } from 'bson'; +import { FeathersRosterService } from './feathers-roster.service'; +import { PseudonymService } from './pseudonym.service'; + +describe('FeathersRosterService', () => { + let module: TestingModule; + let service: FeathersRosterService; + + let userService: DeepMocked; + let pseudonymService: DeepMocked; + let courseService: DeepMocked; + let externalToolService: DeepMocked; + let schoolExternalToolService: DeepMocked; + let contextExternalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + FeathersRosterService, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: PseudonymService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: ExternalToolService, + useValue: createMock(), + }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(FeathersRosterService); + pseudonymService = module.get(PseudonymService); + userService = module.get(UserService); + courseService = module.get(CourseService); + externalToolService = module.get(ExternalToolService); + schoolExternalToolService = module.get(SchoolExternalToolService); + contextExternalToolService = module.get(ContextExternalToolService); + + await setupEntities(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getUsersMetadata', () => { + describe('when pseudonym is given', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build(); + const iFrameSubject = 'iFrameSubject'; + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + userService.findById.mockResolvedValue(user); + pseudonymService.getIframeSubject.mockReturnValue(iFrameSubject); + + return { + pseudonym, + user, + iFrameSubject, + }; + }; + + it('should call the pseudonym service to find the pseudonym', async () => { + const { pseudonym } = setup(); + + await service.getUsersMetadata(pseudonym.pseudonym); + + expect(pseudonymService.findPseudonymByPseudonym).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should call the user service to find the user', async () => { + const { pseudonym } = setup(); + + await service.getUsersMetadata(pseudonym.pseudonym); + + expect(userService.findById).toHaveBeenCalledWith(pseudonym.userId); + }); + + it('should call the pseudonym service to get the iframe subject', async () => { + const { pseudonym } = setup(); + + await service.getUsersMetadata(pseudonym.pseudonym); + + expect(pseudonymService.getIframeSubject).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should return user metadata', async () => { + const { pseudonym, user, iFrameSubject } = setup(); + + const result = await service.getUsersMetadata(pseudonym.pseudonym); + + expect(result).toEqual({ + data: { + user_id: user.id, + username: iFrameSubject, + type: 'student', + }, + }); + }); + }); + + describe('when pseudonym does not exists', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(null); + + return { + pseudonym, + }; + }; + + it('should throw an NotFoundLoggableException', async () => { + const { pseudonym } = setup(); + + const func = service.getUsersMetadata(pseudonym.pseudonym); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(UserDO.name, 'pseudonym', pseudonym.pseudonym) + ); + }); + }); + + describe('when user does not exists', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build(); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + userService.findById.mockRejectedValueOnce(new DatabaseObjectNotFoundException(new Error())); + + return { + pseudonym, + user, + }; + }; + + it('should throw database exception', async () => { + const { pseudonym } = setup(); + + const func = service.getUsersMetadata(pseudonym.pseudonym); + + await expect(func).rejects.toThrow(DatabaseObjectNotFoundException); + }); + }); + }); + + describe('getUserGroups', () => { + describe('when pseudonym is given', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const clientId = 'testClientId'; + const externalTool: ExternalTool = externalToolFactory.withOauth2Config({ clientId }).buildWithId(); + const externalToolId: string = externalTool.id as string; + + const otherExternalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + schoolId: school.id, + }); + const otherSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: otherExternalTool.id, + schoolId: school.id, + }); + const pseudonym: Pseudonym = pseudonymFactory.build(); + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build(); + const courseA: Course = courseFactory.buildWithId(); + const courseB: Course = courseFactory.buildWithId(); + const courseC: Course = courseFactory.buildWithId(); + const courses: Course[] = [courseA, courseB, courseC]; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string, schoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + const otherContextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(otherSchoolExternalTool.id as string, otherSchoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(externalTool); + courseService.findAllByUserId.mockResolvedValue(courses); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([ + contextExternalTool, + otherContextExternalTool, + ]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([otherContextExternalTool]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(otherSchoolExternalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + externalToolService.findExternalToolById.mockResolvedValueOnce(otherExternalTool); + + return { + pseudonym, + externalToolId, + clientId, + user, + courses, + schoolExternalTool, + otherSchoolExternalTool, + otherExternalTool, + }; + }; + + it('should call the pseudonym service to find the pseudonym', async () => { + const { pseudonym, clientId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(pseudonymService.findPseudonymByPseudonym).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should call the course service to find the courses for the userId of the pseudonym', async () => { + const { pseudonym, clientId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(courseService.findAllByUserId).toHaveBeenCalledWith(pseudonym.userId); + }); + + it('should call the context external tool service to find the external tools for each course', async () => { + const { pseudonym, courses, clientId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(contextExternalToolService.findAllByContext.mock.calls).toEqual([ + [new ContextRef({ id: courses[0].id, type: ToolContextType.COURSE })], + [new ContextRef({ id: courses[1].id, type: ToolContextType.COURSE })], + [new ContextRef({ id: courses[2].id, type: ToolContextType.COURSE })], + ]); + }); + + it('should call school external tool service to find the school external tool for each context external tool', async () => { + const { pseudonym, clientId, schoolExternalTool, otherSchoolExternalTool } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(schoolExternalToolService.getSchoolExternalToolById.mock.calls).toEqual([ + [schoolExternalTool.id], + [otherSchoolExternalTool.id], + ]); + }); + + it('should call external tool service to find the external tool for each school external tool', async () => { + const { pseudonym, clientId, otherExternalTool, externalToolId } = setup(); + + await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(externalToolService.findExternalToolById.mock.calls).toEqual([[externalToolId], [otherExternalTool.id]]); + }); + + it('should return a group for each course where the tool of the users pseudonym is used', async () => { + const { pseudonym, clientId, courses } = setup(); + + const result = await service.getUserGroups(pseudonym.pseudonym, clientId); + + expect(result).toEqual({ + data: { + groups: [ + { + group_id: courses[0].id, + name: courses[0].name, + student_count: courses[0].students.count(), + }, + ], + }, + }); + }); + }); + + describe('when pseudonym does not exists', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build(); + + pseudonymService.findPseudonymByPseudonym.mockResolvedValue(null); + + return { + pseudonym, + }; + }; + + it('should throw an NotFoundLoggableException', async () => { + const { pseudonym } = setup(); + + const func = service.getUserGroups(pseudonym.pseudonym, 'externalToolId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(UserDO.name, 'pseudonym', pseudonym.pseudonym) + ); + }); + }); + }); + + describe('getGroup', () => { + describe('when valid courseId and oauth2ClientId is given', () => { + const setup = () => { + let courseA: Course = courseFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId(); + const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: schoolEntity.id }); + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalToolId: string = externalTool.id as string; + const otherExternalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + schoolId: school.id, + }); + const otherSchoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: otherExternalTool.id, + schoolId: school.id, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string, schoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + const otherContextExternalTool: ContextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(otherSchoolExternalTool.id as string, otherSchoolExternalTool.schoolId) + .buildWithId({ + contextRef: new ContextRef({ + id: courseA.id, + type: ToolContextType.COURSE, + }), + }); + + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const student1: UserDO = userDoFactory.build({ id: studentUser.id }); + const student1Pseudonym: Pseudonym = pseudonymFactory.build({ + userId: student1.id, + toolId: contextExternalTool.id, + }); + + const { studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const student2: UserDO = userDoFactory.build({ id: studentUser2.id }); + const student2Pseudonym: Pseudonym = pseudonymFactory.build({ + userId: student2.id, + toolId: contextExternalTool.id, + }); + + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const teacher: UserDO = userDoFactory.build({ id: teacherUser.id }); + const teacherPseudonym: Pseudonym = pseudonymFactory.build({ + userId: teacher.id, + toolId: contextExternalTool.id, + }); + + const { teacherUser: substitutionTeacherUser } = UserAndAccountTestFactory.buildTeacher(); + const substitutionTeacher: UserDO = userDoFactory.build({ id: substitutionTeacherUser.id }); + const substitutionTeacherPseudonym: Pseudonym = pseudonymFactory.build({ + userId: substitutionTeacher.id, + toolId: contextExternalTool.id, + }); + + courseA = courseFactory.build({ + ...courseA, + school: schoolEntity, + students: [studentUser, studentUser2], + teachers: [teacherUser], + substitutionTeachers: [substitutionTeacherUser], + }); + + courseService.findById.mockResolvedValue(courseA); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValue(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValueOnce([schoolExternalTool]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([ + contextExternalTool, + otherContextExternalTool, + ]); + + userService.findById.mockResolvedValueOnce(student1); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(student1Pseudonym); + userService.findById.mockResolvedValueOnce(student2); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(student2Pseudonym); + userService.findById.mockResolvedValueOnce(teacher); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(teacherPseudonym); + userService.findById.mockResolvedValueOnce(substitutionTeacher); + pseudonymService.findOrCreatePseudonym.mockResolvedValueOnce(substitutionTeacherPseudonym); + + const mockedIframeSubject = 'mockedIframeSubject'; + pseudonymService.getIframeSubject.mockReturnValue(mockedIframeSubject); + + return { + externalTool, + externalToolId, + courseA, + schoolExternalTool, + mockedIframeSubject, + student1, + student2, + teacher, + substitutionTeacher, + student1Pseudonym, + student2Pseudonym, + teacherPseudonym, + substitutionTeacherPseudonym, + }; + }; + + it('should call the course service to find the course', async () => { + const { externalToolId, courseA } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(courseService.findById).toHaveBeenCalledWith(courseA.id); + }); + + it('should call the external tool service to find the external tool', async () => { + const { externalToolId } = setup(); + + await service.getGroup('courseId', externalToolId); + + expect(externalToolService.findExternalToolByOAuth2ConfigClientId).toHaveBeenCalledWith(externalToolId); + }); + + it('should call the school external tool service to find the school external tool', async () => { + const { externalToolId, schoolExternalTool } = setup(); + + await service.getGroup('courseId', externalToolId); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + schoolId: schoolExternalTool.schoolId, + toolId: schoolExternalTool.toolId, + }); + }); + + it('should call the context external tool service to find the context external tool', async () => { + const { externalToolId, courseA } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith( + new ContextRef({ id: courseA.id, type: ToolContextType.COURSE }) + ); + }); + + it('should call the user service to find the students', async () => { + const { externalToolId, courseA } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(userService.findById.mock.calls).toEqual([ + [courseA.students[0].id], + [courseA.students[1].id], + [courseA.teachers[0].id], + [courseA.substitutionTeachers[0].id], + ]); + }); + + it('should call the pseudonym service to find the pseudonyms', async () => { + const { externalToolId, externalTool, courseA, student1, student2, teacher, substitutionTeacher } = setup(); + + await service.getGroup(courseA.id, externalToolId); + + expect(pseudonymService.findOrCreatePseudonym.mock.calls).toEqual([ + [student1, externalTool], + [student2, externalTool], + [teacher, externalTool], + [substitutionTeacher, externalTool], + ]); + }); + + it('should return a group for the course where the tool of the users pseudonym is used', async () => { + const { + externalToolId, + courseA, + mockedIframeSubject, + student1Pseudonym, + student2Pseudonym, + teacherPseudonym, + substitutionTeacherPseudonym, + } = setup(); + + const result = await service.getGroup(courseA.id, externalToolId); + + expect(result).toEqual({ + data: { + students: [ + { + user_id: student1Pseudonym.pseudonym, + username: mockedIframeSubject, + }, + { + user_id: student2Pseudonym.pseudonym, + username: mockedIframeSubject, + }, + ], + teachers: [ + { + user_id: teacherPseudonym.pseudonym, + username: mockedIframeSubject, + }, + { + user_id: substitutionTeacherPseudonym.pseudonym, + username: mockedIframeSubject, + }, + ], + }, + }); + }); + }); + + describe('when invalid oauth2Client was given and external tool was not found', () => { + const setup = () => { + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValueOnce(null); + }; + + it('should throw an NotFoundLoggableException', async () => { + setup(); + + const func = service.getGroup('courseId', 'oauth2ClientId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(ExternalTool.name, 'config.clientId', 'oauth2ClientId') + ); + }); + }); + + describe('when no school external tool was found which belongs to the external tool', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const externalToolId: string = externalTool.id as string; + const course: Course = courseFactory.buildWithId(); + + courseService.findById.mockResolvedValue(course); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValueOnce(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValueOnce([]); + + return { + externalToolId, + }; + }; + + it('should throw an NotFoundLoggableException', async () => { + const { externalToolId } = setup(); + + const func = service.getGroup('courseId', 'oauth2ClientId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(SchoolExternalTool.name, 'toolId', externalToolId) + ); + }); + }); + + describe('when no context external tool was found which belongs to the course', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const course: Course = courseFactory.buildWithId(); + + courseService.findById.mockResolvedValue(course); + externalToolService.findExternalToolByOAuth2ConfigClientId.mockResolvedValueOnce(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValueOnce([schoolExternalTool]); + contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); + }; + + it('should throw an NotFoundLoggableException', async () => { + setup(); + + const func = service.getGroup('courseId', 'oauth2ClientId'); + + await expect(func).rejects.toThrow( + new NotFoundLoggableException(ContextExternalTool.name, 'contextRef.id', 'courseId') + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts new file mode 100644 index 00000000000..a5fd359b6c1 --- /dev/null +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts @@ -0,0 +1,242 @@ +import { Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Course, EntityId, Pseudonym, RoleName, RoleReference, UserDO } from '@shared/domain'; +import { CourseService } from '@src/modules/learnroom/service'; +import { ToolContextType } from '@src/modules/tool/common/enum'; +import { ContextExternalTool, ContextRef } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolService } from '@src/modules/tool/context-external-tool/service'; +import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { ExternalToolService } from '@src/modules/tool/external-tool/service'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { SchoolExternalToolService } from '@src/modules/tool/school-external-tool/service'; +import { UserService } from '@src/modules/user'; +import { PseudonymService } from './pseudonym.service'; + +interface UserMetdata { + data: { + user_id: string; + username: string; + type: string; + }; +} + +interface UserGroups { + data: { + groups: UserGroup[]; + }; +} + +interface UserGroup { + group_id: string; + name: string; + student_count: number; +} + +interface UserData { + user_id: string; + username: string; +} + +interface Group { + data: { + students: UserData[]; + teachers: UserData[]; + }; +} + +/** + * Please do not use this service in any other nest modules. + * This service will be called from feathers to get the roster data for ctl pseudonyms {@link ExternalToolPseudonymEntity}. + * These data will be used e.g. by bettermarks to resolve and display the usernames. + */ +@Injectable() +export class FeathersRosterService { + constructor( + private readonly userService: UserService, + private readonly pseudonymService: PseudonymService, + private readonly courseService: CourseService, + private readonly externalToolService: ExternalToolService, + private readonly schoolExternalToolService: SchoolExternalToolService, + private readonly contextExternalToolService: ContextExternalToolService + ) {} + + async getUsersMetadata(pseudonym: string): Promise { + const loadedPseudonym: Pseudonym = await this.findPseudonymByPseudonym(pseudonym); + const user: UserDO = await this.userService.findById(loadedPseudonym.userId); + + const userMetadata: UserMetdata = { + data: { + user_id: user.id as string, + username: this.pseudonymService.getIframeSubject(loadedPseudonym.pseudonym), + type: this.getUserRole(user), + }, + }; + + return userMetadata; + } + + async getUserGroups(pseudonym: string, oauth2ClientId: string): Promise { + const loadedPseudonym: Pseudonym = await this.findPseudonymByPseudonym(pseudonym); + const externalTool: ExternalTool = await this.validateAndGetExternalTool(oauth2ClientId); + + let courses: Course[] = await this.getCoursesFromUsersPseudonym(loadedPseudonym); + courses = await this.filterCoursesByToolAvailability(courses, externalTool.id as string); + + const userGroups: UserGroups = { + data: { + groups: courses.map((course) => { + return { + group_id: course.id, + name: course.name, + student_count: course.students.length, + }; + }), + }, + }; + + return userGroups; + } + + async getGroup(courseId: EntityId, oauth2ClientId: string): Promise { + const course: Course = await this.courseService.findById(courseId); + const externalTool: ExternalTool = await this.validateAndGetExternalTool(oauth2ClientId); + + await this.validateSchoolExternalTool(course.school.id, externalTool.id as string); + await this.validateContextExternalTools(courseId); + + const [studentEntities, teacherEntities, substitutionTeacherEntities] = await Promise.all([ + course.students.loadItems(), + course.teachers.loadItems(), + course.substitutionTeachers.loadItems(), + ]); + + const [students, teachers, substitutionTeachers] = await Promise.all([ + Promise.all(studentEntities.map((user) => this.userService.findById(user.id))), + Promise.all(teacherEntities.map((user) => this.userService.findById(user.id))), + Promise.all(substitutionTeacherEntities.map((user) => this.userService.findById(user.id))), + ]); + + const [studentPseudonyms, teacherPseudonyms, substitutionTeacherPseudonyms] = await Promise.all([ + this.getAndPseudonyms(students, externalTool), + this.getAndPseudonyms(teachers, externalTool), + this.getAndPseudonyms(substitutionTeachers, externalTool), + ]); + + const allTeacherPseudonyms: Pseudonym[] = teacherPseudonyms.concat(substitutionTeacherPseudonyms); + + const group: Group = { + data: { + students: studentPseudonyms.map((pseudonym: Pseudonym) => this.mapPseudonymToUserData(pseudonym)), + teachers: allTeacherPseudonyms.map((pseudonym: Pseudonym) => this.mapPseudonymToUserData(pseudonym)), + }, + }; + + return group; + } + + private async getAndPseudonyms(users: UserDO[], externalTool: ExternalTool): Promise { + const pseudonyms: Pseudonym[] = await Promise.all( + users.map((user: UserDO) => this.pseudonymService.findOrCreatePseudonym(user, externalTool)) + ); + + return pseudonyms; + } + + private getUserRole(user: UserDO): string { + const roleName = user.roles.some((role: RoleReference) => role.name === RoleName.TEACHER) + ? RoleName.TEACHER + : RoleName.STUDENT; + + return roleName; + } + + private async findPseudonymByPseudonym(pseudonym: string): Promise { + const loadedPseudonym: Pseudonym | null = await this.pseudonymService.findPseudonymByPseudonym(pseudonym); + + if (!loadedPseudonym) { + throw new NotFoundLoggableException(Pseudonym.name, 'pseudonym', pseudonym); + } + + return loadedPseudonym; + } + + private async getCoursesFromUsersPseudonym(pseudonym: Pseudonym): Promise { + const courses: Course[] = await this.courseService.findAllByUserId(pseudonym.userId); + + return courses; + } + + private async filterCoursesByToolAvailability(courses: Course[], externalToolId: string): Promise { + const validCourses: Course[] = []; + + await Promise.all( + courses.map(async (course: Course) => { + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( + new ContextRef({ + id: course.id, + type: ToolContextType.COURSE, + }) + ); + + for await (const contextExternalTool of contextExternalTools) { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + contextExternalTool.schoolToolRef.schoolToolId + ); + const externalTool: ExternalTool = await this.externalToolService.findExternalToolById( + schoolExternalTool.toolId + ); + const isRequiredTool: boolean = externalTool.id === externalToolId; + + if (isRequiredTool) { + validCourses.push(course); + break; + } + } + }) + ); + + return validCourses; + } + + private async validateAndGetExternalTool(oauth2ClientId: string): Promise { + const externalTool: ExternalTool | null = await this.externalToolService.findExternalToolByOAuth2ConfigClientId( + oauth2ClientId + ); + + if (!externalTool || !externalTool.id) { + throw new NotFoundLoggableException(ExternalTool.name, 'config.clientId', oauth2ClientId); + } + + return externalTool; + } + + private async validateSchoolExternalTool(schoolId: EntityId, toolId: string): Promise { + const schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolService.findSchoolExternalTools({ + schoolId, + toolId, + }); + + if (schoolExternalTools.length === 0) { + throw new NotFoundLoggableException(SchoolExternalTool.name, 'toolId', toolId); + } + } + + private async validateContextExternalTools(courseId: EntityId): Promise { + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( + new ContextRef({ id: courseId, type: ToolContextType.COURSE }) + ); + + if (contextExternalTools.length === 0) { + throw new NotFoundLoggableException(ContextExternalTool.name, 'contextRef.id', courseId); + } + } + + private mapPseudonymToUserData(pseudonym: Pseudonym): UserData { + const userData: UserData = { + user_id: pseudonym.pseudonym, + username: this.pseudonymService.getIframeSubject(pseudonym.pseudonym), + }; + + return userData; + } +} diff --git a/apps/server/src/modules/pseudonym/service/index.ts b/apps/server/src/modules/pseudonym/service/index.ts index 04ad0bac3ab..c0474020149 100644 --- a/apps/server/src/modules/pseudonym/service/index.ts +++ b/apps/server/src/modules/pseudonym/service/index.ts @@ -1 +1,2 @@ export * from './pseudonym.service'; +export * from './feathers-roster.service'; diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts index 80c5b461c67..e2fbb6e1b1f 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts @@ -1,9 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; +import { IFindOptions, LtiToolDO, Page, Pseudonym, UserDO } from '@shared/domain'; import { externalToolFactory, ltiToolDOFactory, pseudonymFactory, userDoFactory } from '@shared/testing/factory'; import { ExternalTool } from '@src/modules/tool/external-tool/domain'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; import { PseudonymService } from './pseudonym.service'; @@ -42,7 +44,7 @@ describe('PseudonymService', () => { await module.close(); }); - describe('findByUserAndTool', () => { + describe('findByUserAndToolOrThrow', () => { describe('when user or tool is missing', () => { const setup = () => { const user: UserDO = userDoFactory.build({ id: undefined }); @@ -57,7 +59,9 @@ describe('PseudonymService', () => { it('should throw an error', async () => { const { user, externalTool } = setup(); - await expect(service.findByUserAndTool(user, externalTool)).rejects.toThrowError(InternalServerErrorException); + await expect(service.findByUserAndToolOrThrow(user, externalTool)).rejects.toThrowError( + InternalServerErrorException + ); }); }); @@ -75,7 +79,7 @@ describe('PseudonymService', () => { it('should call externalToolPseudonymRepo', async () => { const { user, externalTool } = setup(); - await service.findByUserAndTool(user, externalTool); + await service.findByUserAndToolOrThrow(user, externalTool); expect(externalToolPseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); }); @@ -95,7 +99,7 @@ describe('PseudonymService', () => { it('should call pseudonymRepo', async () => { const { user, ltiToolDO } = setup(); - await service.findByUserAndTool(user, ltiToolDO); + await service.findByUserAndToolOrThrow(user, ltiToolDO); expect(pseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, ltiToolDO.id); }); @@ -119,7 +123,7 @@ describe('PseudonymService', () => { it('should call pseudonymRepo.findByUserIdAndToolId', async () => { const { user, externalTool } = setup(); - await service.findByUserAndTool(user, externalTool); + await service.findByUserAndToolOrThrow(user, externalTool); expect(externalToolPseudonymRepo.findByUserIdAndToolIdOrFail).toHaveBeenCalledWith(user.id, externalTool.id); }); @@ -127,7 +131,7 @@ describe('PseudonymService', () => { it('should return a pseudonym', async () => { const { pseudonym, user, externalTool } = setup(); - const result: Pseudonym = await service.findByUserAndTool(user, externalTool); + const result: Pseudonym = await service.findByUserAndToolOrThrow(user, externalTool); expect(result).toEqual(pseudonym); }); @@ -148,7 +152,7 @@ describe('PseudonymService', () => { it('should pass the error without catching', async () => { const { user, externalTool } = setup(); - const func = async () => service.findByUserAndTool(user, externalTool); + const func = async () => service.findByUserAndToolOrThrow(user, externalTool); await expect(func).rejects.toThrow(NotFoundException); }); @@ -419,4 +423,112 @@ describe('PseudonymService', () => { }); }); }); + + describe('findPseudonymByPseudonym', () => { + describe('when pseudonym is missing', () => { + const setup = () => { + externalToolPseudonymRepo.findPseudonymByPseudonym.mockResolvedValue(null); + }; + + it('should return null', async () => { + setup(); + + const result: Pseudonym | null = await service.findPseudonymByPseudonym('pseudonym'); + + expect(result).toBeNull(); + }); + }); + + describe('when pseudonym is found', () => { + const setup = () => { + const pseudonym: Pseudonym = pseudonymFactory.build({ pseudonym: 'pseudonym' }); + + externalToolPseudonymRepo.findPseudonymByPseudonym.mockResolvedValue(pseudonym); + + return { + pseudonym, + }; + }; + + it('should call pseudonymRepo', async () => { + const { pseudonym } = setup(); + + await service.findPseudonymByPseudonym(pseudonym.pseudonym); + + expect(externalToolPseudonymRepo.findPseudonymByPseudonym).toHaveBeenCalledWith(pseudonym.pseudonym); + }); + + it('should return the pseudonym', async () => { + const { pseudonym } = setup(); + + const result: Pseudonym | null = await service.findPseudonymByPseudonym(pseudonym.pseudonym); + + expect(result).toBeDefined(); + }); + }); + }); + + describe('findPseudonym', () => { + describe('when query and params are given', () => { + const setup = () => { + const query: PseudonymSearchQuery = { + pseudonym: 'pseudonym', + }; + const options: IFindOptions = {}; + const page: Page = new Page([pseudonymFactory.build()], 1); + + externalToolPseudonymRepo.findPseudonym.mockResolvedValueOnce(page); + + return { + query, + options, + page, + }; + }; + + it('should call service with query and params', async () => { + const { query, options } = setup(); + + await service.findPseudonym(query, options); + + expect(externalToolPseudonymRepo.findPseudonym).toHaveBeenCalledWith(query, options); + }); + + it('should return page with pseudonyms', async () => { + const { query, options, page } = setup(); + + const pseudonymPage: Page = await service.findPseudonym(query, options); + + expect(pseudonymPage).toEqual>({ + data: [page.data[0]], + total: page.total, + }); + }); + }); + }); + + describe('getIframeSubject', () => { + describe('when pseudonym is given', () => { + const setup = () => { + const pseudonym = 'pseudonym'; + const host = 'https://host.de'; + Configuration.set('HOST', host); + + return { + pseudonym, + host, + }; + }; + + it('should return the iframeSubject', () => { + const { pseudonym, host } = setup(); + + const result: string = service.getIframeSubject(pseudonym); + + expect(result).toEqual( + `` + ); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts index 9df391dbd33..23819f2fd3b 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts @@ -1,8 +1,10 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { LtiToolDO, Pseudonym, UserDO } from '@shared/domain'; +import { IFindOptions, LtiToolDO, Page, Pseudonym, UserDO } from '@shared/domain'; import { ExternalTool } from '@src/modules/tool/external-tool/domain'; import { v4 as uuidv4 } from 'uuid'; +import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; @Injectable() @@ -12,7 +14,7 @@ export class PseudonymService { private readonly externalToolPseudonymRepo: ExternalToolPseudonymRepo ) {} - public async findByUserAndTool(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { + public async findByUserAndToolOrThrow(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { if (!user.id || !tool.id) { throw new InternalServerErrorException('User or tool id is missing'); } @@ -114,4 +116,24 @@ export class PseudonymService { return this.pseudonymRepo; } + + async findPseudonymByPseudonym(pseudonym: string): Promise { + const result: Pseudonym | null = await this.externalToolPseudonymRepo.findPseudonymByPseudonym(pseudonym); + + return result; + } + + async findPseudonym(query: PseudonymSearchQuery, options: IFindOptions): Promise> { + const result: Page = await this.externalToolPseudonymRepo.findPseudonym(query, options); + + return result; + } + + getIframeSubject(pseudonym: string): string { + const iFrameSubject = ``; + + return iFrameSubject; + } } diff --git a/apps/server/src/modules/pseudonym/uc/index.ts b/apps/server/src/modules/pseudonym/uc/index.ts new file mode 100644 index 00000000000..26aac594b34 --- /dev/null +++ b/apps/server/src/modules/pseudonym/uc/index.ts @@ -0,0 +1 @@ +export * from './pseudonym.uc'; diff --git a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts new file mode 100644 index 00000000000..87fbfcbb526 --- /dev/null +++ b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts @@ -0,0 +1,141 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo, Pseudonym, SchoolEntity, User } from '@shared/domain'; +import { legacySchoolDoFactory, pseudonymFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { ForbiddenException } from '@nestjs/common'; +import { Action, AuthorizationService } from '@src/modules/authorization'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +import { PseudonymService } from '../service'; +import { PseudonymUc } from './pseudonym.uc'; + +describe('PseudonymUc', () => { + let module: TestingModule; + let uc: PseudonymUc; + + let pseudonymService: DeepMocked; + let authorizationService: DeepMocked; + let schoolService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + PseudonymUc, + { + provide: PseudonymService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(PseudonymUc); + pseudonymService = module.get(PseudonymService); + authorizationService = module.get(AuthorizationService); + schoolService = module.get(LegacySchoolService); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('findPseudonymByPseudonym', () => { + describe('when valid user and params are given', () => { + const setup = () => { + const userId = 'userId'; + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId(); + const user: User = userFactory.buildWithId({ school: schoolEntity }); + user.school = schoolEntity; + const pseudonym: Pseudonym = new Pseudonym(pseudonymFactory.build({ userId: user.id })); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + pseudonymService.findPseudonymByPseudonym.mockResolvedValueOnce(pseudonym); + schoolService.getSchoolById.mockResolvedValue(school); + + return { + userId, + user, + school, + schoolEntity, + pseudonym, + }; + }; + + it('should call authorization service with params', async () => { + const { userId, user, school } = setup(); + + await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, school, { + action: Action.read, + requiredPermissions: [], + }); + }); + + it('should call service with pseudonym', async () => { + const { userId } = setup(); + + await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(pseudonymService.findPseudonymByPseudonym).toHaveBeenCalledWith('pseudonym'); + }); + + it('should call school service with school id from pseudonym user', async () => { + const { userId, schoolEntity } = setup(); + + await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(schoolService.getSchoolById).toHaveBeenCalledWith(schoolEntity.id); + }); + + it('should return pseudonym', async () => { + const { userId, pseudonym } = setup(); + + const foundPseudonym: Pseudonym = await uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + expect(foundPseudonym).toEqual(pseudonym); + }); + }); + + describe('when user is not authorized', () => { + const setup = () => { + const userId = 'userId'; + const user: User = userFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId(); + user.school = school; + const pseudonym: Pseudonym = new Pseudonym(pseudonymFactory.build()); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new ForbiddenException(); + }); + pseudonymService.findPseudonymByPseudonym.mockResolvedValueOnce(pseudonym); + + return { + userId, + }; + }; + + it('should throw forbidden exception', async () => { + const { userId } = setup(); + + const func = async () => uc.findPseudonymByPseudonym(userId, 'pseudonym'); + + await expect(func()).rejects.toThrow(ForbiddenException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts new file mode 100644 index 00000000000..a960a33bc3c --- /dev/null +++ b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId, LegacySchoolDo, Pseudonym, User } from '@shared/domain'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +import { PseudonymService } from '../service'; + +@Injectable() +export class PseudonymUc { + constructor( + private readonly pseudonymService: PseudonymService, + private readonly authorizationService: AuthorizationService, + private readonly schoolService: LegacySchoolService + ) {} + + async findPseudonymByPseudonym(userId: EntityId, pseudonym: string): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + + const foundPseudonym: Pseudonym | null = await this.pseudonymService.findPseudonymByPseudonym(pseudonym); + + if (foundPseudonym === null) { + throw new NotFoundLoggableException(Pseudonym.name, 'pseudonym', pseudonym); + } + + const pseudonymUserId: string = foundPseudonym.userId; + const pseudonymUser: User = await this.authorizationService.getUserWithPermissions(pseudonymUserId); + const pseudonymSchool: LegacySchoolDo = await this.schoolService.getSchoolById(pseudonymUser.school.id); + + this.authorizationService.checkPermission(user, pseudonymSchool, AuthorizationContextBuilder.read([])); + + return foundPseudonym; + } +} diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 22997d30f50..20b346929cd 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -36,6 +36,7 @@ import connectRedis from 'connect-redis'; import session from 'express-session'; import { RedisClient } from 'redis'; import { TeamsApiModule } from '@src/modules/teams/teams-api.module'; +import { PseudonymApiModule } from '@src/modules/pseudonym/pseudonym-api.module'; import { ServerController } from './controller/server.controller'; import { serverConfig } from './server.config'; @@ -74,6 +75,7 @@ const serverModules = [ BoardApiModule, GroupApiModule, TeamsApiModule, + PseudonymApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts index b6ce4ffd55e..826d6d69d91 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts @@ -3,7 +3,7 @@ import { EntityId, LtiPrivacyPermission, Pseudonym, RoleName, UserDO } from '@sh import { RoleReference } from '@shared/domain/domainobject'; import { CourseService } from '@src/modules/learnroom/service'; import { LegacySchoolService } from '@src/modules/legacy-school'; -import { PseudonymService } from '@src/modules/pseudonym'; +import { PseudonymService } from '@src/modules/pseudonym/service'; import { UserService } from '@src/modules/user'; import { Authorization } from 'oauth-1.0a'; import { LtiRole } from '../../../common/enum'; diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts index 8efd3ff9a7d..bc4b3878ea5 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.spec.ts @@ -241,7 +241,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getNameWithPrefix.mockReturnValue(groupId); - pseudonymService.findByUserAndTool.mockRejectedValueOnce(undefined); + pseudonymService.findByUserAndToolOrThrow.mockRejectedValueOnce(undefined); client.findGroupFolderIdForGroupId.mockResolvedValue(folderId); client.getGroupUsers.mockResolvedValue([userId]); @@ -306,7 +306,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getNameWithPrefix.mockReturnValue(groupId); client.getGroupUsers.mockResolvedValue([nextCloudUserId]); - pseudonymService.findByUserAndTool.mockRejectedValueOnce(undefined); + pseudonymService.findByUserAndToolOrThrow.mockRejectedValueOnce(undefined); client.findGroupFolderIdForGroupId.mockResolvedValueOnce(folderId); const expectedFolderName: string = NextcloudStrategySpec.specGenerateGroupFolderName(teamDto.id, teamDto.name); @@ -396,7 +396,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getGroupUsers.mockResolvedValue([]); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); client.getNameWithPrefix.mockReturnValue(nextCloudUserId); userService.findById.mockResolvedValue(userDo); @@ -424,7 +424,7 @@ describe('NextCloudStrategy', () => { await strategy.specUpdateTeamUsersInGroup(groupId, teamUsers); - expect(pseudonymService.findByUserAndTool).toHaveBeenCalledWith(userDo, nextcloudTool); + expect(pseudonymService.findByUserAndToolOrThrow).toHaveBeenCalledWith(userDo, nextcloudTool); }); it('should call userService.findById', async () => { @@ -476,7 +476,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getGroupUsers.mockResolvedValue([nextCloudUserId]); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); client.getNameWithPrefix.mockReturnValue(nextCloudUserId); return { teamUsers, nextCloudUserId, groupId }; @@ -495,7 +495,7 @@ describe('NextCloudStrategy', () => { await strategy.specUpdateTeamUsersInGroup(groupId, teamUsers); - expect(pseudonymService.findByUserAndTool).not.toHaveBeenCalled(); + expect(pseudonymService.findByUserAndToolOrThrow).not.toHaveBeenCalled(); }); it('should not call clients getNameWithPrefix', async () => { @@ -542,7 +542,7 @@ describe('NextCloudStrategy', () => { ltiToolRepo.findByName.mockResolvedValue([nextcloudTool]); client.getGroupUsers.mockResolvedValue([nextCloudUserId]); - pseudonymService.findByUserAndTool.mockResolvedValueOnce(pseudonym).mockRejectedValueOnce(undefined); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValueOnce(pseudonym).mockRejectedValueOnce(undefined); client.getNameWithPrefix.mockReturnValue(nextCloudUserId); return { teamUsers, groupId }; @@ -582,7 +582,7 @@ describe('NextCloudStrategy', () => { client.getGroupUsers.mockResolvedValue([nextCloudUserId]); ltiToolRepo.findByName.mockResolvedValue([nextcloudTool, nextcloudTool2]); - pseudonymService.findByUserAndTool.mockResolvedValue(pseudonym); + pseudonymService.findByUserAndToolOrThrow.mockResolvedValue(pseudonym); return { user, teamUsers, pseudonym, nextCloudUserId, groupId }; }; diff --git a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts index 16f8c62aff3..1292bff3a42 100644 --- a/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts +++ b/apps/server/src/shared/infra/collaborative-storage/strategy/nextcloud/nextcloud.strategy.ts @@ -135,7 +135,7 @@ export class NextcloudStrategy implements ICollaborativeStorageStrategy { teamUsers.map(async (teamUser: TeamUserDto): Promise => { const user: UserDO = await this.userService.findById(teamUser.userId); const userId = await this.pseudonymService - .findByUserAndTool(user, nextcloudTool) + .findByUserAndToolOrThrow(user, nextcloudTool) .then((pseudonymDO: Pseudonym) => this.client.getNameWithPrefix(pseudonymDO.pseudonym)) .catch(() => ''); diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index f61ae282350..1ba9fa09aea 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -236,7 +236,7 @@ describe('ExternalToolRepo', () => { }; describe('pagination', () => { - it('should return all ltiTools when options with pagination is set to undefined', async () => { + it('should return all external tools when options with pagination is set to undefined', async () => { const { queryExternalToolDO, ltiTools } = await setupFind(); const page: Page = await repo.find(queryExternalToolDO, undefined); @@ -244,7 +244,7 @@ describe('ExternalToolRepo', () => { expect(page.data.length).toBe(ltiTools.length); }); - it('should return one ltiTool when pagination has a limit of 1', async () => { + it('should return one external tools when pagination has a limit of 1', async () => { const { queryExternalToolDO, options } = await setupFind(); options.pagination = { limit: 1 }; @@ -253,7 +253,7 @@ describe('ExternalToolRepo', () => { expect(page.data.length).toBe(1); }); - it('should return no ltiTool when pagination has a limit of 1 and skip is set to 2', async () => { + it('should return no external tools when pagination has a limit of 1 and skip is set to 2', async () => { const { queryExternalToolDO, options } = await setupFind(); options.pagination = { limit: 1, skip: 3 }; @@ -264,7 +264,7 @@ describe('ExternalToolRepo', () => { }); describe('order', () => { - it('should return ltiTools ordered by default _id when no order is specified', async () => { + it('should return external tools ordered by default _id when no order is specified', async () => { const { queryExternalToolDO, options, ltiTools } = await setupFind(); const page: Page = await repo.find(queryExternalToolDO, options); @@ -274,7 +274,7 @@ describe('ExternalToolRepo', () => { expect(page.data[2].name).toEqual(ltiTools[2].name); }); - it('should return ltiTools ordered by name ascending', async () => { + it('should return external tools ordered by name ascending', async () => { const { queryExternalToolDO, options, ltiTools } = await setupFind(); options.order = { diff --git a/src/services/roster/index.js b/src/services/roster/index.js index 1d1c84a4087..182f2e74a4e 100644 --- a/src/services/roster/index.js +++ b/src/services/roster/index.js @@ -1,6 +1,6 @@ const { static: staticContent } = require('@feathersjs/express'); const path = require('path'); - +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); const hooks = require('./hooks'); const globalHooks = require('../../hooks'); const oauth2 = require('../oauth2/hooks'); @@ -30,8 +30,13 @@ module.exports = function roster() { const metadataHandler = { async find(params) { const { pseudonym } = params; - const userParam = params.route.user; + if (Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { + const userMetadata = await app.service('nest-feathers-roster-service').getUsersMetadata(pseudonym); + return userMetadata; + } + + const userParam = params.route.user; const pseudonyms = await app.service('pseudonym').find({ query: { pseudonym, @@ -84,6 +89,13 @@ module.exports = function roster() { */ const userGroupsHandler = { async find(params) { + if (Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { + const userGroups = await app + .service('nest-feathers-roster-service') + .getUserGroups(params.pseudonym, params.tokenInfo.client_id); + return userGroups; + } + const pseudonyms = await app.service('pseudonym').find({ query: { pseudonym: params.pseudonym, @@ -112,11 +124,13 @@ module.exports = function roster() { // all users courses with given tool enabled return { data: { - groups: courses.map((course) => ({ - group_id: course._id.toString(), - name: course.name, - student_count: course.userIds.length, - })), + groups: courses.map((course) => { + return { + group_id: course._id.toString(), + name: course.name, + student_count: course.userIds.length, + }; + }), }, }; }, @@ -144,6 +158,11 @@ module.exports = function roster() { */ const groupsHandler = { async get(id, params) { + if (Configuration.get('FEATURE_CTL_TOOLS_TAB_ENABLED')) { + const group = await app.service('nest-feathers-roster-service').getGroup(id, params.tokenInfo.client_id); + return group; + } + const courseService = app.service('courses'); const courseId = id; if (!isValidObjectId(courseId)) { @@ -184,14 +203,18 @@ module.exports = function roster() { return { data: { - students: users.data.map((user) => ({ - user_id: user.pseudonym, - username: oauth2.getSubject(user.pseudonym, app.settings.services.web), - })), - teachers: teachers.data.map((user) => ({ - user_id: user.pseudonym, - username: oauth2.getSubject(user.pseudonym, app.settings.services.web), - })), + students: users.data.map((user) => { + return { + user_id: user.pseudonym, + username: oauth2.getSubject(user.pseudonym, app.settings.services.web), + }; + }), + teachers: teachers.data.map((user) => { + return { + user_id: user.pseudonym, + username: oauth2.getSubject(user.pseudonym, app.settings.services.web), + }; + }), }, }; }, diff --git a/test/services/roster/index.test.js b/test/services/roster/index.test.js index af54f95ab81..791ad6b627f 100644 --- a/test/services/roster/index.test.js +++ b/test/services/roster/index.test.js @@ -2,8 +2,12 @@ const assert = require('assert'); const chai = require('chai'); const chaiHttp = require('chai-http'); +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); +const sinon = require('sinon'); const appPromise = require('../../../src/app'); - +const { + FeathersRosterService, +} = require('../../../dist/apps/server/modules/pseudonym/service/feathers-roster.service'); chai.use(chaiHttp); @@ -88,6 +92,10 @@ describe('roster service', function oauth() { return Promise.resolve(); }); + afterEach(() => { + sinon.restore(); + }); + after(() => Promise.all([ pseudonymService.remove(null, { query: {} }), @@ -103,41 +111,199 @@ describe('roster service', function oauth() { assert.ok(groupsService); }); - it('GET metadata', () => - metadataService.find({ route: { user: pseudonym1 } }).then((metadata) => { - assert.strictEqual(pseudonym1, metadata.data.user_id); - assert.strictEqual('teacher', metadata.data.type); - })); - - it('GET user groups', (done) => { - userGroupsService - .find({ - route: { user: pseudonym1 }, - tokenInfo: { client_id: testToolTemplate.oAuthClientId }, - }) - .then((groups) => { - const group1 = groups.data.groups[0]; - assert.strictEqual(testCourse._id, group1.group_id); - assert.strictEqual(testCourse.name, group1.name); - assert.strictEqual(testCourse.userIds.length, group1.student_count); - done(); + describe('GET metadata', () => { + describe('when CTL feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', true); + const nestGetUsersMetadataStub = sinon.stub(FeathersRosterService.prototype, 'getUsersMetadata'); + + return { + nestGetUsersMetadataStub, + }; + }; + + it('should call nest feathers roster service', () => { + const { nestGetUsersMetadataStub } = setup(); + + metadataService.find({ route: { user: pseudonym1 } }).then((metadata) => { + assert.strictEqual(pseudonym1, metadata.data.user_id); + assert.strictEqual('teacher', metadata.data.type); + assert.ok(nestGetUsersMetadataStub.calledOnce); + }); + }); + }); + + describe('when CTL feature is not enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', false); + + const nestGetUsersMetadataStub = sinon.stub(FeathersRosterService.prototype, 'getUsersMetadata'); + + return { + nestGetUsersMetadataStub, + }; + }; + + it('should not call nest feathers roster service', () => { + const { nestGetUsersMetadataStub } = setup(); + + metadataService.find({ route: { user: pseudonym1 } }).then(() => { + assert.ok(nestGetUsersMetadataStub.notCalled); + }); + }); + + it('GET metadata', () => { + setup(); + + metadataService.find({ route: { user: pseudonym1 } }).then((metadata) => { + assert.strictEqual(pseudonym1, metadata.data.user_id); + assert.strictEqual('teacher', metadata.data.type); + }); + }); + }); + }); + + describe('GET user groups', () => { + describe('when CTL feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', true); + + const nestGetUserGroupsStub = sinon.stub(FeathersRosterService.prototype, 'getUserGroups'); + + return { + nestGetUserGroupsStub, + }; + }; + + it('should call nest feathers roster service', () => { + const { nestGetUserGroupsStub } = setup(); + + userGroupsService + .find({ + route: { user: pseudonym1 }, + tokenInfo: { client_id: testToolTemplate.oAuthClientId }, + }) + .then(() => { + assert.ok(nestGetUserGroupsStub.calledOnce); + }); + }); + }); + + describe('when CTL feature is not enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', false); + + const nestGetUserGroupsStub = sinon.stub(FeathersRosterService.prototype, 'getUserGroups'); + + return { + nestGetUserGroupsStub, + }; + }; + + it('should not call nest feathers roster service', () => { + const { nestGetUserGroupsStub } = setup(); + + userGroupsService + .find({ + route: { user: pseudonym1 }, + tokenInfo: { client_id: testToolTemplate.oAuthClientId }, + }) + .then(() => { + assert.ok(nestGetUserGroupsStub.notCalled); + }); }); + + it('GET user groups', (done) => { + setup(); + + userGroupsService + .find({ + route: { user: pseudonym1 }, + tokenInfo: { client_id: testToolTemplate.oAuthClientId }, + }) + .then((groups) => { + const group1 = groups.data.groups[0]; + assert.strictEqual(testCourse._id, group1.group_id); + assert.strictEqual(testCourse.name, group1.name); + assert.strictEqual(testCourse.userIds.length, group1.student_count); + done(); + }); + }); + }); }); - it('GET group', (done) => { - groupsService - .get(testCourse._id, { - tokenInfo: { - client_id: testToolTemplate.oAuthClientId, - obfuscated_subject: pseudonym1, - }, - }) - .then((group) => { - assert.strictEqual(pseudonym1, group.data.teachers[0].user_id); - const properties = 'title="username" style="height: 26px; width: 180px; border: none;"'; - const iframe = ``; - assert.strictEqual(iframe, group.data.teachers[0].username); - done(); + describe('GET group', () => { + describe('when CTL feature is enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', true); + + const nestGroupStub = sinon.stub(FeathersRosterService.prototype, 'getGroup'); + + return { + nestGroupStub, + }; + }; + + it('should call nest feathers roster service', () => { + const { nestGroupStub } = setup(); + + groupsService + .get(testCourse._id, { + tokenInfo: { + client_id: testToolTemplate.oAuthClientId, + obfuscated_subject: pseudonym1, + }, + }) + .then(() => { + assert.ok(nestGroupStub.calledOnce); + }); + }); + }); + + describe('when CTL feature is not enabled', () => { + const setup = () => { + Configuration.set('FEATURE_CTL_TOOLS_TAB_ENABLED', false); + + const nestGroupStub = sinon.stub(FeathersRosterService.prototype, 'getGroup'); + + return { + nestGroupStub, + }; + }; + + it('should not call nest feathers roster service', () => { + const { nestGroupStub } = setup(); + + groupsService + .get(testCourse._id, { + tokenInfo: { + client_id: testToolTemplate.oAuthClientId, + obfuscated_subject: pseudonym1, + }, + }) + .then(() => { + assert.ok(nestGroupStub.notCalled); + }); + }); + + it('GET group', (done) => { + setup(); + + groupsService + .get(testCourse._id, { + tokenInfo: { + client_id: testToolTemplate.oAuthClientId, + obfuscated_subject: pseudonym1, + }, + }) + .then((group) => { + assert.strictEqual(pseudonym1, group.data.teachers[0].user_id); + const properties = 'title="username" style="height: 26px; width: 180px; border: none;"'; + const iframe = ``; + assert.strictEqual(iframe, group.data.teachers[0].username); + done(); + }); }); + }); }); }); From b1e889e4c7fb688d9a6c063fea90257bc1aa6f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:46:36 +0200 Subject: [PATCH 03/34] N21-1250 CTL board element (#4419) --- .../content-element-create.api.spec.ts | 12 +++- .../board/controller/card.controller.ts | 11 +++- .../element/any-content-element.response.ts | 4 +- .../element/external-tool-element.response.ts | 33 ++++++++++ .../board/controller/dto/element/index.ts | 1 + .../update-element-content.body.params.ts | 34 +++++++++-- .../submission-item/submissions.response.ts | 3 +- .../controller/dto/user-data.response.ts | 6 +- .../board/controller/element.controller.ts | 16 +++-- .../content-element-response.factory.ts | 2 + .../external-tool-element-response.mapper.ts | 30 +++++++++ .../modules/board/controller/mapper/index.ts | 1 + .../board/repo/board-do.builder-impl.spec.ts | 24 +++++++- .../board/repo/board-do.builder-impl.ts | 17 ++++++ .../repo/recursive-delete.visitor.spec.ts | 25 +++++++- .../board/repo/recursive-delete.vistor.ts | 8 +++ .../board/repo/recursive-save.visitor.spec.ts | 27 +++++++- .../board/repo/recursive-save.visitor.ts | 19 ++++++ .../board-do-copy.service.spec.ts | 61 +++++++++++++++++++ .../recursive-copy.visitor.ts | 19 ++++++ .../content-element-update.visitor.spec.ts | 59 +++++++++++++++++- .../service/content-element-update.visitor.ts | 24 ++++++-- .../board/service/content-element.service.ts | 10 +-- apps/server/src/modules/board/uc/card.uc.ts | 12 +--- .../server/src/modules/board/uc/element.uc.ts | 8 +-- .../class/repo/mapper/class.mapper.spec.ts | 49 +++++++++------ .../modules/copy-helper/types/copy.types.ts | 1 + .../domain/domainobject/board/card.do.spec.ts | 13 +++- .../domain/domainobject/board/card.do.ts | 4 +- .../board/content-element.factory.spec.ts | 11 +++- .../board/content-element.factory.ts | 18 +++++- .../board/external-tool-element.do.spec.ts | 49 +++++++++++++++ .../board/external-tool-element.do.ts | 32 ++++++++++ .../shared/domain/domainobject/board/index.ts | 1 + .../board/types/any-content-element-do.ts | 6 +- .../board/types/board-composite-visitor.ts | 3 + .../board/types/content-elements.enum.ts | 7 ++- .../src/shared/domain/entity/all-entities.ts | 6 +- .../external-tool-element-node.entity.spec.ts | 60 ++++++++++++++++++ .../external-tool-element-node.entity.ts | 26 ++++++++ .../file-element-node.entity.spec.ts | 8 --- .../shared/domain/entity/boardnode/index.ts | 1 + .../rich-text-element-node.entity.spec.ts | 8 --- .../boardnode/types/board-do.builder.ts | 3 + .../entity/boardnode/types/board-node-type.ts | 1 + .../external-tool-element-node.factory.ts | 9 +++ .../shared/testing/factory/boardnode/index.ts | 1 + .../board/external-tool.do.factory.ts | 15 +++++ .../factory/domainobject/board/index.ts | 1 + config/default.schema.json | 5 ++ config/development.json | 3 +- src/services/config/publicAppConfigService.js | 1 + 52 files changed, 710 insertions(+), 98 deletions(-) create mode 100644 apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts create mode 100644 apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/external-tool-element.do.spec.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/external-tool-element.do.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.ts create mode 100644 apps/server/src/shared/testing/factory/boardnode/external-tool-element-node.factory.ts create mode 100644 apps/server/src/shared/testing/factory/domainobject/board/external-tool.do.factory.ts diff --git a/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts index 36e2b56733e..f6bcd16fd24 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts @@ -3,13 +3,13 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReferenceType, ContentElementType, RichTextElementNode } from '@shared/domain'; import { - TestApiClient, - UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, columnNodeFactory, courseFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server/server.module'; import { AnyContentElementResponse } from '../dto'; @@ -83,6 +83,14 @@ describe(`content element create (api)`, () => { expect((response.body as AnyContentElementResponse).type).toEqual(ContentElementType.FILE); }); + it('should return the created content element of type EXTERNAL_TOOL', async () => { + const { loggedInClient, cardNode } = await setup(); + + const response = await loggedInClient.post(`${cardNode.id}/elements`, { type: ContentElementType.EXTERNAL_TOOL }); + + expect((response.body as AnyContentElementResponse).type).toEqual(ContentElementType.EXTERNAL_TOOL); + }); + it('should return the created content element of type SUBMISSION_CONTAINER', async () => { const { loggedInClient, cardNode } = await setup(); diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index fea31d67d35..e76bdbe088c 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -23,13 +23,14 @@ import { CardListResponse, CardUrlParams, CreateContentElementBodyParams, + ExternalToolElementResponse, FileElementResponse, MoveCardBodyParams, RenameBodyParams, + RichTextElementResponse, SubmissionContainerElementResponse, } from './dto'; import { SetHeightBodyParams } from './dto/board/set-height.body.params'; -import { RichTextElementResponse } from './dto/element/rich-text-element.response'; import { CardResponseMapper, ContentElementResponseFactory } from './mapper'; @ApiTags('Board Card') @@ -114,7 +115,12 @@ export class CardController { } @ApiOperation({ summary: 'Create a new element on a card.' }) - @ApiExtraModels(RichTextElementResponse, FileElementResponse, SubmissionContainerElementResponse) + @ApiExtraModels( + RichTextElementResponse, + FileElementResponse, + SubmissionContainerElementResponse, + ExternalToolElementResponse + ) @ApiResponse({ status: 201, schema: { @@ -122,6 +128,7 @@ export class CardController { { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index f241de21002..1382b75b3f5 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,3 +1,4 @@ +import { ExternalToolElementResponse } from './external-tool-element.response'; import { FileElementResponse } from './file-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; import { SubmissionContainerElementResponse } from './submission-container-element.response'; @@ -5,4 +6,5 @@ import { SubmissionContainerElementResponse } from './submission-container-eleme export type AnyContentElementResponse = | FileElementResponse | RichTextElementResponse - | SubmissionContainerElementResponse; + | SubmissionContainerElementResponse + | ExternalToolElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts b/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts new file mode 100644 index 00000000000..5f51a1a26ec --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class ExternalToolElementContent { + constructor(props: ExternalToolElementContent) { + this.contextExternalToolId = props.contextExternalToolId; + } + + @ApiPropertyOptional() + contextExternalToolId?: string; +} + +export class ExternalToolElementResponse { + constructor(props: ExternalToolElementResponse) { + this.id = props.id; + this.type = props.type; + this.content = props.content; + this.timestamps = props.timestamps; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.EXTERNAL_TOOL; + + @ApiProperty() + content: ExternalToolElementContent; + + @ApiProperty() + timestamps: TimestampsResponse; +} diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 696b53b32a7..b1ef77f8ec0 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -4,3 +4,4 @@ export * from './update-element-content.body.params'; export * from './file-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; +export * from './external-tool-element.response'; diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 770e3e1c8ef..1f2a320119e 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -1,7 +1,7 @@ -import { ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { ContentElementType, InputFormat } from '@shared/domain'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsString, ValidateNested } from 'class-validator'; +import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; export abstract class ElementContentBody { @ApiProperty({ @@ -66,7 +66,27 @@ export class SubmissionContainerElementContentBody extends ElementContentBody { content!: SubmissionContainerContentBody; } -export type AnyElementContentBody = RichTextElementContentBody | FileContentBody; +export class ExternalToolContentBody { + @IsMongoId() + @IsOptional() + @ApiPropertyOptional() + contextExternalToolId?: string; +} + +export class ExternalToolElementContentBody extends ElementContentBody { + @ApiProperty({ type: ContentElementType.EXTERNAL_TOOL }) + type!: ContentElementType.EXTERNAL_TOOL; + + @ValidateNested() + @ApiProperty() + content!: ExternalToolContentBody; +} + +export type AnyElementContentBody = + | FileContentBody + | RichTextContentBody + | SubmissionContainerContentBody + | ExternalToolContentBody; export class UpdateElementContentBodyParams { @ValidateNested() @@ -77,6 +97,7 @@ export class UpdateElementContentBodyParams { { value: FileElementContentBody, name: ContentElementType.FILE }, { value: RichTextElementContentBody, name: ContentElementType.RICH_TEXT }, { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, + { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, ], }, keepDiscriminatorProperty: true, @@ -86,7 +107,12 @@ export class UpdateElementContentBodyParams { { $ref: getSchemaPath(FileElementContentBody) }, { $ref: getSchemaPath(RichTextElementContentBody) }, { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, + { $ref: getSchemaPath(ExternalToolElementContentBody) }, ], }) - data!: FileElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody; + data!: + | FileElementContentBody + | RichTextElementContentBody + | SubmissionContainerElementContentBody + | ExternalToolElementContentBody; } diff --git a/apps/server/src/modules/board/controller/dto/submission-item/submissions.response.ts b/apps/server/src/modules/board/controller/dto/submission-item/submissions.response.ts index 1092421b0c3..17ef89740da 100644 --- a/apps/server/src/modules/board/controller/dto/submission-item/submissions.response.ts +++ b/apps/server/src/modules/board/controller/dto/submission-item/submissions.response.ts @@ -1,5 +1,6 @@ -import { SubmissionItemResponse, UserDataResponse } from '@src/modules/board/controller/dto'; import { ApiProperty } from '@nestjs/swagger'; +import { UserDataResponse } from '../user-data.response'; +import { SubmissionItemResponse } from './submission-item.response'; export class SubmissionsResponse { constructor(submissionItemsResponse: SubmissionItemResponse[], users: UserDataResponse[]) { diff --git a/apps/server/src/modules/board/controller/dto/user-data.response.ts b/apps/server/src/modules/board/controller/dto/user-data.response.ts index 78b71d0de8a..595ba5f3f9b 100644 --- a/apps/server/src/modules/board/controller/dto/user-data.response.ts +++ b/apps/server/src/modules/board/controller/dto/user-data.response.ts @@ -8,11 +8,11 @@ export class UserDataResponse { } @ApiProperty() - firstName!: string; + firstName: string; @ApiProperty() - lastName!: string; + lastName: string; @ApiProperty() - userId!: string; + userId: string; } diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 7954f9a8374..361e59bf6b6 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -19,15 +19,14 @@ import { ElementUc } from '../uc/element.uc'; import { ContentElementUrlParams, CreateSubmissionItemBodyParams, - MoveContentElementBody, - SubmissionItemResponse, -} from './dto'; -import { + ExternalToolElementContentBody, FileElementContentBody, + MoveContentElementBody, RichTextElementContentBody, SubmissionContainerElementContentBody, + SubmissionItemResponse, UpdateElementContentBodyParams, -} from './dto/element/update-element-content.body.params'; +} from './dto'; import { SubmissionItemResponseMapper } from './mapper'; @ApiTags('Board Element') @@ -57,7 +56,12 @@ export class ElementController { } @ApiOperation({ summary: 'Update a single content element.' }) - @ApiExtraModels(FileElementContentBody, RichTextElementContentBody, SubmissionContainerElementContentBody) + @ApiExtraModels( + FileElementContentBody, + RichTextElementContentBody, + SubmissionContainerElementContentBody, + ExternalToolElementContentBody + ) @ApiResponse({ status: 204 }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 7139aefa2b5..3ccf11b1bf2 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -2,6 +2,7 @@ import { NotImplementedException } from '@nestjs/common'; import { AnyBoardDo } from '@shared/domain'; import { AnyContentElementResponse } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; +import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; import { RichTextElementResponseMapper } from './rich-text-element-response.mapper'; import { SubmissionContainerElementResponseMapper } from './submission-container-element-response.mapper'; @@ -11,6 +12,7 @@ export class ContentElementResponseFactory { FileElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), + ExternalToolElementResponseMapper.getInstance(), ]; static mapToResponse(element: AnyBoardDo): AnyContentElementResponse { diff --git a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts new file mode 100644 index 00000000000..a907f4eb157 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts @@ -0,0 +1,30 @@ +import { ContentElementType, ExternalToolElement } from '@shared/domain'; +import { ExternalToolElementContent, ExternalToolElementResponse, TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class ExternalToolElementResponseMapper implements BaseResponseMapper { + private static instance: ExternalToolElementResponseMapper; + + public static getInstance(): ExternalToolElementResponseMapper { + if (!ExternalToolElementResponseMapper.instance) { + ExternalToolElementResponseMapper.instance = new ExternalToolElementResponseMapper(); + } + + return ExternalToolElementResponseMapper.instance; + } + + mapToResponse(element: ExternalToolElement): ExternalToolElementResponse { + const result = new ExternalToolElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.EXTERNAL_TOOL, + content: new ExternalToolElementContent({ contextExternalToolId: element.contextExternalToolId }), + }); + + return result; + } + + canMap(element: ExternalToolElement): boolean { + return element instanceof ExternalToolElement; + } +} diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index ef1ab7962ba..116692df5a4 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -5,3 +5,4 @@ export * from './content-element-response.factory'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; +export * from './external-tool-element-response.mapper'; diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index 748f84f8a48..d640d3f6330 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -1,12 +1,13 @@ -import { BoardNodeType } from '@shared/domain'; +import { BoardNodeType, ExternalToolElement } from '@shared/domain'; import { cardNodeFactory, columnBoardNodeFactory, columnNodeFactory, + externalToolElementNodeFactory, fileElementNodeFactory, richTextElementNodeFactory, - submissionContainerElementNodeFactory, setupEntities, + submissionContainerElementNodeFactory, } from '@shared/testing'; import { BoardDoBuilderImpl } from './board-do.builder-impl'; @@ -185,6 +186,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a external tool element', () => { + it('should work without descendants', () => { + const externalToolElementNode = externalToolElementNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildExternalToolElement(externalToolElementNode); + + expect(domainObject.constructor.name).toBe(ExternalToolElement.name); + }); + + it('should throw error if submissionContainerElement is not a leaf', () => { + const externalToolElementNode = externalToolElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: externalToolElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildExternalToolElement(externalToolElementNode); + }).toThrowError(); + }); + }); + describe('ensure board node types', () => { it('should do nothing if type is correct', () => { const card = cardNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 2de5f7ecd1f..87dc4382798 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -5,6 +5,7 @@ import type { CardNode, ColumnBoardNode, ColumnNode, + ExternalToolElementNodeEntity, FileElementNode, RichTextElementNode, SubmissionContainerElementNode, @@ -16,6 +17,7 @@ import { Card, Column, ColumnBoard, + ExternalToolElement, FileElement, RichTextElement, SubmissionContainerElement, @@ -73,6 +75,7 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { BoardNodeType.FILE_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, + BoardNodeType.EXTERNAL_TOOL, ]); const elements = this.buildChildren(boardNode); @@ -144,6 +147,20 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + buildExternalToolElement(boardNode: ExternalToolElementNodeEntity): ExternalToolElement { + this.ensureLeafNode(boardNode); + + const element: ExternalToolElement = new ExternalToolElement({ + id: boardNode.id, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + contextExternalToolId: boardNode.contextExternalTool?.id, + }); + + return element; + } + buildChildren(boardNode: BoardNode): T[] { const children = this.getChildren(boardNode).map((node) => node.useDoBuilder(this)); return children as T[]; diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 3bfd5d87c63..75f0e9e2e99 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -1,10 +1,11 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { FileRecordParentType } from '@shared/infra/rabbitmq'; import { columnBoardFactory, columnFactory, + externalToolElementFactory, fileElementFactory, setupEntities, submissionContainerElementFactory, @@ -187,4 +188,26 @@ describe(RecursiveDeleteVisitor.name, () => { expect(em.remove).toHaveBeenCalledWith(em.getReference(childSubmissionItem.constructor, childSubmissionItem.id)); }); }); + + describe('visitExternalToolElementAsync', () => { + const setup = () => { + const childExternalToolElement = externalToolElementFactory.build(); + const externalToolElement = externalToolElementFactory.build({ + children: [childExternalToolElement], + }); + + return { externalToolElement, childExternalToolElement }; + }; + + it('should call entity remove', async () => { + const { externalToolElement, childExternalToolElement } = setup(); + + await service.visitExternalToolElementAsync(externalToolElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(externalToolElement.constructor, externalToolElement.id)); + expect(em.remove).toHaveBeenCalledWith( + em.getReference(childExternalToolElement.constructor, childExternalToolElement.id) + ); + }); + }); }); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index 73c1a8ac80a..aaed699c26c 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -7,6 +7,7 @@ import { Card, Column, ColumnBoard, + ExternalToolElement, FileElement, RichTextElement, SubmissionContainerElement, @@ -58,6 +59,13 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(submission); } + async visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise { + // TODO N21-1296: Delete linked ContextExternalTool + this.deleteNode(externalToolElement); + + await this.visitChildrenAsync(externalToolElement); + } + deleteNode(domainObject: AnyBoardDo): void { this.em.remove(this.em.getReference(BoardNode, domainObject.id)); } diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index c63f150e9b7..088b6f7f54c 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -5,6 +5,7 @@ import { CardNode, ColumnBoardNode, ColumnNode, + ExternalToolElementNodeEntity, FileElementNode, RichTextElementNode, SubmissionContainerElementNode, @@ -15,8 +16,11 @@ import { columnBoardFactory, columnBoardNodeFactory, columnFactory, + contextExternalToolEntityFactory, + externalToolElementFactory, fileElementFactory, richTextElementFactory, + setupEntities, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; @@ -28,10 +32,12 @@ describe(RecursiveSaveVisitor.name, () => { let em: DeepMocked; let boardNodeRepo: DeepMocked; - beforeAll(() => { + beforeAll(async () => { em = createMock(); boardNodeRepo = createMock(); + await setupEntities(); + visitor = new RecursiveSaveVisitor(em, boardNodeRepo); }); @@ -179,6 +185,25 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a external tool element', () => { + it('should create or update the node', () => { + const contextExternalTool = contextExternalToolEntityFactory.buildWithId(); + const externalToolElement = externalToolElementFactory.build({ + contextExternalToolId: contextExternalTool.id, + }); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitExternalToolElement(externalToolElement); + + const expectedNode: Partial = { + id: externalToolElement.id, + type: BoardNodeType.EXTERNAL_TOOL, + contextExternalTool, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('createOrUpdateBoardNode', () => { describe('when the board is new', () => { it('should persist the board node', () => { diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index e1a1d497453..082cc73c4f4 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -11,6 +11,8 @@ import { ColumnBoardNode, ColumnNode, EntityId, + ExternalToolElement, + ExternalToolElementNodeEntity, FileElement, FileElementNode, RichTextElement, @@ -20,6 +22,7 @@ import { SubmissionItem, SubmissionItemNode, } from '@shared/domain'; +import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; import { BoardNodeRepo } from './board-node.repo'; type ParentData = { @@ -148,6 +151,22 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.visitChildren(submission, boardNode); } + visitExternalToolElement(externalToolElement: ExternalToolElement): void { + const parentData: ParentData | undefined = this.parentsMap.get(externalToolElement.id); + + const boardNode: ExternalToolElementNodeEntity = new ExternalToolElementNodeEntity({ + id: externalToolElement.id, + contextExternalTool: externalToolElement.contextExternalToolId + ? this.em.getReference(ContextExternalToolEntity, externalToolElement.contextExternalToolId) + : undefined, + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.createOrUpdateBoardNode(boardNode); + this.visitChildren(externalToolElement, boardNode); + } + visitChildren(parent: AnyBoardDo, parentNode: BoardNode) { parent.children.forEach((child) => { this.registerParentData(parent, child, parentNode); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index 4f9fbd6aa28..ba3643e6051 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -4,10 +4,12 @@ import { Card, Column, ColumnBoard, + ExternalToolElement, FileElement, isCard, isColumn, isColumnBoard, + isExternalToolElement, isFileElement, isRichTextElement, isSubmissionContainerElement, @@ -19,6 +21,7 @@ import { cardFactory, columnBoardFactory, columnFactory, + externalToolElementFactory, fileElementFactory, richTextElementFactory, setupEntities, @@ -645,4 +648,62 @@ describe('recursive board copy visitor', () => { expect(result.type).toEqual(CopyElementType.SUBMISSION_ITEM); }); }); + + describe('when copying a external tool element', () => { + const setup = () => { + const original = externalToolElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getExternalToolElementFromStatus = (status: CopyStatus): ExternalToolElement => { + const copy = status.copyEntity; + + expect(isExternalToolElement(copy)).toEqual(true); + + return copy as ExternalToolElement; + }; + + it('should return a external tool element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isExternalToolElement(result.copyEntity)).toEqual(true); + }); + + it('should not copy tool', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); + + expect(copy.contextExternalToolId).toBeUndefined(); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getExternalToolElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type RichTextElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 6f30a40b510..4d1bf55f5ae 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -5,6 +5,7 @@ import { Column, ColumnBoard, EntityId, + ExternalToolElement, FileElement, RichTextElement, SubmissionContainerElement, @@ -167,6 +168,24 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { return Promise.resolve(); } + visitExternalToolElementAsync(original: ExternalToolElement): Promise { + const copy = new ExternalToolElement({ + id: new ObjectId().toHexString(), + contextExternalToolId: undefined, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitChildrenOf(boardDo: AnyBoardDo) { return Promise.allSettled(boardDo.children.map((child) => child.acceptAsync(this))); } diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index 479d90e3612..b96a6ca9a41 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -1,14 +1,16 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { InputFormat } from '@shared/domain'; import { cardFactory, columnBoardFactory, columnFactory, + externalToolElementFactory, fileElementFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; -import { FileContentBody, RichTextContentBody } from '../controller/dto'; +import { ExternalToolContentBody, FileContentBody, RichTextContentBody } from '../controller/dto'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; describe(ContentElementUpdateVisitor.name, () => { @@ -107,4 +109,59 @@ describe(ContentElementUpdateVisitor.name, () => { expect(() => updater.visitSubmissionContainerElement(submissionContainerElement)).toThrow(); }); }); + + describe('when visiting a external tool element', () => { + describe('when visiting a external tool element with valid content', () => { + const setup = () => { + const externalToolElement = externalToolElementFactory.build({ contextExternalToolId: undefined }); + const content = new ExternalToolContentBody(); + content.contextExternalToolId = new ObjectId().toHexString(); + const updater = new ContentElementUpdateVisitor(content); + + return { externalToolElement, updater, content }; + }; + + it('should update the content', () => { + const { externalToolElement, updater, content } = setup(); + + updater.visitExternalToolElement(externalToolElement); + + expect(externalToolElement.contextExternalToolId).toEqual(content.contextExternalToolId); + }); + }); + + describe('when visiting a external tool element using the wrong content', () => { + const setup = () => { + const externalToolElement = externalToolElementFactory.build(); + const content = new RichTextContentBody(); + content.text = 'a text'; + content.inputFormat = InputFormat.RICH_TEXT_CK5; + const updater = new ContentElementUpdateVisitor(content); + + return { externalToolElement, updater }; + }; + + it('should throw an error', () => { + const { externalToolElement, updater } = setup(); + + expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + }); + }); + + describe('when visiting a external tool element without setting a contextExternalId', () => { + const setup = () => { + const externalToolElement = externalToolElementFactory.build(); + const content = new ExternalToolContentBody(); + const updater = new ContentElementUpdateVisitor(content); + + return { externalToolElement, updater }; + }; + + it('should throw an error', () => { + const { externalToolElement, updater } = setup(); + + expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index 5d970baca83..d660fbee98c 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -5,20 +5,25 @@ import { Card, Column, ColumnBoard, + ExternalToolElement, FileElement, InputFormat, RichTextElement, SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; -import { FileContentBody, RichTextContentBody, SubmissionContainerContentBody } from '../controller/dto'; - -type ContentType = FileContentBody | RichTextContentBody | SubmissionContainerContentBody; +import { + AnyElementContentBody, + ExternalToolContentBody, + FileContentBody, + RichTextContentBody, + SubmissionContainerContentBody, +} from '../controller/dto'; export class ContentElementUpdateVisitor implements BoardCompositeVisitor { - private readonly content: ContentType; + private readonly content: AnyElementContentBody; - constructor(content: ContentType) { + constructor(content: AnyElementContentBody) { this.content = content; } @@ -64,6 +69,15 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitor { this.throwNotHandled(submission); } + visitExternalToolElement(externalToolElement: ExternalToolElement): void { + if (this.content instanceof ExternalToolContentBody && this.content.contextExternalToolId !== undefined) { + // Updates should not remove an existing reference to a tool, to prevent orphan tool instances + externalToolElement.contextExternalToolId = this.content.contextExternalToolId; + } else { + this.throwNotHandled(externalToolElement); + } + } + private throwNotHandled(component: AnyBoardDo) { throw new Error(`Cannot update element of type: '${component.constructor.name}'`); } diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index 1c92968ec15..bef5d076fc6 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -7,7 +7,7 @@ import { EntityId, isAnyContentElement, } from '@shared/domain'; -import { FileContentBody, RichTextContentBody, SubmissionContainerContentBody } from '../controller/dto'; +import { AnyElementContentBody } from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; @@ -45,13 +45,13 @@ export class ContentElementService { await this.boardDoService.move(element, targetCard, targetPosition); } - async update( - element: AnyContentElementDo, - content: FileContentBody | RichTextContentBody | SubmissionContainerContentBody - ): Promise { + async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { const updater = new ContentElementUpdateVisitor(content); + element.accept(updater); + const parent = await this.boardDoRepo.findParentOfId(element.id); + await this.boardDoRepo.save(element, parent); } } diff --git a/apps/server/src/modules/board/uc/card.uc.ts b/apps/server/src/modules/board/uc/card.uc.ts index bf79744f52d..577f3a8b963 100644 --- a/apps/server/src/modules/board/uc/card.uc.ts +++ b/apps/server/src/modules/board/uc/card.uc.ts @@ -1,13 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - AnyBoardDo, - Card, - ContentElementType, - EntityId, - FileElement, - RichTextElement, - SubmissionContainerElement, -} from '@shared/domain'; +import { AnyBoardDo, AnyContentElementDo, Card, ContentElementType, EntityId } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization/authorization.service'; import { Action } from '@src/modules/authorization/types/action.enum'; @@ -41,7 +33,7 @@ export class CardUc { cardId: EntityId, type: ContentElementType, toPosition?: number - ): Promise { + ): Promise { this.logger.debug({ action: 'createElement', userId, cardId, type }); const card = await this.cardService.findById(cardId); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index 708599d1f65..7289fdb6ce2 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -3,7 +3,7 @@ import { AnyBoardDo, EntityId, SubmissionContainerElement, SubmissionItem, UserR import { Logger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; import { Action } from '@src/modules/authorization/types/action.enum'; -import { FileContentBody, RichTextContentBody, SubmissionContainerContentBody } from '../controller/dto'; +import { AnyElementContentBody } from '../controller/dto'; import { BoardDoAuthorizableService, ContentElementService } from '../service'; import { SubmissionItemService } from '../service/submission-item.service'; @@ -20,11 +20,7 @@ export class ElementUc { this.logger.setContext(ElementUc.name); } - async updateElementContent( - userId: EntityId, - elementId: EntityId, - content: FileContentBody | RichTextContentBody | SubmissionContainerContentBody - ) { + async updateElementContent(userId: EntityId, elementId: EntityId, content: AnyElementContentBody) { const element = await this.elementService.findById(elementId); await this.checkPermission(userId, element, Action.write); diff --git a/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts b/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts index ce7a357e38e..53d0ecd4360 100644 --- a/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts +++ b/apps/server/src/modules/class/repo/mapper/class.mapper.spec.ts @@ -1,10 +1,10 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { ClassEntity } from '../../entity'; -import { ClassMapper } from './class.mapper'; import { Class } from '../../domain'; import { ClassSourceOptions } from '../../domain/class-source-options.do'; import { classFactory } from '../../domain/testing/factory/class.factory'; +import { ClassEntity } from '../../entity'; import { classEntityFactory } from '../../entity/testing/factory/class.entity.factory'; +import { ClassMapper } from './class.mapper'; describe(ClassMapper.name, () => { describe('mapToDOs', () => { @@ -58,28 +58,39 @@ describe(ClassMapper.name, () => { }); }); describe('When domainObjects array is mapped for entities array', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('should properly map the domainObjects to the entities', () => { const domainObjects = [classFactory.build()]; const entities = ClassMapper.mapToEntities(domainObjects); - const expectedEntities = domainObjects.map( - (domainObject) => - new ClassEntity({ - id: domainObject.id, - name: domainObject.name, - schoolId: new ObjectId(domainObject.schoolId), - teacherIds: domainObject.teacherIds.map((teacherId) => new ObjectId(teacherId)), - invitationLink: domainObject.invitationLink, - ldapDN: domainObject.ldapDN, - source: domainObject.source, - gradeLevel: domainObject.gradeLevel, - sourceOptions: domainObject.sourceOptions, - successor: new ObjectId(domainObject.successor), - userIds: domainObject.userIds?.map((userId) => new ObjectId(userId)), - year: new ObjectId(domainObject.year), - }) - ); + const expectedEntities = domainObjects.map((domainObject) => { + const entity = new ClassEntity({ + id: domainObject.id, + name: domainObject.name, + schoolId: new ObjectId(domainObject.schoolId), + teacherIds: domainObject.teacherIds.map((teacherId) => new ObjectId(teacherId)), + invitationLink: domainObject.invitationLink, + ldapDN: domainObject.ldapDN, + source: domainObject.source, + gradeLevel: domainObject.gradeLevel, + sourceOptions: domainObject.sourceOptions, + successor: new ObjectId(domainObject.successor), + userIds: domainObject.userIds?.map((userId) => new ObjectId(userId)), + year: new ObjectId(domainObject.year), + }); + + return entity; + }); + expect(entities).toEqual(expectedEntities); }); }); diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index 551aad0a010..77f0f80e4cc 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -19,6 +19,7 @@ export enum CopyElementType { 'CONTENT' = 'CONTENT', 'COURSE' = 'COURSE', 'COURSEGROUP_GROUP' = 'COURSEGROUP_GROUP', + 'EXTERNAL_TOOL_ELEMENT' = 'EXTERNAL_TOOL_ELEMENT', 'FILE' = 'FILE', 'FILE_ELEMENT' = 'FILE_ELEMENT', 'FILE_GROUP' = 'FILE_GROUP', diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/card.do.spec.ts index 5bf6a3983e3..7ee0fd5b9b5 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.spec.ts @@ -1,5 +1,10 @@ import { createMock } from '@golevelup/ts-jest'; -import { cardFactory, richTextElementFactory, submissionContainerElementFactory } from '@shared/testing'; +import { + cardFactory, + externalToolElementFactory, + richTextElementFactory, + submissionContainerElementFactory, +} from '@shared/testing'; import { Card } from './card.do'; import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; @@ -16,6 +21,12 @@ describe(Card.name, () => { const submissionContainerElement = submissionContainerElementFactory.build(); expect(card.isAllowedAsChild(submissionContainerElement)).toBe(true); }); + + it('should allow external tool element objects', () => { + const card = cardFactory.build(); + const externalToolElement = externalToolElementFactory.build(); + expect(card.isAllowedAsChild(externalToolElement)).toBe(true); + }); }); describe('accept', () => { diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index a80703cf14b..652d30ff027 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,4 +1,5 @@ import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; @@ -25,7 +26,8 @@ export class Card extends BoardComposite { const allowed = domainObject instanceof FileElement || domainObject instanceof RichTextElement || - domainObject instanceof SubmissionContainerElement; + domainObject instanceof SubmissionContainerElement || + domainObject instanceof ExternalToolElement; return allowed; } diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts index c6b3fd2096d..89d1c297399 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.spec.ts @@ -1,9 +1,10 @@ import { NotImplementedException } from '@nestjs/common'; import { ContentElementFactory } from './content-element.factory'; +import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; -import { ContentElementType } from './types/content-elements.enum'; +import { ContentElementType } from './types'; describe(ContentElementFactory.name, () => { describe('build', () => { @@ -37,6 +38,14 @@ describe(ContentElementFactory.name, () => { expect(element).toBeInstanceOf(SubmissionContainerElement); }); + it('should return element of EXTERNAL_TOOL', () => { + const { contentElementFactory } = setup(); + + const element = contentElementFactory.build(ContentElementType.EXTERNAL_TOOL); + + expect(element).toBeInstanceOf(ExternalToolElement); + }); + it('should throw NotImplementedException', () => { const { contentElementFactory } = setup(); diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 5b9f4aaecca..ea268206559 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -1,11 +1,11 @@ import { Injectable, NotImplementedException } from '@nestjs/common'; import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; +import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; -import { AnyContentElementDo } from './types/any-content-element-do'; -import { ContentElementType } from './types/content-elements.enum'; +import { AnyContentElementDo, ContentElementType } from './types'; @Injectable() export class ContentElementFactory { @@ -22,6 +22,9 @@ export class ContentElementFactory { case ContentElementType.SUBMISSION_CONTAINER: element = this.buildSubmissionContainer(); break; + case ContentElementType.EXTERNAL_TOOL: + element = this.buildExternalTool(); + break; default: break; } @@ -71,4 +74,15 @@ export class ContentElementFactory { return element; } + + private buildExternalTool() { + const element = new ExternalToolElement({ + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } } diff --git a/apps/server/src/shared/domain/domainobject/board/external-tool-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/external-tool-element.do.spec.ts new file mode 100644 index 00000000000..47fa813afba --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/external-tool-element.do.spec.ts @@ -0,0 +1,49 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { externalToolElementFactory } from '@shared/testing'; +import { ExternalToolElement } from './external-tool-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(ExternalToolElement.name, () => { + describe('when trying to add a child to a external tool element', () => { + it('should throw an error ', () => { + const externalToolElement = externalToolElementFactory.build(); + const externalToolElementChild = externalToolElementFactory.build(); + + expect(() => externalToolElement.addChild(externalToolElementChild)).toThrow(); + }); + }); + + describe('update contextExternalToolId', () => { + it('should be able to update contextExternalToolId', () => { + const externalToolElement = externalToolElementFactory.build(); + const contextExternalToolId = new ObjectId().toHexString(); + + externalToolElement.contextExternalToolId = contextExternalToolId; + + expect(externalToolElement.contextExternalToolId).toEqual(contextExternalToolId); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const externalToolElement = externalToolElementFactory.build(); + + externalToolElement.accept(visitor); + + expect(visitor.visitExternalToolElement).toHaveBeenCalledWith(externalToolElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const externalToolElement = externalToolElementFactory.build(); + + await externalToolElement.acceptAsync(visitor); + + expect(visitor.visitExternalToolElementAsync).toHaveBeenCalledWith(externalToolElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/external-tool-element.do.ts b/apps/server/src/shared/domain/domainobject/board/external-tool-element.do.ts new file mode 100644 index 00000000000..84f053a1268 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/external-tool-element.do.ts @@ -0,0 +1,32 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class ExternalToolElement extends BoardComposite { + get contextExternalToolId(): string | undefined { + return this.props.contextExternalToolId; + } + + set contextExternalToolId(value: string | undefined) { + this.props.contextExternalToolId = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitExternalToolElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitExternalToolElementAsync(this); + } +} + +export interface ExternalToolElementProps extends BoardCompositeProps { + contextExternalToolId?: string; +} + +export function isExternalToolElement(reference: unknown): reference is ExternalToolElement { + return reference instanceof ExternalToolElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 588bc4a42b0..86f4d2639c3 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -8,4 +8,5 @@ export * from './rich-text-element.do'; export * from './submission-container-element.do'; export * from './submission-item.do'; export * from './submission-item.factory'; +export * from './external-tool-element.do'; export * from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts index d0fc5ac15a7..d6ccfbd56ca 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts @@ -1,15 +1,17 @@ +import { ExternalToolElement } from '../external-tool-element.do'; import { FileElement } from '../file-element.do'; import { RichTextElement } from '../rich-text-element.do'; import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; -export type AnyContentElementDo = FileElement | RichTextElement | SubmissionContainerElement; +export type AnyContentElementDo = FileElement | RichTextElement | SubmissionContainerElement | ExternalToolElement; export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = element instanceof FileElement || element instanceof RichTextElement || - element instanceof SubmissionContainerElement; + element instanceof SubmissionContainerElement || + element instanceof ExternalToolElement; return result; }; diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index b81eb5f957e..38e16fc8e5f 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,6 +1,7 @@ import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; +import { ExternalToolElement } from '../external-tool-element.do'; import type { FileElement } from '../file-element.do'; import { RichTextElement } from '../rich-text-element.do'; import { SubmissionContainerElement } from '../submission-container-element.do'; @@ -14,6 +15,7 @@ export interface BoardCompositeVisitor { visitRichTextElement(richTextElement: RichTextElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; + visitExternalToolElement(externalToolElement: ExternalToolElement): void; } export interface BoardCompositeVisitorAsync { @@ -24,4 +26,5 @@ export interface BoardCompositeVisitorAsync { visitRichTextElementAsync(richTextElement: RichTextElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; + visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise; } diff --git a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts index 0b9600dae93..4c6ce7269bd 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts @@ -1,5 +1,6 @@ export enum ContentElementType { - 'FILE' = 'file', - 'RICH_TEXT' = 'richText', - 'SUBMISSION_CONTAINER' = 'submissionContainer', + FILE = 'file', + RICH_TEXT = 'richText', + SUBMISSION_CONTAINER = 'submissionContainer', + EXTERNAL_TOOL = 'externalTool', } diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index b2ff674b8b4..406fda13bf4 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -1,21 +1,22 @@ +import { ClassEntity } from '@src/modules/class/entity'; import { GroupEntity } from '@src/modules/group/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@src/modules/pseudonym/entity'; import { ShareToken } from '@src/modules/sharing/entity/share-token.entity'; import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@src/modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; -import { ClassEntity } from '@src/modules/class/entity'; import { Account } from './account.entity'; import { + BoardNode, CardNode, ColumnBoardNode, ColumnNode, + ExternalToolElementNodeEntity, FileElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, } from './boardnode'; -import { BoardNode } from './boardnode/boardnode.entity'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; import { DashboardGridElementModel, DashboardModelEntity } from './dashboard.model.entity'; @@ -60,6 +61,7 @@ export const ALL_ENTITIES = [ RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, + ExternalToolElementNodeEntity, Course, ContextExternalToolEntity, CourseGroup, diff --git a/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts new file mode 100644 index 00000000000..83a51d42e6e --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts @@ -0,0 +1,60 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ExternalToolElement } from '@shared/domain/domainobject'; +import { contextExternalToolEntityFactory, externalToolElementFactory, setupEntities } from '@shared/testing'; +import { ExternalToolElementNodeEntity, ExternalToolElementNodeEntityProps } from './external-tool-element-node.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(ExternalToolElementNodeEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('when trying to create a external tool element', () => { + const setup = () => { + const elementProps: ExternalToolElementNodeEntityProps = { + contextExternalTool: contextExternalToolEntityFactory.buildWithId(), + }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a ExternalToolElementNode', () => { + const { elementProps } = setup(); + + const element = new ExternalToolElementNodeEntity(elementProps); + + expect(element.type).toEqual(BoardNodeType.EXTERNAL_TOOL); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new ExternalToolElementNodeEntity({ + contextExternalTool: contextExternalToolEntityFactory.buildWithId(), + }); + const builder: DeepMocked = createMock(); + const elementDo: ExternalToolElement = externalToolElementFactory.build(); + + builder.buildExternalToolElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildExternalToolElement).toHaveBeenCalledWith(element); + }); + + it('should return ExternalToolElement', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.ts new file mode 100644 index 00000000000..68df0b696b5 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.ts @@ -0,0 +1,26 @@ +import { Entity, ManyToOne } from '@mikro-orm/core'; +import { AnyBoardDo } from '@shared/domain/domainobject'; +import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity/context-external-tool.entity'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.EXTERNAL_TOOL }) +export class ExternalToolElementNodeEntity extends BoardNode { + @ManyToOne({ nullable: true }) + contextExternalTool?: ContextExternalToolEntity; + + constructor(props: ExternalToolElementNodeEntityProps) { + super(props); + this.type = BoardNodeType.EXTERNAL_TOOL; + this.contextExternalTool = props.contextExternalTool; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildExternalToolElement(this); + return domainObject; + } +} + +export interface ExternalToolElementNodeEntityProps extends BoardNodeProps { + contextExternalTool?: ContextExternalToolEntity; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/file-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/file-element-node.entity.spec.ts index 36f8b1e57a6..050cf255f73 100644 --- a/apps/server/src/shared/domain/entity/boardnode/file-element-node.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/boardnode/file-element-node.entity.spec.ts @@ -40,14 +40,6 @@ describe(FileElementNode.name, () => { expect(builder.buildFileElement).toHaveBeenCalledWith(element); }); - it('should call the specific builder method', () => { - const { element, builder } = setup(); - - element.useDoBuilder(builder); - - expect(builder.buildFileElement).toHaveBeenCalledWith(element); - }); - it('should return FileElementDo', () => { const { element, builder, elementDo } = setup(); diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index de0d79bdf7e..cd7cc9d65be 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -6,4 +6,5 @@ export * from './file-element-node.entity'; export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; +export * from './external-tool-element-node.entity'; export * from './types'; diff --git a/apps/server/src/shared/domain/entity/boardnode/rich-text-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/rich-text-element-node.entity.spec.ts index 60abe07cbd8..fdb7a691ca0 100644 --- a/apps/server/src/shared/domain/entity/boardnode/rich-text-element-node.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/boardnode/rich-text-element-node.entity.spec.ts @@ -41,14 +41,6 @@ describe(RichTextElementNode.name, () => { expect(builder.buildRichTextElement).toHaveBeenCalledWith(element); }); - it('should call the specific builder method', () => { - const { element, builder } = setup(); - - element.useDoBuilder(builder); - - expect(builder.buildRichTextElement).toHaveBeenCalledWith(element); - }); - it('should return RichTextElementDo', () => { const { element, builder, elementDo } = setup(); diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index 8d240bbb078..a5c2a8b2e16 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -3,6 +3,7 @@ import type { Card, Column, ColumnBoard, + ExternalToolElement, FileElement, RichTextElement, SubmissionContainerElement, @@ -10,6 +11,7 @@ import type { import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-node.entity'; +import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; import type { RichTextElementNode } from '../rich-text-element-node.entity'; import type { SubmissionContainerElementNode } from '../submission-container-element-node.entity'; @@ -23,4 +25,5 @@ export interface BoardDoBuilder { buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement; buildSubmissionItem(boardNode: SubmissionItemNode): SubmissionItem; + buildExternalToolElement(boardNode: ExternalToolElementNodeEntity): ExternalToolElement; } diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index 0573cb394e5..a1b44207907 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -6,4 +6,5 @@ export enum BoardNodeType { RICH_TEXT_ELEMENT = 'rich-text-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', + EXTERNAL_TOOL = 'external-tool', } diff --git a/apps/server/src/shared/testing/factory/boardnode/external-tool-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/external-tool-element-node.factory.ts new file mode 100644 index 00000000000..b09c35d4733 --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/external-tool-element-node.factory.ts @@ -0,0 +1,9 @@ +import { ExternalToolElementNodeEntity, ExternalToolElementNodeEntityProps } from '../../../domain'; +import { BaseFactory } from '../base.factory'; + +export const externalToolElementNodeFactory = BaseFactory.define< + ExternalToolElementNodeEntity, + ExternalToolElementNodeEntityProps +>(ExternalToolElementNodeEntity, () => { + return {}; +}); diff --git a/apps/server/src/shared/testing/factory/boardnode/index.ts b/apps/server/src/shared/testing/factory/boardnode/index.ts index 5df59ac37be..410a399ccff 100644 --- a/apps/server/src/shared/testing/factory/boardnode/index.ts +++ b/apps/server/src/shared/testing/factory/boardnode/index.ts @@ -5,3 +5,4 @@ export * from './file-element-node.factory'; export * from './rich-text-element-node.factory'; export * from './submission-container-element-node.factory'; export * from './submission-item-node.factory'; +export * from './external-tool-element-node.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/board/external-tool.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/external-tool.do.factory.ts new file mode 100644 index 00000000000..314650758f8 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/external-tool.do.factory.ts @@ -0,0 +1,15 @@ +import { ExternalToolElement, ExternalToolElementProps } from '@shared/domain'; +import { ObjectId } from 'bson'; +import { BaseFactory } from '../../base.factory'; + +export const externalToolElementFactory = BaseFactory.define( + ExternalToolElement, + () => { + return { + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index 8f9371ffa95..e7b3bae56ed 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -5,3 +5,4 @@ export * from './file-element.do.factory'; export * from './rich-text-element.do.factory'; export * from './submission-container-element.do.factory'; export * from './submission-item.do.factory'; +export * from './external-tool.do.factory'; diff --git a/config/default.schema.json b/config/default.schema.json index 278caf57a74..d986b4ffdf9 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1272,6 +1272,11 @@ "default": false, "description": "Enables the configuration of custom parameters for context external tools" }, + "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables external tools on the column board" + }, "CTL_TOOLS": { "type": "object", "description": "CTL Tools properties", diff --git a/config/development.json b/config/development.json index 80d9fb3c3e9..a2b8ba524a9 100644 --- a/config/development.json +++ b/config/development.json @@ -67,5 +67,6 @@ "SESSION_SECRET": "dev-session-secret", "FEATURE_COURSE_SHARE": true, "FEATURE_COLUMN_BOARD_ENABLED": true, - "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true + "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, + "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true } diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index afaf46ca911..b6452220088 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -39,6 +39,7 @@ const exposedVars = [ 'SC_TITLE', 'FEATURE_COLUMN_BOARD_ENABLED', 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', + 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED', 'FEATURE_COURSE_SHARE', 'FEATURE_COURSE_SHARE_NEW', 'FEATURE_LOGIN_LINK_ENABLED', From 13d317f60b446b74d3ba88eaaed1452424dbdaf8 Mon Sep 17 00:00:00 2001 From: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> Date: Fri, 22 Sep 2023 17:23:59 +0200 Subject: [PATCH 04/34] BC-4970 legacy user files retrieval (#4429) * add files service method to find files accessible by user with given userId * modify some tests descriptions * add files service method to find files owned by user with given userId --- .../files/service/files.service.spec.ts | 92 +++++++++++++++++++ .../modules/files/service/files.service.ts | 9 ++ 2 files changed, 101 insertions(+) diff --git a/apps/server/src/modules/files/service/files.service.spec.ts b/apps/server/src/modules/files/service/files.service.spec.ts index 5ee07897565..98ddc788d34 100644 --- a/apps/server/src/modules/files/service/files.service.spec.ts +++ b/apps/server/src/modules/files/service/files.service.spec.ts @@ -39,6 +39,51 @@ describe(FilesService.name, () => { await module.close(); }); + describe('findFilesAccessibleByUser', () => { + describe('when called with a userId of a user that', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const accessibleFiles: FileEntity[] = []; + + for (let i = 0; i < 5; i += 1) { + accessibleFiles.push( + fileEntityFactory.build({ + permissions: [filePermissionEntityFactory.build({ refId: userId })], + }) + ); + } + + return { userId, accessibleFiles }; + }; + + describe("doesn't have an access to any files", () => { + it('should return an empty array', async () => { + const { userId } = setup(); + + repo.findByPermissionRefId.mockResolvedValueOnce([]); + + const result = await service.findFilesAccessibleByUser(userId); + + expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(result).toEqual([]); + }); + }); + + describe('does have an access to some files', () => { + it('should return an array containing proper file entities', async () => { + const { userId, accessibleFiles } = setup(); + + repo.findByPermissionRefId.mockResolvedValueOnce(accessibleFiles); + + const result = await service.findFilesAccessibleByUser(userId); + + expect(repo.findByPermissionRefId).toBeCalledWith(userId); + expect(result).toEqual(accessibleFiles); + }); + }); + }); + }); + describe('removeUserPermissionsToAnyFiles', () => { it('should not modify any files if there are none that user has permission to access', async () => { const userId = new ObjectId().toHexString(); @@ -110,6 +155,53 @@ describe(FilesService.name, () => { }); }); + describe('findFilesOwnedByUser', () => { + describe('when called with a userId of a user that', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const ownedFiles: FileEntity[] = []; + + for (let i = 0; i < 5; i += 1) { + ownedFiles.push( + fileEntityFactory.build({ + ownerId: userId, + creatorId: userId, + permissions: [filePermissionEntityFactory.build({ refId: userId })], + }) + ); + } + + return { userId, ownedFiles }; + }; + + describe("doesn't own any files", () => { + it('should return an empty array', async () => { + const { userId } = setup(); + + repo.findByOwnerUserId.mockResolvedValueOnce([]); + + const result = await service.findFilesOwnedByUser(userId); + + expect(repo.findByOwnerUserId).toBeCalledWith(userId); + expect(result).toEqual([]); + }); + }); + + describe('does own some files', () => { + it('should return an array containing proper file entities', async () => { + const { userId, ownedFiles } = setup(); + + repo.findByOwnerUserId.mockResolvedValueOnce(ownedFiles); + + const result = await service.findFilesOwnedByUser(userId); + + expect(repo.findByOwnerUserId).toBeCalledWith(userId); + expect(result).toEqual(ownedFiles); + }); + }); + }); + }); + describe('markFilesOwnedByUserForDeletion', () => { const verifyEntityChanges = (entity: FileEntity) => { expect(entity.deleted).toEqual(true); diff --git a/apps/server/src/modules/files/service/files.service.ts b/apps/server/src/modules/files/service/files.service.ts index b3d3bd352cc..b2faef58fd1 100644 --- a/apps/server/src/modules/files/service/files.service.ts +++ b/apps/server/src/modules/files/service/files.service.ts @@ -1,11 +1,16 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { FilesRepo } from '../repo'; +import { FileEntity } from '../entity'; @Injectable() export class FilesService { constructor(private readonly repo: FilesRepo) {} + async findFilesAccessibleByUser(userId: EntityId): Promise { + return this.repo.findByPermissionRefId(userId); + } + async removeUserPermissionsToAnyFiles(userId: EntityId): Promise { const entities = await this.repo.findByPermissionRefId(userId); @@ -20,6 +25,10 @@ export class FilesService { return entities.length; } + async findFilesOwnedByUser(userId: EntityId): Promise { + return this.repo.findByOwnerUserId(userId); + } + async markFilesOwnedByUserForDeletion(userId: EntityId): Promise { const entities = await this.repo.findByOwnerUserId(userId); From 69b15285a0e53c72064ef3783450b6c80c1a13ea Mon Sep 17 00:00:00 2001 From: Max Bischof <106820326+bischofmax@users.noreply.github.com> Date: Mon, 25 Sep 2023 10:14:12 +0200 Subject: [PATCH 05/34] BC-3800 - Add caption of file (#4433) --- .../board/file-element.do.spec.ts | 56 +++++++++++++++++++ .../domainobject/board/file-element.do.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/server/src/shared/domain/domainobject/board/file-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/file-element.do.spec.ts index a659be88ee1..6de914f6751 100644 --- a/apps/server/src/shared/domain/domainobject/board/file-element.do.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/file-element.do.spec.ts @@ -4,6 +4,62 @@ import { FileElement } from './file-element.do'; import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; describe(FileElement.name, () => { + describe('get caption', () => { + describe('when caption is set', () => { + it('should return the caption', () => { + const fileElement = fileElementFactory.build(); + + expect(fileElement.caption).toEqual(fileElement.caption); + }); + }); + + describe('when caption is not set', () => { + it('should return an empty string', () => { + const fileElement = fileElementFactory.build({ caption: undefined }); + + expect(fileElement.caption).toEqual(''); + }); + }); + }); + + describe('set caption', () => { + it('should set caption', () => { + const fileElement = fileElementFactory.build(); + const text = 'new caption'; + fileElement.caption = text; + + expect(fileElement.caption).toEqual(text); + }); + }); + + describe('get alternative text', () => { + describe('when alternative text is set', () => { + it('should return the alternative text', () => { + const fileElement = fileElementFactory.build(); + + expect(fileElement.alternativeText).toEqual(fileElement.alternativeText); + }); + }); + + describe('when alternative text is not set', () => { + it('should return an empty string', () => { + const fileElement = fileElementFactory.build({ alternativeText: undefined }); + + expect(fileElement.alternativeText).toEqual(''); + }); + }); + }); + + describe('set alternative text', () => { + it('should set alternative text', () => { + const fileElement = fileElementFactory.build(); + const text = 'new alternative text'; + fileElement.alternativeText = text; + + expect(fileElement.alternativeText).toEqual(text); + }); + }); + describe('when trying to add a child to a file element', () => { it('should throw an error ', () => { const fileElement = fileElementFactory.build(); diff --git a/apps/server/src/shared/domain/domainobject/board/file-element.do.ts b/apps/server/src/shared/domain/domainobject/board/file-element.do.ts index ab209fbe346..4d58aa300ea 100644 --- a/apps/server/src/shared/domain/domainobject/board/file-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/file-element.do.ts @@ -3,7 +3,7 @@ import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types' export class FileElement extends BoardComposite { get caption(): string { - return this.props.caption; + return this.props.caption || ''; } set caption(value: string) { From b1c09d49f2bb0b4d3d2e656f9c0030e50a0f072f Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:43:22 +0200 Subject: [PATCH 06/34] BC-4375 - fix api generator (#4435) --- .../controller/api-test/submission-item-lookup.api.spec.ts | 2 +- .../src/modules/board/controller/dto/submission-item/index.ts | 4 +++- .../controller/mapper/submission-item-response.mapper.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts index 4686f953d29..6f31d5a4e5a 100644 --- a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts @@ -15,7 +15,7 @@ import { userFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server'; -import { SubmissionsResponse } from '../dto'; +import { SubmissionsResponse } from '../dto/submission-item/submissions.response'; const baseRouteName = '/board-submissions'; describe('submission item lookup (api)', () => { diff --git a/apps/server/src/modules/board/controller/dto/submission-item/index.ts b/apps/server/src/modules/board/controller/dto/submission-item/index.ts index 294c722dbef..b009f3e0560 100644 --- a/apps/server/src/modules/board/controller/dto/submission-item/index.ts +++ b/apps/server/src/modules/board/controller/dto/submission-item/index.ts @@ -2,5 +2,7 @@ export * from './submission-container.url.params'; export * from './create-submission-item.body.params'; export * from './submission-item.response'; export * from './submission-item.url.params'; -export * from './submissions.response'; +// TODO for some reason, api generator messes up the types +// import it directly, not via this index seems to fix it +// export * from './submissions.response'; export * from './update-submission-item.body.params'; diff --git a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts index 53efb37a482..82d2292ba11 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts @@ -1,5 +1,6 @@ import { SubmissionItem, UserBoardRoles } from '@shared/domain'; -import { SubmissionItemResponse, SubmissionsResponse, TimestampsResponse, UserDataResponse } from '../dto'; +import { SubmissionsResponse } from '../dto/submission-item/submissions.response'; +import { SubmissionItemResponse, TimestampsResponse, UserDataResponse } from '../dto'; export class SubmissionItemResponseMapper { private static instance: SubmissionItemResponseMapper; From 8501d4f24c7b90140197fd847df4520f3f2cb94b Mon Sep 17 00:00:00 2001 From: Alexander Weber <103171324+alweber-cap@users.noreply.github.com> Date: Mon, 25 Sep 2023 13:32:44 +0200 Subject: [PATCH 07/34] Ew 604 Handle Users without accounts and proper naming (#4431) * add account check and lowercase username * add test * fix lint * fix account get flow --- .../user-import/uc/user-import.uc.spec.ts | 17 +++++++++------- .../modules/user-import/uc/user-import.uc.ts | 20 +++++++++++++++++-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index 3617b5c4e3d..8dcccaf84e2 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -483,6 +483,7 @@ describe('[ImportUserModule]', () => { userMatch1 = userFactory.buildWithId({ school }); userMatch2 = userFactory.buildWithId({ school }); + importUser1 = importUserFactory.buildWithId({ school, user: userMatch1, @@ -505,13 +506,15 @@ describe('[ImportUserModule]', () => { userRepoFlushSpy = userRepo.flush.mockResolvedValueOnce(); permissionServiceSpy = authorizationService.checkAllPermissions.mockReturnValue(); importUserRepoFindImportUsersSpy = importUserRepo.findImportUsers.mockResolvedValue([[], 0]); - accountServiceFindByUserIdSpy = accountService.findByUserIdOrFail.mockResolvedValue({ - id: 'dummyId', - userId: currentUser.id, - username: currentUser.email, - createdAt: new Date(), - updatedAt: new Date(), - }); + accountServiceFindByUserIdSpy = accountService.findByUserId + .mockResolvedValue({ + id: 'dummyId', + userId: currentUser.id, + username: currentUser.email, + createdAt: new Date(), + updatedAt: new Date(), + }) + .mockResolvedValueOnce(null); importUserRepoDeleteImportUsersBySchoolSpy = importUserRepo.deleteImportUsersBySchool.mockResolvedValue(); importUserRepoDeleteImportUserSpy = importUserRepo.delete.mockResolvedValue(); schoolServiceSaveSpy = schoolService.save.mockReturnValueOnce(Promise.resolve(createMockSchoolDo(school))); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index 3812b945cc6..ecef86207a0 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -23,6 +23,7 @@ import { AccountService } from '@src/modules/account/services/account.service'; import { AccountDto } from '@src/modules/account/services/dto/account.dto'; import { AuthorizationService } from '@src/modules/authorization'; import { LegacySchoolService } from '@src/modules/legacy-school'; +import { AccountSaveDto } from '../../account/services/dto'; import { MigrationMayBeCompleted, MigrationMayNotBeCompleted, @@ -281,17 +282,32 @@ export class UserImportUc { user.ldapDn = importUser.ldapDn; user.externalId = importUser.externalId; - const account: AccountDto = await this.accountService.findByUserIdOrFail(user.id); + const account: AccountDto = await this.getAccount(user); account.systemId = importUser.system.id; account.password = undefined; - account.username = `${school.externalId}/${importUser.loginName}`; + account.username = `${school.externalId}/${importUser.loginName}`.toLowerCase(); await this.userRepo.save(user); await this.accountService.save(account); await this.importUserRepo.delete(importUser); } + private async getAccount(user: User): Promise { + let account: AccountDto | null = await this.accountService.findByUserId(user.id); + + if (!account) { + const newAccount: AccountSaveDto = new AccountSaveDto({ + userId: user.id, + username: user.email, + }); + + await this.accountService.saveWithValidation(newAccount); + account = await this.accountService.findByUserIdOrFail(user.id); + } + return account; + } + private async getMigrationSystem(): Promise { const systemId = Configuration.get('FEATURE_USER_MIGRATION_SYSTEM_ID') as string; const system = await this.systemRepo.findById(systemId); From ee43c77b8e36315b72f6399d9f82a6f7bdb1fd7d Mon Sep 17 00:00:00 2001 From: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Date: Mon, 25 Sep 2023 14:31:40 +0200 Subject: [PATCH 08/34] BC-4973-rewrite-data-retrival-in-course (#4422) * changes in lesson module * add method for services in lesson, course and coursegroup * litle addition in testcase * changes in tests * change method names in service after review * small changes in test --- .../learnroom/service/course.service.spec.ts | 37 +++++++++++++++++ .../learnroom/service/course.service.ts | 8 +++- .../service/coursegroup.service.spec.ts | 34 +++++++++++++++ .../learnroom/service/coursegroup.service.ts | 8 +++- .../lesson/service/lesson.service.spec.ts | 41 +++++++++++++++++++ .../modules/lesson/service/lesson.service.ts | 6 +++ 6 files changed, 132 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/learnroom/service/course.service.spec.ts b/apps/server/src/modules/learnroom/service/course.service.spec.ts index 67c581d6818..fbf2f09b698 100644 --- a/apps/server/src/modules/learnroom/service/course.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course.service.spec.ts @@ -56,6 +56,43 @@ describe('CourseService', () => { }); }); + describe('findAllCoursesByUserId', () => { + describe('when finding by userId', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course1 = courseFactory.buildWithId({ students: [user] }); + const course2 = courseFactory.buildWithId({ teachers: [user] }); + const course3 = courseFactory.buildWithId({ substitutionTeachers: [user] }); + const allCourses = [course1, course2, course3]; + + userRepo.findById.mockResolvedValue(user); + courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); + + return { + user, + allCourses, + }; + }; + + it('should call courseRepo.findAllByUserId', async () => { + const { user } = setup(); + + await courseService.findAllCoursesByUserId(user.id); + + expect(courseRepo.findAllByUserId).toBeCalledWith(user.id); + }); + + it('should return array of courses with userId', async () => { + const { user, allCourses } = setup(); + + const [courses] = await courseService.findAllCoursesByUserId(user.id); + + expect(courses.length).toEqual(3); + expect(courses).toEqual(allCourses); + }); + }); + }); + describe('when deleting by userId', () => { const setup = () => { const user = userFactory.buildWithId(); diff --git a/apps/server/src/modules/learnroom/service/course.service.ts b/apps/server/src/modules/learnroom/service/course.service.ts index c2e7364ffe9..76b441b42d8 100644 --- a/apps/server/src/modules/learnroom/service/course.service.ts +++ b/apps/server/src/modules/learnroom/service/course.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CourseRepo } from '@shared/repo'; -import { Course, EntityId } from '@shared/domain'; +import { Counted, Course, EntityId } from '@shared/domain'; @Injectable() export class CourseService { @@ -10,6 +10,12 @@ export class CourseService { return this.repo.findById(courseId); } + public async findAllCoursesByUserId(userId: EntityId): Promise> { + const [courses, count] = await this.repo.findAllByUserId(userId); + + return [courses, count]; + } + public async deleteUserDataFromCourse(userId: EntityId): Promise { const [courses, count] = await this.repo.findAllByUserId(userId); diff --git a/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts b/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts index 5a445792a23..48caf02c378 100644 --- a/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts @@ -38,6 +38,40 @@ describe('CourseGroupService', () => { jest.clearAllMocks(); }); + describe('findAllCourseGroupsByUserId', () => { + describe('when finding by userId', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const courseGroups = courseGroupFactory.buildListWithId(2, { students: [user] }); + + userRepo.findById.mockResolvedValue(user); + courseGroupRepo.findByUserId.mockResolvedValue([courseGroups, courseGroups.length]); + + return { + user, + courseGroups, + }; + }; + + it('should call courseGroupRepo.findByUserId', async () => { + const { user } = setup(); + + await courseGroupService.findAllCourseGroupsByUserId(user.id); + + expect(courseGroupRepo.findByUserId).toBeCalledWith(user.id); + }); + + it('should return array with coursesGroup with userId', async () => { + const { user, courseGroups } = setup(); + + const [courseGroup] = await courseGroupService.findAllCourseGroupsByUserId(user.id); + + expect(courseGroup.length).toEqual(2); + expect(courseGroup).toEqual(courseGroups); + }); + }); + }); + describe('when deleting by userId', () => { const setup = () => { const user = userFactory.buildWithId(); diff --git a/apps/server/src/modules/learnroom/service/coursegroup.service.ts b/apps/server/src/modules/learnroom/service/coursegroup.service.ts index acd8f5b9b42..622cdbc5e9e 100644 --- a/apps/server/src/modules/learnroom/service/coursegroup.service.ts +++ b/apps/server/src/modules/learnroom/service/coursegroup.service.ts @@ -1,11 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain'; +import { Counted, CourseGroup, EntityId } from '@shared/domain'; import { CourseGroupRepo } from '@shared/repo'; @Injectable() export class CourseGroupService { constructor(private readonly repo: CourseGroupRepo) {} + public async findAllCourseGroupsByUserId(userId: EntityId): Promise> { + const [courseGroups, count] = await this.repo.findByUserId(userId); + + return [courseGroups, count]; + } + public async deleteUserDataFromCourseGroup(userId: EntityId): Promise { const [courseGroups, count] = await this.repo.findByUserId(userId); diff --git a/apps/server/src/modules/lesson/service/lesson.service.spec.ts b/apps/server/src/modules/lesson/service/lesson.service.spec.ts index bd5431b326d..7f0179640b5 100644 --- a/apps/server/src/modules/lesson/service/lesson.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson.service.spec.ts @@ -77,6 +77,47 @@ describe('LessonService', () => { }); }); + describe('findAllLessonsByUserId', () => { + describe('when finding by userId', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const contentExample: IComponentProperties = { + title: 'title', + hidden: false, + user: userId, + component: ComponentType.TEXT, + content: { text: 'test of content' }, + }; + const lesson1 = lessonFactory.buildWithId({ contents: [contentExample] }); + const lesson2 = lessonFactory.buildWithId({ contents: [contentExample] }); + const lessons = [lesson1, lesson2]; + + lessonRepo.findByUserId.mockResolvedValue(lessons); + + return { + userId, + lessons, + }; + }; + + it('should call findByCourseIds from lesson repo', async () => { + const { userId } = setup(); + + await expect(lessonService.findAllLessonsByUserId(userId)).resolves.not.toThrow(); + expect(lessonRepo.findByUserId).toBeCalledWith(userId); + }); + + it('should return array of lessons with userId', async () => { + const { userId, lessons } = setup(); + + const result = await lessonService.findAllLessonsByUserId(userId); + + expect(result).toHaveLength(2); + expect(result).toEqual(lessons); + }); + }); + }); + describe('deleteUserDataFromTeams', () => { describe('when deleting by userId', () => { const setup = () => { diff --git a/apps/server/src/modules/lesson/service/lesson.service.ts b/apps/server/src/modules/lesson/service/lesson.service.ts index 5f8acb03e38..5a69a19f305 100644 --- a/apps/server/src/modules/lesson/service/lesson.service.ts +++ b/apps/server/src/modules/lesson/service/lesson.service.ts @@ -24,6 +24,12 @@ export class LessonService { return this.lessonRepo.findAllByCourseIds(courseIds); } + async findAllLessonsByUserId(userId: EntityId): Promise { + const lessons = await this.lessonRepo.findByUserId(userId); + + return lessons; + } + async deleteUserDataFromLessons(userId: EntityId): Promise { const lessons = await this.lessonRepo.findByUserId(userId); From 4e3b8b9e97fcee4675c4a20d0f1fb8b270e57b20 Mon Sep 17 00:00:00 2001 From: Martin Schuhmacher <55735359+MartinSchuhmacher@users.noreply.github.com> Date: Mon, 25 Sep 2023 17:09:58 +0200 Subject: [PATCH 09/34] BC-4374 - make submission due date optional (#4378) For the response FE needs the dueDate to be set so that vue can properly watch it. Therefor, the response will set it to null if undefined. Co-authored-by: virgilchiriac --- .../content-element-create.api.spec.ts | 5 +- .../content-element-update-content.spec.ts | 139 +++++++++++++++--- .../controller/dto/card/card.response.ts | 8 +- .../submission-container-element.response.ts | 8 +- .../update-element-content.body.params.ts | 8 +- ...ssion-container-element-response.mapper.ts | 8 +- .../board/repo/board-do.builder-impl.ts | 6 +- .../board/repo/recursive-save.visitor.ts | 5 +- .../service/content-element-update.visitor.ts | 2 +- .../board/content-element.factory.ts | 2 - .../board/submission-container-element.do.ts | 6 +- ...ubmission-container-element-node.entity.ts | 6 +- ...bmission-container-element-node.factory.ts | 5 +- 13 files changed, 165 insertions(+), 43 deletions(-) diff --git a/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts index f6bcd16fd24..0f7a3795580 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts @@ -12,7 +12,7 @@ import { UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server/server.module'; -import { AnyContentElementResponse } from '../dto'; +import { AnyContentElementResponse, SubmissionContainerElementResponse } from '../dto'; const baseRouteName = '/cards'; @@ -91,7 +91,7 @@ describe(`content element create (api)`, () => { expect((response.body as AnyContentElementResponse).type).toEqual(ContentElementType.EXTERNAL_TOOL); }); - it('should return the created content element of type SUBMISSION_CONTAINER', async () => { + it('should return the created content element of type SUBMISSION_CONTAINER with dueDate set to null', async () => { const { loggedInClient, cardNode } = await setup(); const response = await loggedInClient.post(`${cardNode.id}/elements`, { @@ -99,6 +99,7 @@ describe(`content element create (api)`, () => { }); expect((response.body as AnyContentElementResponse).type).toEqual(ContentElementType.SUBMISSION_CONTAINER); + expect((response.body as SubmissionContainerElementResponse).content.dueDate).toBeNull(); }); it('should actually create the content element', async () => { diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index 09e3f8c046b..bee1ad63f0f 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -8,10 +8,9 @@ import { FileElementNode, InputFormat, RichTextElementNode, + SubmissionContainerElementNode, } from '@shared/domain'; import { - TestApiClient, - UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, @@ -19,6 +18,9 @@ import { courseFactory, fileElementNodeFactory, richTextElementNodeFactory, + submissionContainerElementNodeFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server/server.module'; @@ -59,8 +61,15 @@ describe(`content element update content (api)`, () => { const column = columnNodeFactory.buildWithId({ parent: columnBoardNode }); const parentCard = cardNodeFactory.buildWithId({ parent: column }); - const richTextelement = richTextElementNodeFactory.buildWithId({ parent: parentCard }); + const richTextElement = richTextElementNodeFactory.buildWithId({ parent: parentCard }); const fileElement = fileElementNodeFactory.buildWithId({ parent: parentCard }); + const submissionContainerElement = submissionContainerElementNodeFactory.buildWithId({ parent: parentCard }); + + const tomorrow = new Date(Date.now() + 86400000); + const submissionContainerElementWithDueDate = submissionContainerElementNodeFactory.buildWithId({ + parent: parentCard, + dueDate: tomorrow, + }); await em.persistAndFlush([ teacherAccount, @@ -68,20 +77,28 @@ describe(`content element update content (api)`, () => { parentCard, column, columnBoardNode, - richTextelement, + richTextElement, fileElement, + submissionContainerElement, + submissionContainerElementWithDueDate, ]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); - return { loggedInClient, richTextelement, fileElement }; + return { + loggedInClient, + richTextElement, + fileElement, + submissionContainerElement, + submissionContainerElementWithDueDate, + }; }; it('should return status 204', async () => { - const { loggedInClient, richTextelement } = await setup(); + const { loggedInClient, richTextElement } = await setup(); - const response = await loggedInClient.patch(`${richTextelement.id}/content`, { + const response = await loggedInClient.patch(`${richTextElement.id}/content`, { data: { content: { text: 'hello world', inputFormat: InputFormat.RICH_TEXT_CK5 }, type: ContentElementType.RICH_TEXT, @@ -92,30 +109,30 @@ describe(`content element update content (api)`, () => { }); it('should actually change content of the element', async () => { - const { loggedInClient, richTextelement } = await setup(); + const { loggedInClient, richTextElement } = await setup(); - await loggedInClient.patch(`${richTextelement.id}/content`, { + await loggedInClient.patch(`${richTextElement.id}/content`, { data: { content: { text: 'hello world', inputFormat: InputFormat.RICH_TEXT_CK5 }, type: ContentElementType.RICH_TEXT, }, }); - const result = await em.findOneOrFail(RichTextElementNode, richTextelement.id); + const result = await em.findOneOrFail(RichTextElementNode, richTextElement.id); expect(result.text).toEqual('hello world'); }); it('should sanitize rich text before changing content of the element', async () => { - const { loggedInClient, richTextelement } = await setup(); + const { loggedInClient, richTextElement } = await setup(); const text = ' some more text'; const sanitizedText = sanitizeRichText(text, InputFormat.RICH_TEXT_CK5); - await loggedInClient.patch(`${richTextelement.id}/content`, { + await loggedInClient.patch(`${richTextElement.id}/content`, { data: { content: { text, inputFormat: InputFormat.RICH_TEXT_CK5 }, type: ContentElementType.RICH_TEXT }, }); - const result = await em.findOneOrFail(RichTextElementNode, richTextelement.id); + const result = await em.findOneOrFail(RichTextElementNode, richTextElement.id); expect(result.text).toEqual(sanitizedText); }); @@ -146,6 +163,76 @@ describe(`content element update content (api)`, () => { expect(result.alternativeText).toEqual('rich text 1 some more text'); }); + + it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => { + const { loggedInClient, submissionContainerElement } = await setup(); + + const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { + data: { + content: {}, + type: 'submissionContainer', + }, + }); + + expect(response.statusCode).toEqual(204); + }); + + it('should not change dueDate value without dueDate parameter for submission container element', async () => { + const { loggedInClient, submissionContainerElement } = await setup(); + + await loggedInClient.patch(`${submissionContainerElement.id}/content`, { + data: { + content: {}, + type: 'submissionContainer', + }, + }); + const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElement.id); + + expect(result.dueDate).toBeUndefined(); + }); + + it('should set dueDate value when dueDate parameter is provided for submission container element', async () => { + const { loggedInClient, submissionContainerElement } = await setup(); + + const inThreeDays = new Date(Date.now() + 259200000); + + await loggedInClient.patch(`${submissionContainerElement.id}/content`, { + data: { + content: { dueDate: inThreeDays }, + type: 'submissionContainer', + }, + }); + const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElement.id); + + expect(result.dueDate).toEqual(inThreeDays); + }); + + it('should unset dueDate value when dueDate parameter is not provided for submission container element', async () => { + const { loggedInClient, submissionContainerElementWithDueDate } = await setup(); + + await loggedInClient.patch(`${submissionContainerElementWithDueDate.id}/content`, { + data: { + content: {}, + type: 'submissionContainer', + }, + }); + const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElementWithDueDate.id); + + expect(result.dueDate).toBeUndefined(); + }); + + it('should return status 400 for wrong date format for submission container element', async () => { + const { loggedInClient, submissionContainerElement } = await setup(); + + const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { + data: { + content: { dueDate: 'hello world' }, + type: 'submissionContainer', + }, + }); + + expect(response.statusCode).toEqual(400); + }); }); describe('with invalid user', () => { @@ -163,24 +250,38 @@ describe(`content element update content (api)`, () => { const column = columnNodeFactory.buildWithId({ parent: columnBoardNode }); const parentCard = cardNodeFactory.buildWithId({ parent: column }); - const element = richTextElementNodeFactory.buildWithId({ parent: parentCard }); + const richTextElement = richTextElementNodeFactory.buildWithId({ parent: parentCard }); + const submissionContainerElement = submissionContainerElementNodeFactory.buildWithId({ parent: parentCard }); - await em.persistAndFlush([parentCard, column, columnBoardNode, element]); + await em.persistAndFlush([parentCard, column, columnBoardNode, richTextElement, submissionContainerElement]); em.clear(); const loggedInClient = await testApiClient.login(invalidTeacherAccount); - return { loggedInClient, element }; + return { loggedInClient, richTextElement, submissionContainerElement }; }; - it('should return status 403', async () => { - const { loggedInClient, element } = await setup(); + it('should return status 403 for rich text element', async () => { + const { loggedInClient, richTextElement } = await setup(); - const response = await loggedInClient.patch(`${element.id}/content`, { + const response = await loggedInClient.patch(`${richTextElement.id}/content`, { data: { content: { text: 'hello world', inputFormat: InputFormat.RICH_TEXT_CK5 }, type: 'richText' }, }); expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); + + it('should return status 403 for submission container element', async () => { + const { loggedInClient, submissionContainerElement } = await setup(); + + const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { + data: { + content: {}, + type: 'submissionContainer', + }, + }); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + }); }); }); diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index 8ff034ad093..44ee426fb6b 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -1,6 +1,6 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; -import { AnyContentElementResponse } from '../element'; +import { AnyContentElementResponse, FileElementResponse, SubmissionContainerElementResponse } from '../element'; import { RichTextElementResponse } from '../element/rich-text-element.response'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; @@ -31,7 +31,11 @@ export class CardResponse { @ApiProperty({ type: 'array', items: { - oneOf: [{ $ref: getSchemaPath(RichTextElementResponse) }], + oneOf: [ + { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(SubmissionContainerElementResponse) }, + ], }, }) elements: AnyContentElementResponse[]; diff --git a/apps/server/src/modules/board/controller/dto/element/submission-container-element.response.ts b/apps/server/src/modules/board/controller/dto/element/submission-container-element.response.ts index 642d5e44818..e6f0d1364ef 100644 --- a/apps/server/src/modules/board/controller/dto/element/submission-container-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/submission-container-element.response.ts @@ -7,8 +7,12 @@ export class SubmissionContainerElementContent { this.dueDate = dueDate; } - @ApiProperty() - dueDate: Date; + @ApiProperty({ + type: Date, + description: 'The dueDate as date string or null of not set', + example: '2023-08-17T14:17:51.958+00:00', + }) + dueDate: Date | null; } export class SubmissionContainerElementResponse { diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 1f2a320119e..05856e9ef5f 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -53,8 +53,12 @@ export class RichTextElementContentBody extends ElementContentBody { export class SubmissionContainerContentBody { @IsDate() - @ApiProperty() - dueDate!: Date; + @IsOptional() + @ApiPropertyOptional({ + required: false, + description: 'The point in time until when a submission can be handed in.', + }) + dueDate?: Date; } export class SubmissionContainerElementContentBody extends ElementContentBody { diff --git a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts index 30acafa298f..8b3dc6ae54f 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts @@ -18,9 +18,15 @@ export class SubmissionContainerElementResponseMapper implements BaseResponseMap id: element.id, timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), type: ContentElementType.SUBMISSION_CONTAINER, - content: new SubmissionContainerElementContent({ dueDate: element.dueDate }), + content: new SubmissionContainerElementContent({ + dueDate: element.dueDate || null, + }), }); + if (element.dueDate) { + result.content = new SubmissionContainerElementContent({ dueDate: element.dueDate }); + } + return result; } diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 87dc4382798..af58280b33f 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -125,11 +125,15 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { const element = new SubmissionContainerElement({ id: boardNode.id, - dueDate: boardNode.dueDate, children: elements, createdAt: boardNode.createdAt, updatedAt: boardNode.updatedAt, }); + + if (boardNode.dueDate) { + element.dueDate = boardNode.dueDate; + } + return element; } diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index 082cc73c4f4..5561e636267 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -128,11 +128,14 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { const boardNode = new SubmissionContainerElementNode({ id: submissionContainerElement.id, - dueDate: submissionContainerElement.dueDate, parent: parentData?.boardNode, position: parentData?.position, }); + if (submissionContainerElement.dueDate) { + boardNode.dueDate = submissionContainerElement.dueDate; + } + this.createOrUpdateBoardNode(boardNode); this.visitChildren(submissionContainerElement, boardNode); } diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index d660fbee98c..dfd430aa250 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -59,7 +59,7 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitor { visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { if (this.content instanceof SubmissionContainerContentBody) { - submissionContainerElement.dueDate = this.content.dueDate; + submissionContainerElement.dueDate = this.content.dueDate ?? undefined; } else { this.throwNotHandled(submissionContainerElement); } diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index ea268206559..fb476d2dbd0 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -63,10 +63,8 @@ export class ContentElementFactory { } private buildSubmissionContainer() { - const tomorrow = new Date(Date.now() + 86400000); const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), - dueDate: tomorrow, children: [], createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts index 3b9a85600c6..09756153a90 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts @@ -3,11 +3,11 @@ import { SubmissionItem } from './submission-item.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; export class SubmissionContainerElement extends BoardComposite { - get dueDate(): Date { + get dueDate(): Date | undefined { return this.props.dueDate; } - set dueDate(value: Date) { + set dueDate(value: Date | undefined) { this.props.dueDate = value; } @@ -26,7 +26,7 @@ export class SubmissionContainerElement extends BoardComposite(SubmissionContainerElementNode, () => { - const inThreeDays = new Date(Date.now() + 259200000); - return { - dueDate: inThreeDays, - }; + return {}; }); From ddfdae2a8b0f9bb291a610b0e474a36cb4efac17 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Tue, 26 Sep 2023 12:02:29 +0200 Subject: [PATCH 10/34] N21-1257 refactoring tool module (#4434) refactoring tool Module --- .../src/modules/pseudonym/loggable/index.ts | 1 + ...many-pseudonyms.loggable-exception.spec.ts | 43 +++++++++++++++++++ .../too-many-pseudonyms.loggable-exception.ts | 28 ++++++++++++ ...status-outdated.loggable-exception.spec.ts | 32 ++++++++++++++ .../tool-launch/mapper/tool-launch.mapper.ts | 10 ++--- .../strategy/lti11-tool-launch.strategy.ts | 22 ++++++++-- .../oauth2-tool-launch.strategy.spec.ts | 6 +-- .../service/tool-launch.service.spec.ts | 26 ----------- .../types/authentication-values.ts | 10 +++++ .../modules/tool/tool-launch/types/index.ts | 1 + .../tool-launch/uc/tool-launch.uc.spec.ts | 20 ++++----- 11 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/modules/pseudonym/loggable/index.ts create mode 100644 apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.ts create mode 100644 apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/tool/tool-launch/types/authentication-values.ts diff --git a/apps/server/src/modules/pseudonym/loggable/index.ts b/apps/server/src/modules/pseudonym/loggable/index.ts new file mode 100644 index 00000000000..134eab4c556 --- /dev/null +++ b/apps/server/src/modules/pseudonym/loggable/index.ts @@ -0,0 +1 @@ +export * from './too-many-pseudonyms.loggable-exception'; diff --git a/apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.spec.ts b/apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.spec.ts new file mode 100644 index 00000000000..b03350c38ba --- /dev/null +++ b/apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.spec.ts @@ -0,0 +1,43 @@ +import { TooManyPseudonymsLoggableException } from './too-many-pseudonyms.loggable-exception'; + +describe('TooManyPseudonymsLoggableException', () => { + describe('constructor', () => { + const setup = () => { + const pseudonym = 'pseudonym'; + + return { pseudonym }; + }; + + it('should create an instance of TooManyPseudonymsLoggableException', () => { + const { pseudonym } = setup(); + + const loggable = new TooManyPseudonymsLoggableException(pseudonym); + + expect(loggable).toBeInstanceOf(TooManyPseudonymsLoggableException); + }); + }); + + describe('getLogMessage', () => { + const setup = () => { + const pseudonym = 'pseudonym'; + const loggable = new TooManyPseudonymsLoggableException(pseudonym); + + return { loggable, pseudonym }; + }; + + it('should return a loggable message', () => { + const { loggable, pseudonym } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'PSEUDONYMS_TOO_MANY_PSEUDONYMS_FOUND', + message: 'Too many pseudonyms where found.', + stack: loggable.stack, + data: { + pseudonym, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.ts b/apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.ts new file mode 100644 index 00000000000..eb402f93900 --- /dev/null +++ b/apps/server/src/modules/pseudonym/loggable/too-many-pseudonyms.loggable-exception.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger/types'; + +export class TooManyPseudonymsLoggableException extends BusinessError implements Loggable { + constructor(private readonly pseudonym: string) { + super( + { + type: 'PSEUDONYMS_TOO_MANY_PSEUDONYMS_FOUND', + title: 'Too many pseudonyms where found.', + defaultMessage: 'Too many pseudonyms where found.', + }, + HttpStatus.BAD_REQUEST + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'PSEUDONYMS_TOO_MANY_PSEUDONYMS_FOUND', + message: 'Too many pseudonyms where found.', + stack: this.stack, + data: { + pseudonym: this.pseudonym, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts new file mode 100644 index 00000000000..c20249fd69f --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-outdated.loggable-exception.spec.ts @@ -0,0 +1,32 @@ +import { ToolStatusOutdatedLoggableException } from './tool-status-outdated.loggable-exception'; + +describe('ToolStatusOutdatedLoggableException', () => { + describe('getLogMessage', () => { + const setup = () => { + const toolId = 'toolId'; + const userId = 'userId'; + + const exception = new ToolStatusOutdatedLoggableException(userId, toolId); + + return { + exception, + }; + }; + + it('should log the correct message', () => { + const { exception } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'TOOL_STATUS_OUTDATED', + message: expect.any(String), + stack: expect.any(String), + data: { + userId: 'userId', + toolId: 'toolId', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts index 7fd0370d589..d3d8e060127 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts @@ -14,11 +14,11 @@ const toolConfigTypeToToolLaunchDataTypeMapping: Record = Object.entries( - toolConfigTypeToToolLaunchDataTypeMapping -).reduce((acc: Record, [key, value]) => { - return { ...acc, [value]: key as ToolConfigType }; -}, {} as Record); +const toolLaunchDataTypeToToolConfigTypeMapping: Record = { + [ToolLaunchDataType.BASIC]: ToolConfigType.BASIC, + [ToolLaunchDataType.LTI11]: ToolConfigType.LTI11, + [ToolLaunchDataType.OAUTH2]: ToolConfigType.OAUTH2, +}; export class ToolLaunchMapper { static mapToParameterLocation(location: CustomParameterLocation): PropertyLocation { diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts index 826d6d69d91..8c957ca9421 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts @@ -9,7 +9,7 @@ import { Authorization } from 'oauth-1.0a'; import { LtiRole } from '../../../common/enum'; import { ExternalTool } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; -import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; +import { LaunchRequestMethod, PropertyData, PropertyLocation, AuthenticationValues } from '../../types'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { IToolLaunchParams } from './tool-launch-params.interface'; @@ -129,6 +129,19 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { payload[property.name] = property.value; } + const authentication: AuthenticationValues = this.getAuthenticationValues(properties); + + const signedPayload: Authorization = this.lti11EncryptionService.sign( + authentication.keyValue, + authentication.secretValue, + url, + payload + ); + + return JSON.stringify(signedPayload); + } + + private getAuthenticationValues(properties: PropertyData[]): AuthenticationValues { const key: PropertyData | undefined = properties.find((property: PropertyData) => property.name === 'key'); const secret: PropertyData | undefined = properties.find((property: PropertyData) => property.name === 'secret'); @@ -138,9 +151,12 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { ); } - const signedPayload: Authorization = this.lti11EncryptionService.sign(key.value, secret.value, url, payload); + const authentication: AuthenticationValues = new AuthenticationValues({ + keyValue: key.value, + secretValue: secret.value, + }); - return JSON.stringify(signedPayload); + return authentication; } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts index b49eb4c85de..f1e3388ed15 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/oauth2-tool-launch.strategy.spec.ts @@ -33,7 +33,7 @@ describe('OAuth2ToolLaunchStrategy', () => { }); describe('buildToolLaunchRequestPayload', () => { - describe('when always', () => { + describe('whenever it is called', () => { it('should return undefined', () => { const payload: string | null = strategy.buildToolLaunchRequestPayload('url', []); @@ -43,7 +43,7 @@ describe('OAuth2ToolLaunchStrategy', () => { }); describe('buildToolLaunchDataFromConcreteConfig', () => { - describe('when always', () => { + describe('whenever it is called', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.build(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); @@ -69,7 +69,7 @@ describe('OAuth2ToolLaunchStrategy', () => { }); describe('determineLaunchRequestMethod', () => { - describe('when always', () => { + describe('whenever it is called', () => { it('should return GET', () => { const result: LaunchRequestMethod = strategy.determineLaunchRequestMethod([]); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 3be1df798fe..02bb484093f 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -181,32 +181,6 @@ describe('ToolLaunchService', () => { new InternalServerErrorException('Unknown tool config type') ); }); - - it('should call getSchoolExternalToolById', async () => { - const { launchParams } = setup(); - - try { - await service.getLaunchData('userId', launchParams.contextExternalTool); - } catch (exception) { - // Do nothing - } - - expect(schoolExternalToolService.getSchoolExternalToolById).toHaveBeenCalledWith( - launchParams.schoolExternalTool.id - ); - }); - - it('should call findExternalToolById', async () => { - const { launchParams } = setup(); - - try { - await service.getLaunchData('userId', launchParams.contextExternalTool); - } catch (exception) { - // Do nothing - } - - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); - }); }); describe('when tool configuration status is not LATEST', () => { diff --git a/apps/server/src/modules/tool/tool-launch/types/authentication-values.ts b/apps/server/src/modules/tool/tool-launch/types/authentication-values.ts new file mode 100644 index 00000000000..4d4d568cbd6 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/types/authentication-values.ts @@ -0,0 +1,10 @@ +export class AuthenticationValues { + keyValue: string; + + secretValue: string; + + constructor(props: AuthenticationValues) { + this.keyValue = props.keyValue; + this.secretValue = props.secretValue; + } +} diff --git a/apps/server/src/modules/tool/tool-launch/types/index.ts b/apps/server/src/modules/tool/tool-launch/types/index.ts index 13eacedf6dd..47a1fe842cf 100644 --- a/apps/server/src/modules/tool/tool-launch/types/index.ts +++ b/apps/server/src/modules/tool/tool-launch/types/index.ts @@ -4,3 +4,4 @@ export * from './property-location'; export * from './tool-launch-request'; export * from './tool-launch-data-type'; export * from './launch-request-method'; +export * from './authentication-values'; diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts index a0b31b9c321..62424d8b8aa 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { contextExternalToolFactory } from '@shared/testing'; +import { ObjectId } from 'bson'; import { ToolLaunchService } from '../service'; import { ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; @@ -62,7 +63,11 @@ describe('ToolLaunchUc', () => { properties: [], }); - const userId = 'userId'; + const userId: string = new ObjectId().toHexString(); + + toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); + contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); return { userId, @@ -82,8 +87,6 @@ describe('ToolLaunchUc', () => { it('should call service to get data', async () => { const { userId, contextExternalToolId, contextExternalTool } = setup(); - toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); await uc.getToolLaunchRequest(userId, contextExternalToolId); @@ -91,11 +94,9 @@ describe('ToolLaunchUc', () => { }); it('should call service to generate launch request', async () => { - const { userId, contextExternalToolId, contextExternalTool, toolLaunchData } = setup(); - toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); + const { userId, contextExternalToolId, toolLaunchData } = setup(); - toolLaunchService.getLaunchData.mockResolvedValue(toolLaunchData); + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); await uc.getToolLaunchRequest(userId, contextExternalToolId); @@ -103,10 +104,7 @@ describe('ToolLaunchUc', () => { }); it('should return launch request', async () => { - const { userId, contextExternalToolId, toolLaunchData, contextExternalTool } = setup(); - toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); - toolLaunchService.getLaunchData.mockResolvedValue(toolLaunchData); + const { userId, contextExternalToolId } = setup(); const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(userId, contextExternalToolId); From 7e6a281a9427b2283c8abf76ff3578d3ddfd607b Mon Sep 17 00:00:00 2001 From: mrikallab <93978883+mrikallab@users.noreply.github.com> Date: Tue, 26 Sep 2023 12:31:39 +0200 Subject: [PATCH 11/34] N21-841 use user login migration (#4356) * N21-841 fix close migration --- .../api-test/user-login-migration.api.spec.ts | 49 +++++++++++++++++++ .../user-login-migration.controller.ts | 16 +++--- .../service/user-login-migration.service.ts | 1 - .../uc/close-user-login-migration.uc.spec.ts | 16 ++++++ .../uc/close-user-login-migration.uc.ts | 6 +-- .../uc/restart-user-login-migration.uc.ts | 2 - .../uc/start-user-login-migration.uc.ts | 2 - 7 files changed, 78 insertions(+), 14 deletions(-) diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index 95cb8fdde5e..486c814ccd7 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -1202,5 +1202,54 @@ describe('UserLoginMigrationController (API)', () => { expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); }); + + describe('when no user has migrate', () => { + const setup = async () => { + const sourceSystem: SystemEntity = systemFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); + const targetSystem: SystemEntity = systemFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [sourceSystem], + officialSchoolNumber: '12345', + }); + const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ + school, + targetSystem, + sourceSystem, + startedAt: new Date(2023, 1, 4), + }); + + const user: User = userFactory.buildWithId(); + + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.USER_LOGIN_MIGRATION_ADMIN, + ]); + + await em.persistAndFlush([ + sourceSystem, + targetSystem, + school, + adminAccount, + adminUser, + userLoginMigration, + user, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + userLoginMigration, + }; + }; + + it('should return nothing', async () => { + const { loggedInClient } = await setup(); + + const response: Response = await loggedInClient.post('/close'); + + expect(response.body).toEqual({}); + }); + }); }); }); diff --git a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts index 7ef6242eb72..19098167d96 100644 --- a/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts +++ b/apps/server/src/modules/user-login-migration/controller/user-login-migration.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiForbiddenResponse, ApiInternalServerErrorResponse, + ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, @@ -196,16 +197,19 @@ export class UserLoginMigrationController { @ApiOkResponse({ description: 'User login migration closed', type: UserLoginMigrationResponse }) @ApiUnauthorizedResponse() @ApiForbiddenResponse() - async closeMigration(@CurrentUser() currentUser: ICurrentUser): Promise { - const userLoginMigration: UserLoginMigrationDO = await this.closeUserLoginMigrationUc.closeMigration( + @ApiNoContentResponse({ description: 'User login migration was reverted' }) + async closeMigration(@CurrentUser() currentUser: ICurrentUser): Promise { + const userLoginMigration: UserLoginMigrationDO | undefined = await this.closeUserLoginMigrationUc.closeMigration( currentUser.userId, currentUser.schoolId ); - const migrationResponse: UserLoginMigrationResponse = - UserLoginMigrationMapper.mapUserLoginMigrationDoToResponse(userLoginMigration); - - return migrationResponse; + if (userLoginMigration) { + const migrationResponse: UserLoginMigrationResponse = + UserLoginMigrationMapper.mapUserLoginMigrationDoToResponse(userLoginMigration); + return migrationResponse; + } + return undefined; } @Post('migrate-to-oauth2') diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts index f1037997955..9f3d6c59e84 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration.service.ts @@ -169,7 +169,6 @@ export class UserLoginMigrationService { } private async updateExistingMigration(userLoginMigrationDO: UserLoginMigrationDO) { - userLoginMigrationDO.startedAt = new Date(); userLoginMigrationDO.closedAt = undefined; userLoginMigrationDO.finishedAt = undefined; diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts index 1ea3fa50954..6b796e69bba 100644 --- a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.spec.ts @@ -179,6 +179,22 @@ describe('CloseUserLoginMigrationUc', () => { expect(userLoginMigrationRevertService.revertUserLoginMigration).toHaveBeenCalledWith(closedUserLoginMigration); }); + + it('should not mark all un-migrated users as outdated', async () => { + const { user, schoolId } = setup(); + + await uc.closeMigration(user.id, schoolId); + + expect(schoolMigrationService.markUnmigratedUsersAsOutdated).not.toHaveBeenCalled(); + }); + + it('should return undefined', async () => { + const { user, schoolId } = setup(); + + const result = await uc.closeMigration(user.id, schoolId); + + expect(result).toBeUndefined(); + }); }); describe('when the user login migration was already closed', () => { diff --git a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts index ad6646edae8..13058e64c49 100644 --- a/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/close-user-login-migration.uc.ts @@ -16,7 +16,7 @@ export class CloseUserLoginMigrationUc { private readonly authorizationService: AuthorizationService ) {} - async closeMigration(userId: EntityId, schoolId: EntityId): Promise { + async closeMigration(userId: EntityId, schoolId: EntityId): Promise { const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( schoolId ); @@ -47,9 +47,9 @@ export class CloseUserLoginMigrationUc { if (!hasSchoolMigratedUser) { await this.userLoginMigrationRevertService.revertUserLoginMigration(updatedUserLoginMigration); - } else { - await this.schoolMigrationService.markUnmigratedUsersAsOutdated(schoolId); + return undefined; } + await this.schoolMigrationService.markUnmigratedUsersAsOutdated(schoolId); return updatedUserLoginMigration; } diff --git a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts index 514973db20b..42412e50fe3 100644 --- a/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/restart-user-login-migration.uc.ts @@ -39,8 +39,6 @@ export class RestartUserLoginMigrationUc { userLoginMigration = await this.userLoginMigrationService.restartMigration(schoolId); this.logger.info(new UserLoginMigrationStartLoggable(userId, schoolId)); - } else { - // Do nothing, if migration is already started but not stopped. } return userLoginMigration; diff --git a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts index 8eefd9a85ee..9e670e7fdf3 100644 --- a/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts +++ b/apps/server/src/modules/user-login-migration/uc/start-user-login-migration.uc.ts @@ -34,8 +34,6 @@ export class StartUserLoginMigrationUc { userLoginMigration.id as string, userLoginMigration.closedAt ); - } else { - // Do nothing, if migration is already started but not stopped. } return userLoginMigration; From 5b4fa78ac0d70c13b4ac1b434ea976ef5a71e951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:47:53 +0200 Subject: [PATCH 12/34] N21-1206 show classes and goups (#4406) * implement endpoint for class list * add feature --- .../modules/class/repo/classes.repo.spec.ts | 44 ++- .../src/modules/class/repo/classes.repo.ts | 20 +- .../class/service/class.service.spec.ts | 47 ++- .../modules/class/service/class.service.ts | 6 +- .../controller/api-test/group.api.spec.ts | 140 ++++++++ .../src/modules/group/controller/dto/index.ts | 2 + .../dto/request/class-sort-params.ts | 15 + .../group/controller/dto/request/index.ts | 1 + .../class-info-search-list.response.ts | 13 + .../dto/response/class-info.response.ts | 18 + .../group/controller/dto/response/index.ts | 2 + .../group/controller/group.controller.ts | 46 +++ .../src/modules/group/controller/index.ts | 1 + .../mapper/group-response.mapper.ts | 34 ++ .../modules/group/controller/mapper/index.ts | 1 + apps/server/src/modules/group/domain/group.ts | 18 +- .../src/modules/group/group-api.module.ts | 12 +- .../src/modules/group/repo/group.repo.spec.ts | 70 +++- .../src/modules/group/repo/group.repo.ts | 25 +- .../group/service/group.service.spec.ts | 33 ++ .../modules/group/service/group.service.ts | 18 +- .../modules/group/uc/dto/class-info.dto.ts | 13 + apps/server/src/modules/group/uc/dto/index.ts | 2 + .../group/uc/dto/resolved-group-user.ts | 13 + .../src/modules/group/uc/group.uc.spec.ts | 309 ++++++++++++++++++ apps/server/src/modules/group/uc/group.uc.ts | 150 +++++++++ apps/server/src/modules/group/uc/index.ts | 1 + .../group/uc/mapper/group-uc.mapper.ts | 35 ++ apps/server/src/modules/group/util/index.ts | 1 + .../modules/group/util/sort-helper.spec.ts | 70 ++++ .../src/modules/group/util/sort-helper.ts | 21 ++ .../sanis/sanis-response.mapper.spec.ts | 2 +- .../strategy/sanis/sanis-response.mapper.ts | 8 +- .../shared/testing/factory/user.factory.ts | 6 +- config/default.schema.json | 5 + src/services/config/publicAppConfigService.js | 1 + 36 files changed, 1143 insertions(+), 60 deletions(-) create mode 100644 apps/server/src/modules/group/controller/api-test/group.api.spec.ts create mode 100644 apps/server/src/modules/group/controller/dto/index.ts create mode 100644 apps/server/src/modules/group/controller/dto/request/class-sort-params.ts create mode 100644 apps/server/src/modules/group/controller/dto/request/index.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/class-info.response.ts create mode 100644 apps/server/src/modules/group/controller/dto/response/index.ts create mode 100644 apps/server/src/modules/group/controller/group.controller.ts create mode 100644 apps/server/src/modules/group/controller/index.ts create mode 100644 apps/server/src/modules/group/controller/mapper/group-response.mapper.ts create mode 100644 apps/server/src/modules/group/controller/mapper/index.ts create mode 100644 apps/server/src/modules/group/uc/dto/class-info.dto.ts create mode 100644 apps/server/src/modules/group/uc/dto/index.ts create mode 100644 apps/server/src/modules/group/uc/dto/resolved-group-user.ts create mode 100644 apps/server/src/modules/group/uc/group.uc.spec.ts create mode 100644 apps/server/src/modules/group/uc/group.uc.ts create mode 100644 apps/server/src/modules/group/uc/index.ts create mode 100644 apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts create mode 100644 apps/server/src/modules/group/util/index.ts create mode 100644 apps/server/src/modules/group/util/sort-helper.spec.ts create mode 100644 apps/server/src/modules/group/util/sort-helper.ts diff --git a/apps/server/src/modules/class/repo/classes.repo.spec.ts b/apps/server/src/modules/class/repo/classes.repo.spec.ts index c7c519c9435..8059cdf8557 100644 --- a/apps/server/src/modules/class/repo/classes.repo.spec.ts +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -1,13 +1,14 @@ -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { TestingModule } from '@nestjs/testing/testing-module'; import { Test } from '@nestjs/testing'; -import { cleanupCollections } from '@shared/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { SchoolEntity } from '@shared/domain'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections, schoolFactory } from '@shared/testing'; import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; -import { ClassesRepo } from './classes.repo'; +import { Class } from '../domain'; import { ClassEntity } from '../entity'; +import { ClassesRepo } from './classes.repo'; import { ClassMapper } from './mapper'; -import { Class } from '../domain'; describe(ClassesRepo.name, () => { let module: TestingModule; @@ -32,6 +33,38 @@ describe(ClassesRepo.name, () => { await cleanupCollections(em); }); + describe('findAllBySchoolId', () => { + describe('when school has no class', () => { + it('should return empty array', async () => { + const result = await repo.findAllBySchoolId(new ObjectId().toHexString()); + + expect(result).toEqual([]); + }); + }); + + describe('when school has classes', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const classes: ClassEntity[] = classEntityFactory.buildListWithId(3, { schoolId: school.id }); + + await em.persistAndFlush(classes); + + return { + school, + classes, + }; + }; + + it('should find classes with particular userId', async () => { + const { school } = await setup(); + + const result: Class[] = await repo.findAllBySchoolId(school.id); + + expect(result.length).toEqual(3); + }); + }); + }); + describe('findAllByUserId', () => { describe('when user is not found in classes', () => { it('should return empty array', async () => { @@ -40,6 +73,7 @@ describe(ClassesRepo.name, () => { expect(result).toEqual([]); }); }); + describe('when user is in classes', () => { const setup = async () => { const testUser = new ObjectId(); diff --git a/apps/server/src/modules/class/repo/classes.repo.ts b/apps/server/src/modules/class/repo/classes.repo.ts index 1e4b3ba750e..378b3de9716 100644 --- a/apps/server/src/modules/class/repo/classes.repo.ts +++ b/apps/server/src/modules/class/repo/classes.repo.ts @@ -1,18 +1,28 @@ -import { Injectable } from '@nestjs/common'; - import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { ClassEntity } from '../entity'; import { Class } from '../domain'; +import { ClassEntity } from '../entity'; import { ClassMapper } from './mapper'; @Injectable() export class ClassesRepo { - constructor(private readonly em: EntityManager, private readonly mapper: ClassMapper) {} + constructor(private readonly em: EntityManager) {} + + async findAllBySchoolId(schoolId: EntityId): Promise { + const classes: ClassEntity[] = await this.em.find(ClassEntity, { schoolId: new ObjectId(schoolId) }); + + const mapped: Class[] = ClassMapper.mapToDOs(classes); + + return mapped; + } async findAllByUserId(userId: EntityId): Promise { const classes: ClassEntity[] = await this.em.find(ClassEntity, { userIds: new ObjectId(userId) }); - return ClassMapper.mapToDOs(classes); + + const mapped: Class[] = ClassMapper.mapToDOs(classes); + + return mapped; } async updateMany(classes: Class[]): Promise { diff --git a/apps/server/src/modules/class/service/class.service.spec.ts b/apps/server/src/modules/class/service/class.service.spec.ts index d851a38f62e..3d1e851bd32 100644 --- a/apps/server/src/modules/class/service/class.service.spec.ts +++ b/apps/server/src/modules/class/service/class.service.spec.ts @@ -1,13 +1,15 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { EntityId } from '@shared/domain'; -import { InternalServerErrorException } from '@nestjs/common'; -import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; -import { ObjectId } from '@mikro-orm/mongodb'; import { setupEntities } from '@shared/testing'; -import { ClassService } from './class.service'; +import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; +import { Class } from '../domain'; +import { classFactory } from '../domain/testing/factory/class.factory'; import { ClassesRepo } from '../repo'; import { ClassMapper } from '../repo/mapper'; +import { ClassService } from './class.service'; describe(ClassService.name, () => { let module: TestingModule; @@ -39,38 +41,35 @@ describe(ClassService.name, () => { await module.close(); }); - describe('findUserDataFromClasses', () => { - describe('when finding by userId', () => { + describe('findClassesForSchool', () => { + describe('when the school has classes', () => { const setup = () => { - const userId1 = new ObjectId(); - const userId2 = new ObjectId(); - const userId3 = new ObjectId(); - const class1 = classEntityFactory.withUserIds([userId1, userId2]).build(); - const class2 = classEntityFactory.withUserIds([userId1, userId3]).build(); - classEntityFactory.withUserIds([userId2, userId3]).build(); + const schoolId: string = new ObjectId().toHexString(); - const mappedClasses = ClassMapper.mapToDOs([class1, class2]); + const classes: Class[] = classFactory.buildList(3); - classesRepo.findAllByUserId.mockResolvedValue(mappedClasses); + classesRepo.findAllBySchoolId.mockResolvedValueOnce(classes); return { - userId1, + schoolId, + classes, }; }; - it('should call classesRepo.findAllByUserId', async () => { - const { userId1 } = setup(); - await service.deleteUserDataFromClasses(userId1.toHexString()); + it('should call the repo', async () => { + const { schoolId } = setup(); - expect(classesRepo.findAllByUserId).toBeCalledWith(userId1.toHexString()); + await service.findClassesForSchool(schoolId); + + expect(classesRepo.findAllBySchoolId).toHaveBeenCalledWith(schoolId); }); - it('should return array of two teams with user', async () => { - const { userId1 } = setup(); + it('should return the classes', async () => { + const { schoolId, classes } = setup(); - const result = await service.findUserDataFromClasses(userId1.toHexString()); + const result: Class[] = await service.findClassesForSchool(schoolId); - expect(result.length).toEqual(2); + expect(result).toEqual(classes); }); }); }); diff --git a/apps/server/src/modules/class/service/class.service.ts b/apps/server/src/modules/class/service/class.service.ts index 7a42606769a..9671c456912 100644 --- a/apps/server/src/modules/class/service/class.service.ts +++ b/apps/server/src/modules/class/service/class.service.ts @@ -1,14 +1,14 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { ClassesRepo } from '../repo'; import { Class } from '../domain'; +import { ClassesRepo } from '../repo'; @Injectable() export class ClassService { constructor(private readonly classesRepo: ClassesRepo) {} - public async findUserDataFromClasses(userId: EntityId): Promise { - const classes = await this.classesRepo.findAllByUserId(userId); + public async findClassesForSchool(schoolId: EntityId): Promise { + const classes: Class[] = await this.classesRepo.findAllBySchoolId(schoolId); return classes; } 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 new file mode 100644 index 00000000000..f0561518c0c --- /dev/null +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -0,0 +1,140 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, RoleName, SchoolEntity, SortOrder, SystemEntity, User } from '@shared/domain'; +import { + groupEntityFactory, + roleFactory, + schoolFactory, + systemFactory, + TestApiClient, + UserAndAccountTestFactory, + userFactory, +} from '@shared/testing'; +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 { GroupEntity, GroupEntityTypes } from '../../entity'; +import { ClassInfoSearchListResponse, ClassSortBy } from '../dto'; + +const baseRouteName = '/groups'; + +describe('Group (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('findClassesForSchool', () => { + describe('when an admin requests a list of classes', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); + const system: SystemEntity = systemFactory.buildWithId(); + const clazz: ClassEntity = classEntityFactory.buildWithId({ + name: 'Group A', + schoolId: school._id, + teacherIds: [teacherUser._id], + source: undefined, + }); + const group: GroupEntity = groupEntityFactory.buildWithId({ + name: 'Group B', + type: GroupEntityTypes.CLASS, + externalSource: { + externalId: 'externalId', + system, + }, + organization: school, + users: [ + { + user: adminUser, + role: teacherRole, + }, + ], + }); + + await em.persistAndFlush([school, adminAccount, adminUser, teacherRole, teacherUser, system, clazz, group]); + em.clear(); + + const adminClient = await testApiClient.login(adminAccount); + + return { + adminClient, + group, + clazz, + system, + adminUser, + teacherUser, + }; + }; + + it('should return the classes of his school', async () => { + const { adminClient, group, clazz, system, adminUser, teacherUser } = await setup(); + + const response = await adminClient.get(`/class`).query({ + skip: 0, + limit: 2, + sortBy: ClassSortBy.NAME, + sortOrder: SortOrder.desc, + }); + + expect(response.body).toEqual({ + total: 2, + data: [ + { + name: group.name, + externalSourceName: system.displayName, + teachers: [adminUser.lastName], + }, + { + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + teachers: [teacherUser.lastName], + }, + ], + skip: 0, + limit: 2, + }); + }); + }); + + describe('when an invalid user requests a list of classes', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + }; + }; + + it('should return forbidden', async () => { + const { studentClient } = await setup(); + + const response = await studentClient.get(`/class`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/controller/dto/index.ts b/apps/server/src/modules/group/controller/dto/index.ts new file mode 100644 index 00000000000..a05175977f8 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/index.ts @@ -0,0 +1,2 @@ +export * from './request'; +export * from './response'; diff --git a/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts new file mode 100644 index 00000000000..094f7efece4 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SortingParams } from '@shared/controller'; +import { IsEnum, IsOptional } from 'class-validator'; + +export enum ClassSortBy { + NAME = 'name', + EXTERNAL_SOURCE_NAME = 'externalSourceName', +} + +export class ClassSortParams extends SortingParams { + @IsOptional() + @IsEnum(ClassSortBy) + @ApiPropertyOptional({ enum: ClassSortBy }) + sortBy?: ClassSortBy; +} diff --git a/apps/server/src/modules/group/controller/dto/request/index.ts b/apps/server/src/modules/group/controller/dto/request/index.ts new file mode 100644 index 00000000000..2255e9aac09 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/index.ts @@ -0,0 +1 @@ +export * from './class-sort-params'; diff --git a/apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts b/apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts new file mode 100644 index 00000000000..0af573f1b94 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { ClassInfoResponse } from './class-info.response'; + +export class ClassInfoSearchListResponse extends PaginationResponse { + constructor(data: ClassInfoResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [ClassInfoResponse] }) + data: ClassInfoResponse[]; +} diff --git a/apps/server/src/modules/group/controller/dto/response/class-info.response.ts b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts new file mode 100644 index 00000000000..a2d71333c04 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ClassInfoResponse { + @ApiProperty() + name: string; + + @ApiPropertyOptional() + externalSourceName?: string; + + @ApiProperty({ type: [String] }) + teachers: string[]; + + constructor(props: ClassInfoResponse) { + this.name = props.name; + this.externalSourceName = props.externalSourceName; + this.teachers = props.teachers; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/index.ts b/apps/server/src/modules/group/controller/dto/response/index.ts new file mode 100644 index 00000000000..1ec8a62f0d4 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/index.ts @@ -0,0 +1,2 @@ +export * from './class-info.response'; +export * from './class-info-search-list.response'; diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts new file mode 100644 index 00000000000..e810e200d85 --- /dev/null +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, HttpStatus, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PaginationParams } from '@shared/controller'; +import { Page } from '@shared/domain'; +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 { GroupResponseMapper } from './mapper'; + +@ApiTags('Group') +@Authenticate('jwt') +@Controller('groups') +export class GroupController { + constructor(private readonly groupUc: GroupUc) {} + + @ApiOperation({ summary: 'Get a list of classes and groups of type class for the current users school.' }) + @ApiResponse({ status: HttpStatus.OK, type: ClassInfoSearchListResponse }) + @ApiResponse({ status: '4XX', type: ErrorResponse }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + @Get('/class') + public async findClassesForSchool( + @Query() pagination: PaginationParams, + @Query() sortingQuery: ClassSortParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const board: Page = await this.groupUc.findAllClassesForSchool( + currentUser.userId, + currentUser.schoolId, + pagination.skip, + pagination.limit, + sortingQuery.sortBy, + sortingQuery.sortOrder + ); + + const response: ClassInfoSearchListResponse = GroupResponseMapper.mapToClassInfosToListResponse( + board, + pagination.skip, + pagination.limit + ); + + return response; + } +} diff --git a/apps/server/src/modules/group/controller/index.ts b/apps/server/src/modules/group/controller/index.ts new file mode 100644 index 00000000000..daf2953fe71 --- /dev/null +++ b/apps/server/src/modules/group/controller/index.ts @@ -0,0 +1 @@ +export * from './group.controller'; 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 new file mode 100644 index 00000000000..6fbb0c6dc65 --- /dev/null +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -0,0 +1,34 @@ +import { Page } from '@shared/domain'; +import { ClassInfoDto } from '../../uc/dto'; +import { ClassInfoResponse, ClassInfoSearchListResponse } from '../dto'; + +export class GroupResponseMapper { + static mapToClassInfosToListResponse( + classInfos: Page, + skip?: number, + limit?: number + ): ClassInfoSearchListResponse { + const mappedData: ClassInfoResponse[] = classInfos.data.map((classInfo) => + this.mapToClassInfoToResponse(classInfo) + ); + + const response: ClassInfoSearchListResponse = new ClassInfoSearchListResponse( + mappedData, + classInfos.total, + skip, + limit + ); + + return response; + } + + private static mapToClassInfoToResponse(classInfo: ClassInfoDto): ClassInfoResponse { + const mapped = new ClassInfoResponse({ + name: classInfo.name, + externalSourceName: classInfo.externalSourceName, + teachers: classInfo.teachers, + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/group/controller/mapper/index.ts b/apps/server/src/modules/group/controller/mapper/index.ts new file mode 100644 index 00000000000..8dbc461623b --- /dev/null +++ b/apps/server/src/modules/group/controller/mapper/index.ts @@ -0,0 +1 @@ +export * from './group-response.mapper'; diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 8ebd8b7ab04..cbc5a416ffe 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -21,4 +21,20 @@ export interface GroupProps extends AuthorizableObject { organizationId?: string; } -export class Group extends DomainObject {} +export class Group extends DomainObject { + get name(): string { + return this.props.name; + } + + get users(): GroupUser[] { + return this.props.users; + } + + get externalSource(): ExternalSource | undefined { + return this.props.externalSource; + } + + get organizationId(): string | undefined { + return this.props.organizationId; + } +} diff --git a/apps/server/src/modules/group/group-api.module.ts b/apps/server/src/modules/group/group-api.module.ts index 1be422855a9..913fb2ef903 100644 --- a/apps/server/src/modules/group/group-api.module.ts +++ b/apps/server/src/modules/group/group-api.module.ts @@ -1,7 +1,17 @@ import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '@src/modules/authorization'; +import { ClassModule } from '@src/modules/class'; +import { RoleModule } from '@src/modules/role'; +import { LegacySchoolModule } from '@src/modules/legacy-school'; +import { SystemModule } from '@src/modules/system'; +import { UserModule } from '@src/modules/user'; +import { GroupController } from './controller'; import { GroupModule } from './group.module'; +import { GroupUc } from './uc'; @Module({ - imports: [GroupModule], + imports: [GroupModule, ClassModule, UserModule, RoleModule, LegacySchoolModule, AuthorizationModule, SystemModule], + controllers: [GroupController], + providers: [GroupUc], }) export class GroupApiModule {} diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index a7c7454dae4..6b7c9daf741 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,10 +1,10 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalSource } from '@shared/domain'; +import { ExternalSource, SchoolEntity } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, groupEntityFactory, groupFactory } from '@shared/testing'; +import { cleanupCollections, groupEntityFactory, groupFactory, schoolFactory } from '@shared/testing'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; -import { GroupEntity } from '../entity'; +import { GroupEntity, GroupEntityTypes } from '../entity'; import { GroupRepo } from './group.repo'; describe('GroupRepo', () => { @@ -82,6 +82,70 @@ describe('GroupRepo', () => { }); }); + describe('findClassesForSchool', () => { + describe('when groups of type class for the school exist', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + type: GroupEntityTypes.CLASS, + organization: school, + }); + + const otherSchool: SchoolEntity = schoolFactory.buildWithId(); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { + type: GroupEntityTypes.CLASS, + organization: otherSchool, + }); + + await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups]); + em.clear(); + + return { + school, + otherSchool, + groups, + }; + }; + + it('should return the group', async () => { + const { school, groups } = await setup(); + + const result: Group[] = await repo.findClassesForSchool(school.id); + + expect(result).toHaveLength(groups.length); + }); + + it('should not return groups from another school', async () => { + const { school, otherSchool } = await setup(); + + const result: Group[] = await repo.findClassesForSchool(school.id); + + expect(result.map((group) => group.organizationId)).not.toContain(otherSchool.id); + }); + }); + + describe('when no group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + + await em.persistAndFlush(school); + em.clear(); + + return { + school, + }; + }; + + it('should return an empty array', async () => { + const { school } = await setup(); + + const result: Group[] = await repo.findClassesForSchool(school.id); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('save', () => { describe('when a new object is provided', () => { const setup = () => { diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index a5477908d6c..2c920b9a39d 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -2,14 +2,14 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { Group, GroupProps } from '../domain'; -import { GroupEntity, GroupEntityProps } from '../entity'; +import { GroupEntity, GroupEntityProps, GroupEntityTypes } from '../entity'; import { GroupDomainMapper } from './group-domain.mapper'; @Injectable() export class GroupRepo { constructor(private readonly em: EntityManager) {} - async findById(id: EntityId): Promise { + public async findById(id: EntityId): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id }); if (!entity) { @@ -23,7 +23,7 @@ export class GroupRepo { return domainObject; } - async findByExternalSource(externalId: string, systemId: EntityId): Promise { + public async findByExternalSource(externalId: string, systemId: EntityId): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { externalSource: { externalId, @@ -42,7 +42,22 @@ export class GroupRepo { return domainObject; } - async save(domainObject: Group): Promise { + public async findClassesForSchool(schoolId: EntityId): Promise { + const entities: GroupEntity[] = await this.em.find(GroupEntity, { + type: GroupEntityTypes.CLASS, + organization: schoolId, + }); + + const domainObjects = entities.map((entity) => { + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + return new Group(props); + }); + + return domainObjects; + } + + public async save(domainObject: Group): Promise { const entityProps: GroupEntityProps = GroupDomainMapper.mapDomainObjectToEntityProperties(domainObject, this.em); const newEntity: GroupEntity = new GroupEntity(entityProps); @@ -67,7 +82,7 @@ export class GroupRepo { return savedDomainObject; } - async delete(domainObject: Group): Promise { + public async delete(domainObject: Group): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id: domainObject.id }); if (!entity) { diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 3bcc8fa287e..71cc9eaeb6a 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { groupFactory } from '@shared/testing'; @@ -119,6 +120,38 @@ describe('GroupService', () => { }); }); + describe('findClassesForSchool', () => { + describe('when the school has groups of type class', () => { + const setup = () => { + const schoolId: string = new ObjectId().toHexString(); + const groups: Group[] = groupFactory.buildList(3); + + groupRepo.findClassesForSchool.mockResolvedValue(groups); + + return { + schoolId, + groups, + }; + }; + + it('should call the repo', async () => { + const { schoolId } = setup(); + + await service.findClassesForSchool(schoolId); + + expect(groupRepo.findClassesForSchool).toHaveBeenCalledWith(schoolId); + }); + + it('should return the groups', async () => { + const { schoolId, groups } = setup(); + + const result: Group[] = await service.findClassesForSchool(schoolId); + + expect(result).toEqual(groups); + }); + }); + }); + describe('save', () => { describe('when saving a group', () => { const setup = () => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index 030f3eb6685..dcba9377de3 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -9,7 +9,7 @@ import { GroupRepo } from '../repo'; export class GroupService implements AuthorizationLoaderServiceGeneric { constructor(private readonly groupRepo: GroupRepo) {} - async findById(id: EntityId): Promise { + public async findById(id: EntityId): Promise { const group: Group | null = await this.groupRepo.findById(id); if (!group) { @@ -19,25 +19,31 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } - async findByExternalSource(externalId: string, systemId: EntityId): Promise { + public async tryFindById(id: EntityId): Promise { + const group: Group | null = await this.groupRepo.findById(id); + + return group; + } + + public async findByExternalSource(externalId: string, systemId: EntityId): Promise { const group: Group | null = await this.groupRepo.findByExternalSource(externalId, systemId); return group; } - async tryFindById(id: EntityId): Promise { - const group: Group | null = await this.groupRepo.findById(id); + public async findClassesForSchool(schoolId: EntityId): Promise { + const group: Group[] = await this.groupRepo.findClassesForSchool(schoolId); return group; } - async save(group: Group): Promise { + public async save(group: Group): Promise { const savedGroup: Group = await this.groupRepo.save(group); return savedGroup; } - async delete(group: Group): Promise { + public async delete(group: Group): Promise { await this.groupRepo.delete(group); } } diff --git a/apps/server/src/modules/group/uc/dto/class-info.dto.ts b/apps/server/src/modules/group/uc/dto/class-info.dto.ts new file mode 100644 index 00000000000..0d2b5adaf68 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/class-info.dto.ts @@ -0,0 +1,13 @@ +export class ClassInfoDto { + name: string; + + externalSourceName?: string; + + teachers: string[]; + + constructor(props: ClassInfoDto) { + this.name = props.name; + this.externalSourceName = props.externalSourceName; + this.teachers = props.teachers; + } +} diff --git a/apps/server/src/modules/group/uc/dto/index.ts b/apps/server/src/modules/group/uc/dto/index.ts new file mode 100644 index 00000000000..389a31da162 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/index.ts @@ -0,0 +1,2 @@ +export * from './class-info.dto'; +export * from './resolved-group-user'; diff --git a/apps/server/src/modules/group/uc/dto/resolved-group-user.ts b/apps/server/src/modules/group/uc/dto/resolved-group-user.ts new file mode 100644 index 00000000000..862abdba594 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/resolved-group-user.ts @@ -0,0 +1,13 @@ +import { UserDO } from '@shared/domain'; +import { RoleDto } from '@src/modules/role/service/dto/role.dto'; + +export class ResolvedGroupUser { + user: UserDO; + + role: RoleDto; + + constructor(props: ResolvedGroupUser) { + this.user = props.user; + this.role = props.role; + } +} diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts new file mode 100644 index 00000000000..b4115d3739b --- /dev/null +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -0,0 +1,309 @@ +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 { LegacySchoolDo, Page, Permission, SortOrder, User, UserDO } from '@shared/domain'; +import { + groupFactory, + legacySchoolDoFactory, + roleDtoFactory, + setupEntities, + UserAndAccountTestFactory, + userDoFactory, + userFactory, +} from '@shared/testing'; +import { Action, AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; +import { ClassService } from '@src/modules/class'; +import { Class } from '@src/modules/class/domain'; +import { classFactory } from '@src/modules/class/domain/testing/factory/class.factory'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +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 { GroupService } from '../service'; +import { ClassInfoDto } from './dto'; +import { GroupUc } from './group.uc'; + +describe('GroupUc', () => { + let module: TestingModule; + let uc: GroupUc; + + let groupService: DeepMocked; + let classService: DeepMocked; + let systemService: DeepMocked; + let userService: DeepMocked; + let roleService: DeepMocked; + let schoolService: DeepMocked; + let authorizationService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + GroupUc, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + { + provide: SystemService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(GroupUc); + groupService = module.get(GroupService); + classService = module.get(ClassService); + systemService = module.get(SystemService); + userService = module.get(UserService); + roleService = module.get(RoleService); + schoolService = module.get(LegacySchoolService); + authorizationService = module.get(AuthorizationService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findClassesForSchool', () => { + describe('when the user has no permission', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + error, + }; + }; + + it('should throw forbidden', async () => { + const { user, error } = setup(); + + const func = () => uc.findAllClassesForSchool(user.id, user.school.id); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when the school has classes', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.buildWithId({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.buildWithId({ + id: studentUser.id, + lastName: studentUser.lastName, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + const clazz: Class = classFactory.build({ name: 'A', teacherIds: [teacherUser.id], source: 'LDAP' }); + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], + externalSource: undefined, + }); + const groupWithSystem: Group = groupFactory.build({ + name: 'C', + externalSource: { externalId: 'externalId', systemId: system.id }, + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + classService.findClassesForSchool.mockResolvedValueOnce([clazz]); + groupService.findClassesForSchool.mockResolvedValueOnce([group, groupWithSystem]); + systemService.findById.mockResolvedValue(system); + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + if (roleId === studentUser.roles[0].id) { + return Promise.resolve(studentRole); + } + + throw new Error(); + }); + + return { + teacherUser, + school, + clazz, + group, + groupWithSystem, + system, + }; + }; + + it('should check the CLASS_LIST permission', async () => { + const { teacherUser, school } = setup(); + + await uc.findAllClassesForSchool(teacherUser.id, teacherUser.school.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, LegacySchoolDo, AuthorizationContext]>( + teacherUser, + school, + { + action: Action.read, + requiredPermissions: [Permission.CLASS_LIST], + } + ); + }); + + describe('when no pagination is given', () => { + it('should return all classes sorted by name', async () => { + const { teacherUser, clazz, group, groupWithSystem, system } = setup(); + + const result: Page = await uc.findAllClassesForSchool(teacherUser.id, teacherUser.school.id); + + expect(result).toEqual>({ + data: [ + { + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + externalSourceName: clazz.source, + teachers: [teacherUser.lastName], + }, + { + name: group.name, + teachers: [teacherUser.lastName], + }, + { + name: groupWithSystem.name, + externalSourceName: system.displayName, + teachers: [teacherUser.lastName], + }, + ], + total: 3, + }); + }); + }); + + describe('when sorting by external source name in descending order', () => { + it('should return all classes sorted by external source name in descending order', async () => { + const { teacherUser, clazz, group, groupWithSystem, system } = setup(); + + const result: Page = await uc.findAllClassesForSchool( + teacherUser.id, + teacherUser.school.id, + undefined, + undefined, + 'externalSourceName', + SortOrder.desc + ); + + expect(result).toEqual>({ + data: [ + { + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + externalSourceName: clazz.source, + teachers: [teacherUser.lastName], + }, + { + name: groupWithSystem.name, + externalSourceName: system.displayName, + teachers: [teacherUser.lastName], + }, + { + name: group.name, + teachers: [teacherUser.lastName], + }, + ], + total: 3, + }); + }); + }); + + describe('when using pagination', () => { + it('should return the selected page', async () => { + const { teacherUser, group } = setup(); + + const result: Page = await uc.findAllClassesForSchool( + teacherUser.id, + teacherUser.school.id, + 1, + 1, + 'name', + SortOrder.asc + ); + + expect(result).toEqual>({ + data: [ + { + name: group.name, + teachers: [teacherUser.lastName], + }, + ], + total: 3, + }); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts new file mode 100644 index 00000000000..a179b8cb352 --- /dev/null +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId, LegacySchoolDo, Page, Permission, SortOrder, User, UserDO } from '@shared/domain'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { ClassService } from '@src/modules/class'; +import { Class } from '@src/modules/class/domain'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +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, GroupUser } from '../domain'; +import { GroupService } from '../service'; +import { SortHelper } from '../util'; +import { ClassInfoDto, ResolvedGroupUser } from './dto'; +import { GroupUcMapper } from './mapper/group-uc.mapper'; + +@Injectable() +export class GroupUc { + constructor( + private readonly groupService: GroupService, + private readonly classService: ClassService, + private readonly systemService: SystemService, + private readonly userService: UserService, + private readonly roleService: RoleService, + private readonly schoolService: LegacySchoolService, + private readonly authorizationService: AuthorizationService + ) {} + + public async findAllClassesForSchool( + userId: EntityId, + schoolId: EntityId, + skip = 0, + limit?: number, + sortBy: keyof ClassInfoDto = 'name', + sortOrder: SortOrder = SortOrder.asc + ): Promise> { + const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, school, AuthorizationContextBuilder.read([Permission.CLASS_LIST])); + + const combinedClassInfo: ClassInfoDto[] = await this.findCombinedClassListForSchool(schoolId); + + combinedClassInfo.sort((a: ClassInfoDto, b: ClassInfoDto): number => + SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) + ); + + const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); + + const page: Page = new Page(pageContent, combinedClassInfo.length); + + return page; + } + + private async findCombinedClassListForSchool(schoolId: string): Promise { + const [classInfosFromClasses, classInfosFromGroups] = await Promise.all([ + await this.findClassesForSchool(schoolId), + await this.findGroupsOfTypeClassForSchool(schoolId), + ]); + + const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; + + return combinedClassInfo; + } + + private async findClassesForSchool(schoolId: EntityId): Promise { + const classes: Class[] = await this.classService.findClassesForSchool(schoolId); + + const classInfosFromClasses: ClassInfoDto[] = await Promise.all( + classes.map(async (clazz: Class): Promise => { + const teachers: UserDO[] = await Promise.all( + clazz.teacherIds.map((teacherId: EntityId) => this.userService.findById(teacherId)) + ); + + const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto(clazz, teachers); + + return mapped; + }) + ); + + return classInfosFromClasses; + } + + private async findGroupsOfTypeClassForSchool(schoolId: EntityId): Promise { + const groupsOfTypeClass: Group[] = await this.groupService.findClassesForSchool(schoolId); + + const systemMap: Map = await this.findSystemNamesForGroups(groupsOfTypeClass); + + const classInfosFromGroups: ClassInfoDto[] = await Promise.all( + groupsOfTypeClass.map(async (group: Group): Promise => { + let system: SystemDto | undefined; + if (group.externalSource) { + system = systemMap.get(group.externalSource.systemId); + } + + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + + const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto(group, resolvedUsers, system); + + return mapped; + }) + ); + + return classInfosFromGroups; + } + + private async findSystemNamesForGroups(groups: Group[]): Promise> { + const systemIds: EntityId[] = groups + .map((group: Group): string | undefined => group.externalSource?.systemId) + .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); + + const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); + + const systems: Map = new Map(); + + await Promise.all( + uniqueSystemIds.map(async (systemId: string) => { + const system: SystemDto = await this.systemService.findById(systemId); + + systems.set(systemId, system); + }) + ); + + return systems; + } + + private async findUsersForGroup(group: Group): Promise { + const resolvedGroupUsers: ResolvedGroupUser[] = await Promise.all( + group.users.map(async (groupUser: GroupUser): Promise => { + const user: UserDO = await this.userService.findById(groupUser.userId); + const role: RoleDto = await this.roleService.findById(groupUser.roleId); + + const resolvedGroups = new ResolvedGroupUser({ + user, + role, + }); + + return resolvedGroups; + }) + ); + + return resolvedGroupUsers; + } + + private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined) { + const page: ClassInfoDto[] = combinedClassInfo.slice(skip, limit ? skip + limit : combinedClassInfo.length); + + return page; + } +} diff --git a/apps/server/src/modules/group/uc/index.ts b/apps/server/src/modules/group/uc/index.ts new file mode 100644 index 00000000000..3f268fdf74c --- /dev/null +++ b/apps/server/src/modules/group/uc/index.ts @@ -0,0 +1 @@ +export * from './group.uc'; 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 new file mode 100644 index 00000000000..1e1f11057ce --- /dev/null +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -0,0 +1,35 @@ +import { RoleName, 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'; + +export class GroupUcMapper { + public static mapGroupToClassInfoDto( + group: Group, + resolvedUsers: ResolvedGroupUser[], + system?: SystemDto + ): ClassInfoDto { + const mapped: ClassInfoDto = new ClassInfoDto({ + name: group.name, + externalSourceName: system?.displayName, + teachers: resolvedUsers + .filter((groupUser: ResolvedGroupUser) => groupUser.role.name === RoleName.TEACHER) + .map((groupUser: ResolvedGroupUser) => groupUser.user.lastName), + }); + + return mapped; + } + + public static mapClassToClassInfoDto(clazz: Class, teachers: UserDO[]): ClassInfoDto { + const name = clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name; + + const mapped: ClassInfoDto = new ClassInfoDto({ + name, + externalSourceName: clazz.source, + teachers: teachers.map((user: UserDO) => user.lastName), + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/group/util/index.ts b/apps/server/src/modules/group/util/index.ts new file mode 100644 index 00000000000..f8413f66d6e --- /dev/null +++ b/apps/server/src/modules/group/util/index.ts @@ -0,0 +1 @@ +export * from './sort-helper'; diff --git a/apps/server/src/modules/group/util/sort-helper.spec.ts b/apps/server/src/modules/group/util/sort-helper.spec.ts new file mode 100644 index 00000000000..f4c738a3c65 --- /dev/null +++ b/apps/server/src/modules/group/util/sort-helper.spec.ts @@ -0,0 +1,70 @@ +import { SortOrder } from '@shared/domain'; +import { SortHelper } from './sort-helper'; + +describe('SortHelper', () => { + describe('genericSortFunction', () => { + describe('when a is defined and b is undefined', () => { + it('should return more than 0', () => { + const result: number = SortHelper.genericSortFunction(1, undefined, SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when a is undefined and b is defined', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(undefined, 1, SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when a and b are both undefined', () => { + it('should return 0', () => { + const result: number = SortHelper.genericSortFunction(undefined, undefined, SortOrder.asc); + + expect(result).toEqual(0); + }); + }); + + describe('when a is a greater number than b', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction(2, 1, SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when b is a greater number than a', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(1, 2, SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when a is later in the alphabet as b', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction('B', 'A', SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when b is later in the alphabet as a', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction('A', 'B', SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when a is greater than b, but the order is reversed', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(2, 1, SortOrder.desc); + + expect(result).toBeLessThan(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/util/sort-helper.ts b/apps/server/src/modules/group/util/sort-helper.ts new file mode 100644 index 00000000000..099c726403e --- /dev/null +++ b/apps/server/src/modules/group/util/sort-helper.ts @@ -0,0 +1,21 @@ +import { SortOrder } from '@shared/domain'; + +export class SortHelper { + public static genericSortFunction(a: T, b: T, sortOrder: SortOrder): number { + let order: number; + + if (typeof a !== 'undefined' && typeof b === 'undefined') { + order = 1; + } else if (typeof a === 'undefined' && typeof b !== 'undefined') { + order = -1; + } else if (typeof a === 'string' && typeof b === 'string') { + order = a.localeCompare(b); + } else if (typeof a === 'number' && typeof b === 'number') { + order = a - b; + } else { + order = 0; + } + + return sortOrder === SortOrder.desc ? -order : order; + } +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index d199bfce8e6..902e2e850f2 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -166,7 +166,7 @@ describe('SanisResponseMapper', () => { expect(result![0]).toEqual({ name: group.gruppe.bezeichnung, type: GroupTypes.CLASS, - externalOrganizationId: group.gruppe.orgid, + externalOrganizationId: personenkontext.organisation.id, from: group.gruppe.laufzeit.von, until: group.gruppe.laufzeit.bis, externalId: group.gruppe.id, diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 3a0d340427e..34a7ff4029c 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -92,15 +92,17 @@ export class SanisResponseMapper { .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) .filter((user): user is ExternalGroupUserDto => user !== null); - return { + const externalOrganizationId = source.personenkontexte[0].organisation?.id; + + return new ExternalGroupDto({ name: group.gruppe.bezeichnung, type: groupType, - externalOrganizationId: group.gruppe.orgid, + externalOrganizationId, from: group.gruppe.laufzeit?.von, until: group.gruppe.laufzeit?.bis, externalId: group.gruppe.id, users: gruppenzugehoerigkeiten, - }; + }); }) .filter((group): group is ExternalGroupDto => group !== null); diff --git a/apps/server/src/shared/testing/factory/user.factory.ts b/apps/server/src/shared/testing/factory/user.factory.ts index b79d4c739ff..1557b3ccd35 100644 --- a/apps/server/src/shared/testing/factory/user.factory.ts +++ b/apps/server/src/shared/testing/factory/user.factory.ts @@ -22,7 +22,7 @@ class UserFactory extends BaseFactory { asStudent(additionalPermissions: Permission[] = []): this { const permissions = _.union(userPermissions, studentPermissions, additionalPermissions); - const role = roleFactory.buildWithId({ permissions }); + const role = roleFactory.buildWithId({ permissions, name: RoleName.STUDENT }); const params: DeepPartial = { roles: [role] }; @@ -31,7 +31,7 @@ class UserFactory extends BaseFactory { asTeacher(additionalPermissions: Permission[] = []): this { const permissions = _.union(userPermissions, teacherPermissions, additionalPermissions); - const role = roleFactory.buildWithId({ permissions }); + const role = roleFactory.buildWithId({ permissions, name: RoleName.TEACHER }); const params: DeepPartial = { roles: [role] }; @@ -40,7 +40,7 @@ class UserFactory extends BaseFactory { asAdmin(additionalPermissions: Permission[] = []): this { const permissions = _.union(userPermissions, adminPermissions, additionalPermissions); - const role = roleFactory.buildWithId({ permissions }); + const role = roleFactory.buildWithId({ permissions, name: RoleName.ADMINISTRATOR }); const params: DeepPartial = { roles: [role] }; diff --git a/config/default.schema.json b/config/default.schema.json index d986b4ffdf9..9103cfd7d4f 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1288,6 +1288,11 @@ } } }, + "FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the new class list view" + }, "TSP_SCHOOL_SYNCER": { "type": "object", "description": "TSP School Syncer properties", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index b6452220088..31a3ae22224 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -62,6 +62,7 @@ const exposedVars = [ 'FEATURE_SHOW_OUTDATED_USERS', 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', + 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', ]; /** From f5036206d8f7c520a5375e4b4b11a1718cadd5e4 Mon Sep 17 00:00:00 2001 From: Phillip Date: Thu, 28 Sep 2023 15:27:37 +0200 Subject: [PATCH 13/34] removed unused file (#4445) --- deploy/compose-hydra | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 deploy/compose-hydra diff --git a/deploy/compose-hydra b/deploy/compose-hydra deleted file mode 100644 index 72f0b61c444..00000000000 --- a/deploy/compose-hydra +++ /dev/null @@ -1,2 +0,0 @@ - command: ["migrate", "sql", "--yes", "$DSN"] - container_name: hydra-host From 6fee998065567637c93e470b42c6cb60ba999c6d Mon Sep 17 00:00:00 2001 From: VikDavydiuk <117301540+VikDavydiuk@users.noreply.github.com> Date: Mon, 2 Oct 2023 11:01:21 +0200 Subject: [PATCH 14/34] BC-4992-Upload school logo (#4425) * BC-4992-Upload school logo * Delete logo_nam --------- Co-authored-by: Viktoriia <1> --- apps/server/src/modules/management/seed-data/schools.ts | 4 ++++ backup/setup/schools.json | 2 ++ src/services/school/hooks/index.js | 2 +- src/services/school/model.js | 1 + test/services/helpers/services/schools.js | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/modules/management/seed-data/schools.ts b/apps/server/src/modules/management/seed-data/schools.ts index a4d9e538594..ba09c0d6952 100644 --- a/apps/server/src/modules/management/seed-data/schools.ts +++ b/apps/server/src/modules/management/seed-data/schools.ts @@ -34,6 +34,7 @@ type SeedSchoolProperties = Omit timezone?: string; language?: string; logo_dataUrl?: string; + logo_name?: string; enableStudentTeamCreation?: boolean; }; @@ -180,6 +181,7 @@ const seedSchools: SeedSchoolProperties[] = [ pilot: false, language: 'de', logo_dataUrl: '', + logo_name: '', officialSchoolNumber: '', }, { @@ -204,6 +206,7 @@ const seedSchools: SeedSchoolProperties[] = [ timezone: 'America/Belem', language: 'en', logo_dataUrl: '', + logo_name: '', officialSchoolNumber: '', }, { @@ -311,6 +314,7 @@ export function generateSchools(entities: { schoolEntity['timezone'] = partial.timezone; schoolEntity['language'] = partial.language; schoolEntity['logo_dataUrl'] = partial.logo_dataUrl; + schoolEntity['logo_name'] = partial.logo_name; schoolEntity['enableStudentTeamCreation'] = partial.enableStudentTeamCreation; return schoolEntity; diff --git a/backup/setup/schools.json b/backup/setup/schools.json index 9078df81bfb..ecbfd82b4c6 100644 --- a/backup/setup/schools.json +++ b/backup/setup/schools.json @@ -230,6 +230,7 @@ }, "language": "de", "logo_dataUrl": "", + "logo_name": "", "officialSchoolNumber": "" }, { @@ -271,6 +272,7 @@ }, "language": "en", "logo_dataUrl": "", + "logo_name": "", "officialSchoolNumber": "" }, { diff --git a/src/services/school/hooks/index.js b/src/services/school/hooks/index.js index 98024706fa0..d2904cf33c4 100644 --- a/src/services/school/hooks/index.js +++ b/src/services/school/hooks/index.js @@ -172,7 +172,7 @@ const hasEditPermissions = async (context) => { if ( (user.permissions.includes('SCHOOL_CHAT_MANAGE') && updatesChat(key, context.data)) || (user.permissions.includes('SCHOOL_STUDENT_TEAM_MANAGE') && updatesTeamCreation(key, context.data)) || - (user.permissions.includes('SCHOOL_LOGO_MANAGE') && key === 'logo_dataUrl') + (user.permissions.includes('SCHOOL_LOGO_MANAGE') && (key === 'logo_dataUrl' || key === 'logo_name')) ) { patch[key] = context.data[key]; } diff --git a/src/services/school/model.js b/src/services/school/model.js index 51f886baea2..0ec931e4191 100644 --- a/src/services/school/model.js +++ b/src/services/school/model.js @@ -77,6 +77,7 @@ const schoolSchema = new Schema( currentYear: { type: Schema.Types.ObjectId, ref: 'year' }, customYears: [{ type: customYearSchema }], logo_dataUrl: { type: String }, + logo_name: { type: String }, purpose: { type: String }, rssFeeds: [{ type: rssFeedSchema }], language: { type: String }, diff --git a/test/services/helpers/services/schools.js b/test/services/helpers/services/schools.js index d44753f6821..cabd6055947 100644 --- a/test/services/helpers/services/schools.js +++ b/test/services/helpers/services/schools.js @@ -22,6 +22,7 @@ const create = documentBaseDirType, // eslint-disable-next-line camelcase logo_dataUrl, + logo_name, purpose = 'test', rssFeeds = [], features = [], @@ -56,6 +57,7 @@ const create = currentYear, // eslint-disable-next-line camelcase logo_dataUrl, + logo_name, purpose, rssFeeds, features, From 13f4d7a9af60ecafc96d4ebf0a5651b7ba89767b Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Mon, 2 Oct 2023 15:12:41 +0200 Subject: [PATCH 15/34] BC-5102 - use type guards (#4450) --- .../service/submission-item.service.spec.ts | 14 ++++++-- .../board/service/submission-item.service.ts | 14 +++++--- .../server/src/modules/board/uc/element.uc.ts | 27 +++++++++----- .../board/uc/submission-item.uc.spec.ts | 24 +------------ .../modules/board/uc/submission-item.uc.ts | 35 ++++++------------- .../domainobject/board/submission-item.do.ts | 4 +++ 6 files changed, 54 insertions(+), 64 deletions(-) diff --git a/apps/server/src/modules/board/service/submission-item.service.spec.ts b/apps/server/src/modules/board/service/submission-item.service.spec.ts index 0dd2c9efbf9..fef2bf00ae6 100644 --- a/apps/server/src/modules/board/service/submission-item.service.spec.ts +++ b/apps/server/src/modules/board/service/submission-item.service.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { NotFoundException } from '@nestjs/common'; +import { NotFoundException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SubmissionItem } from '@shared/domain'; import { ValidationError } from '@shared/common'; -import { setupEntities, userFactory } from '@shared/testing'; +import { richTextElementFactory, setupEntities, userFactory } from '@shared/testing'; import { cardFactory, submissionContainerElementFactory, @@ -108,7 +108,7 @@ describe(SubmissionItemService.name, () => { return { submissionContainer, submissionItem }; }; - it('should fetch the SubmissionContainer parent', async () => { + it('should fetch the parent', async () => { const { submissionItem } = setup(); await service.update(submissionItem, true); @@ -116,6 +116,14 @@ describe(SubmissionItemService.name, () => { expect(boardDoRepo.findParentOfId).toHaveBeenCalledWith(submissionItem.id); }); + it('should throw if parent is not SubmissionContainerElement', async () => { + const submissionItem = submissionItemFactory.build(); + const richTextElement = richTextElementFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValueOnce(richTextElement); + + await expect(service.update(submissionItem, true)).rejects.toThrow(UnprocessableEntityException); + }); + it('should call bord repo to save submission item', async () => { const { submissionItem, submissionContainer } = setup(); diff --git a/apps/server/src/modules/board/service/submission-item.service.ts b/apps/server/src/modules/board/service/submission-item.service.ts index 990504357a7..0fc67ba694f 100644 --- a/apps/server/src/modules/board/service/submission-item.service.ts +++ b/apps/server/src/modules/board/service/submission-item.service.ts @@ -1,7 +1,7 @@ import { ObjectId } from 'bson'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, SubmissionContainerElement, SubmissionItem } from '@shared/domain'; +import { EntityId, isSubmissionContainerElement, SubmissionContainerElement, SubmissionItem } from '@shared/domain'; import { ValidationError } from '@shared/common'; import { BoardDoRepo } from '../repo'; @@ -42,13 +42,17 @@ export class SubmissionItemService { } async update(submissionItem: SubmissionItem, completed: boolean): Promise { - const parent = (await this.boardDoRepo.findParentOfId(submissionItem.id)) as SubmissionContainerElement; + const submissionContainterElement = await this.boardDoRepo.findParentOfId(submissionItem.id); + if (!isSubmissionContainerElement(submissionContainterElement)) { + throw new UnprocessableEntityException(); + } + const now = new Date(); - if (parent.dueDate && parent.dueDate < now) { + if (submissionContainterElement.dueDate && submissionContainterElement.dueDate < now) { throw new ValidationError('not allowed to save anymore'); } submissionItem.completed = completed; - await this.boardDoRepo.save(submissionItem, parent); + await this.boardDoRepo.save(submissionItem, submissionContainterElement); } } diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index 7289fdb6ce2..0dafd9eb98f 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -1,5 +1,12 @@ import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { AnyBoardDo, EntityId, SubmissionContainerElement, SubmissionItem, UserRoleEnum } from '@shared/domain'; +import { + AnyBoardDo, + EntityId, + isSubmissionContainerElement, + isSubmissionItem, + SubmissionItem, + UserRoleEnum, +} from '@shared/domain'; import { Logger } from '@src/core/logger'; import { AuthorizationService } from '@src/modules/authorization'; import { Action } from '@src/modules/authorization/types/action.enum'; @@ -33,23 +40,25 @@ export class ElementUc { contentElementId: EntityId, completed: boolean ): Promise { - const submissionContainer = (await this.elementService.findById(contentElementId)) as SubmissionContainerElement; + const submissionContainerElement = await this.elementService.findById(contentElementId); - if (!(submissionContainer instanceof SubmissionContainerElement)) + if (!isSubmissionContainerElement(submissionContainerElement)) { throw new HttpException( 'Cannot create submission-item for non submission-container-element', HttpStatus.UNPROCESSABLE_ENTITY ); + } - if (!submissionContainer.children.every((child) => child instanceof SubmissionItem)) + if (!submissionContainerElement.children.every((child) => isSubmissionItem(child))) { throw new HttpException( 'Children of submission-container-element must be of type submission-item', HttpStatus.UNPROCESSABLE_ENTITY ); + } - const userSubmissionExists = submissionContainer.children.find( - (item) => (item as SubmissionItem).userId === userId - ); + const userSubmissionExists = submissionContainerElement.children + .filter(isSubmissionItem) + .find((item) => item.userId === userId); if (userSubmissionExists) { throw new HttpException( 'User is not allowed to have multiple submission-items per submission-container-element', @@ -57,9 +66,9 @@ export class ElementUc { ); } - await this.checkPermission(userId, submissionContainer, Action.read, UserRoleEnum.STUDENT); + await this.checkPermission(userId, submissionContainerElement, Action.read, UserRoleEnum.STUDENT); - const submissionItem = await this.submissionItemService.create(userId, submissionContainer, { completed }); + const submissionItem = await this.submissionItemService.create(userId, submissionContainerElement, { completed }); return submissionItem; } diff --git a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts index 7177201a5af..33bc8468fc9 100644 --- a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts +++ b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts @@ -194,29 +194,7 @@ describe(SubmissionItemUc.name, () => { it('should throw HttpException', async () => { const { teacher, fileEl } = setup(); - await expect(uc.findSubmissionItems(teacher.id, fileEl.id)).rejects.toThrow( - 'Id does not belong to a submission container' - ); - }); - }); - describe('when called with invalid submission container children', () => { - const setup = () => { - const teacher = userFactory.buildWithId(); - const fileEl = fileElementFactory.build(); - const submissionContainer = submissionContainerElementFactory.build({ - children: [fileEl], - }); - elementService.findById.mockResolvedValue(submissionContainer); - - return { teacher, submissionContainer }; - }; - - it('should throw HttpException', async () => { - const { teacher, submissionContainer } = setup(); - - await expect(uc.findSubmissionItems(teacher.id, submissionContainer.id)).rejects.toThrow( - 'Children of submission-container-element must be of type submission-item' - ); + await expect(uc.findSubmissionItems(teacher.id, fileEl.id)).rejects.toThrow('Id is not submission container'); }); }); }); diff --git a/apps/server/src/modules/board/uc/submission-item.uc.ts b/apps/server/src/modules/board/uc/submission-item.uc.ts index 6a17373818c..e59afa4b49b 100644 --- a/apps/server/src/modules/board/uc/submission-item.uc.ts +++ b/apps/server/src/modules/board/uc/submission-item.uc.ts @@ -2,7 +2,8 @@ import { ForbiddenException, forwardRef, HttpException, HttpStatus, Inject, Inje import { AnyBoardDo, EntityId, - SubmissionContainerElement, + isSubmissionContainerElement, + isSubmissionItem, SubmissionItem, UserBoardRoles, UserRoleEnum, @@ -29,22 +30,20 @@ export class SubmissionItemUc { userId: EntityId, submissionContainerId: EntityId ): Promise<{ submissionItems: SubmissionItem[]; users: UserBoardRoles[] }> { - const submissionContainer = await this.getSubmissionContainer(submissionContainerId); - await this.checkPermission(userId, submissionContainer, Action.read); + const submissionContainerElement = await this.elementService.findById(submissionContainerId); - let submissionItems = submissionContainer.children as SubmissionItem[]; - - if (!submissionItems.every((child) => child instanceof SubmissionItem)) { - throw new HttpException( - 'Children of submission-container-element must be of type submission-item', - HttpStatus.UNPROCESSABLE_ENTITY - ); + if (!isSubmissionContainerElement(submissionContainerElement)) { + throw new HttpException('Id is not submission container', HttpStatus.UNPROCESSABLE_ENTITY); } - const boardAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(submissionContainer); + await this.checkPermission(userId, submissionContainerElement, Action.read); + + let submissionItems = submissionContainerElement.children.filter(isSubmissionItem); + + const boardAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(submissionContainerElement); let users = boardAuthorizable.users.filter((user) => user.userRoleEnum === UserRoleEnum.STUDENT); - const isAuthorizedStudent = await this.isAuthorizedStudent(userId, submissionContainer); + const isAuthorizedStudent = await this.isAuthorizedStudent(userId, submissionContainerElement); if (isAuthorizedStudent) { submissionItems = submissionItems.filter((item) => item.userId === userId); users = []; @@ -86,18 +85,6 @@ export class SubmissionItemUc { return false; } - private async getSubmissionContainer(submissionContainerId: EntityId): Promise { - const submissionContainer = (await this.elementService.findById( - submissionContainerId - )) as SubmissionContainerElement; - - if (!(submissionContainer instanceof SubmissionContainerElement)) { - throw new HttpException('Id does not belong to a submission container', HttpStatus.UNPROCESSABLE_ENTITY); - } - - return submissionContainer; - } - private async checkPermission( userId: EntityId, boardDo: AnyBoardDo, diff --git a/apps/server/src/shared/domain/domainobject/board/submission-item.do.ts b/apps/server/src/shared/domain/domainobject/board/submission-item.do.ts index c21c2d60af4..cb072f37455 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-item.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-item.do.ts @@ -38,3 +38,7 @@ export interface SubmissionItemProps extends BoardCompositeProps { completed: boolean; userId: EntityId; } + +export function isSubmissionItem(reference: unknown): reference is SubmissionItem { + return reference instanceof SubmissionItem; +} From 6f7f09cc8f637552c42ca49b2b53891c6c830740 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Fri, 6 Oct 2023 07:45:32 +0200 Subject: [PATCH 16/34] N21 1309 fix empty class sync (#4443) * handle empty, null and undefined lists * change scope of unique members assertion to fix current classes --- .../strategies/consumerActions/ClassAction.js | 14 ++-- .../sync/repo/user.repo.integration.test.js | 72 +++++++++++++------ .../consumerActions/ClassAction.test.js | 48 +++++++++++++ 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/src/services/sync/strategies/consumerActions/ClassAction.js b/src/services/sync/strategies/consumerActions/ClassAction.js index 3bf1b1329f6..c688600375e 100644 --- a/src/services/sync/strategies/consumerActions/ClassAction.js +++ b/src/services/sync/strategies/consumerActions/ClassAction.js @@ -90,14 +90,16 @@ class ClassAction extends BaseConsumerAction { const teachers = []; const ldapDns = !Array.isArray(uniqueMembers) ? [uniqueMembers] : uniqueMembers; - const users = await UserRepo.findByLdapDnsAndSchool(ldapDns, schoolId); + if (ldapDns[0]) { + const users = await UserRepo.findByLdapDnsAndSchool(ldapDns, schoolId); - users.forEach((user) => { - user.roles.forEach((role) => { - if (role.name === 'student') students.push(user._id); - if (role.name === 'teacher') teachers.push(user._id); + users.forEach((user) => { + user.roles.forEach((role) => { + if (role.name === 'student') students.push(user._id); + if (role.name === 'teacher') teachers.push(user._id); + }); }); - }); + } await ClassRepo.updateClassStudents(classId, students); await ClassRepo.updateClassTeachers(classId, teachers); diff --git a/test/services/sync/repo/user.repo.integration.test.js b/test/services/sync/repo/user.repo.integration.test.js index eecb30e9e3c..199a114de6a 100644 --- a/test/services/sync/repo/user.repo.integration.test.js +++ b/test/services/sync/repo/user.repo.integration.test.js @@ -188,45 +188,71 @@ describe('user repo', () => { }); describe('findByLdapDnsAndSchool', () => { - it('should return empty list if not found', async () => { - const testSchool = await testObjects.createTestSchool(); - const res = await UserRepo.findByLdapDnsAndSchool('Not existed dn', testSchool._id); + const setup = async () => { + const ldapDn = 'TEST_LDAP_DN'; + const ldapDn2 = 'TEST_LDAP_DN2'; + const previousLdapDn = 'PREVIOUS_LDAP_DN'; + const notExistingLdapDn = 'NOT_EXISTING_LDAP_DN'; + const ldapDns = [ldapDn, ldapDn2]; + + const school = await testObjects.createTestSchool(); + + const migratedUser = await testObjects.createTestUser({ + previousExternalId: previousLdapDn, + schoolId: school._id, + ldapDn: 'NEW_ID', + }); + const createdUsers = [ + await testObjects.createTestUser({ ldapDn, schoolId: school._id }), + await testObjects.createTestUser({ ldapDn2, schoolId: school._id }), + ]; + + return { + ldapDns, + notExistingLdapDn, + previousLdapDn, + migratedUser, + createdUsers, + school, + }; + }; + + it('should return empty list if user with ldapDn does not exist', async () => { + const { school, notExistingLdapDn } = await setup(); + + const res = await UserRepo.findByLdapDnsAndSchool([notExistingLdapDn], school._id); + + expect(res).to.eql([]); + }); + + it('should return empty list if ldapDns are empty', async () => { + const { school } = await setup(); + + const res = await UserRepo.findByLdapDnsAndSchool([], school._id); + expect(res).to.eql([]); }); it('should find user by ldap dn and school', async () => { - const ldapDns = ['TEST_LDAP_DN', 'TEST_LDAP_DN2']; - const school = await testObjects.createTestSchool(); - const createdUsers = await Promise.all( - ldapDns.map((ldapDn) => testObjects.createTestUser({ ldapDn, schoolId: school._id })) - ); + const { school, ldapDns, createdUsers } = await setup(); + const res = await UserRepo.findByLdapDnsAndSchool(ldapDns, school._id); + const user1 = res.filter((user) => createdUsers[0]._id.toString() === user._id.toString()); const user2 = res.filter((user) => createdUsers[1]._id.toString() === user._id.toString()); + expect(user1).not.to.be.undefined; expect(user2).not.to.be.undefined; }); describe('when the user has migrated', () => { - const setup = async () => { - const ldapDn = 'TEST_LDAP_DN'; - const school = await testObjects.createTestSchool(); - const user = await testObjects.createTestUser({ previousExternalId: ldapDn, schoolId: school._id }); - - return { - ldapDn, - user, - school, - }; - }; - it('should find the user by its old ldap dn and school', async () => { - const { ldapDn, school, user } = await setup(); + const { previousLdapDn, school, migratedUser } = await setup(); - const res = await UserRepo.findByLdapDnsAndSchool([ldapDn], school._id); + const res = await UserRepo.findByLdapDnsAndSchool([previousLdapDn], school._id); expect(res.length).to.equal(1); - expect(res[0]._id.toString()).to.equal(user._id.toString()); + expect(res[0]._id.toString()).to.equal(migratedUser._id.toString()); }); }); }); diff --git a/test/services/sync/strategies/consumerActions/ClassAction.test.js b/test/services/sync/strategies/consumerActions/ClassAction.test.js index 4b450df5622..efbd48da37b 100644 --- a/test/services/sync/strategies/consumerActions/ClassAction.test.js +++ b/test/services/sync/strategies/consumerActions/ClassAction.test.js @@ -342,5 +342,53 @@ describe('Class Actions', () => { expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); expect(updateClassTeachersStub.getCall(0).lastArg).to.eql(['user2', 'user3']); }); + + it('should not add any user to the class, when uniqueMembers are []', async () => { + const uniqueMembers = []; + const schoolObj = { _id: new ObjectId(), currentYear: new ObjectId() }; + const findByLdapDnsAndSchoolStub = sinon.stub(UserRepo, 'findByLdapDnsAndSchool'); + + await classAction.addUsersToClass(schoolObj._id, mockClass._id, uniqueMembers); + + expect(findByLdapDnsAndSchoolStub.notCalled).to.be.true; + + expect(updateClassStudentsStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassStudentsStub.getCall(0).lastArg).to.eql([]); + + expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassTeachersStub.getCall(0).lastArg).to.eql([]); + }); + + it('should not add any user to the class, when uniqueMembers are [undefined]', async () => { + const uniqueMembers = [undefined]; + const schoolObj = { _id: new ObjectId(), currentYear: new ObjectId() }; + const findByLdapDnsAndSchoolStub = sinon.stub(UserRepo, 'findByLdapDnsAndSchool'); + + await classAction.addUsersToClass(schoolObj._id, mockClass._id, uniqueMembers); + + expect(findByLdapDnsAndSchoolStub.notCalled).to.be.true; + + expect(updateClassStudentsStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassStudentsStub.getCall(0).lastArg).to.eql([]); + + expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassTeachersStub.getCall(0).lastArg).to.eql([]); + }); + + it('should not add any user to the class, when uniqueMembers are [null]', async () => { + const uniqueMembers = [null]; + const schoolObj = { _id: new ObjectId(), currentYear: new ObjectId() }; + const findByLdapDnsAndSchoolStub = sinon.stub(UserRepo, 'findByLdapDnsAndSchool'); + + await classAction.addUsersToClass(schoolObj._id, mockClass._id, uniqueMembers); + + expect(findByLdapDnsAndSchoolStub.notCalled).to.be.true; + + expect(updateClassStudentsStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassStudentsStub.getCall(0).lastArg).to.eql([]); + + expect(updateClassTeachersStub.getCall(0).firstArg.toString()).to.be.equal(mockClass._id.toString()); + expect(updateClassTeachersStub.getCall(0).lastArg).to.eql([]); + }); }); }); From d3a0975f93081e0b9346bd6b045e6352b3911144 Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Fri, 6 Oct 2023 09:27:28 +0200 Subject: [PATCH 17/34] N21 1212 remove user from group (#4454) * remove user from group: * remove emptx groups --- .../src/modules/group/domain/group.spec.ts | 138 ++++++++++++ apps/server/src/modules/group/domain/group.ts | 10 +- .../src/modules/group/repo/group.repo.spec.ts | 68 +++++- .../src/modules/group/repo/group.repo.ts | 17 +- .../group/service/group.service.spec.ts | 47 +++- .../modules/group/service/group.service.ts | 8 +- .../strategy/oidc/oidc.strategy.spec.ts | 28 ++- .../strategy/oidc/oidc.strategy.ts | 6 +- .../service/oidc-provisioning.service.spec.ts | 202 +++++++++++++++++- .../oidc/service/oidc-provisioning.service.ts | 40 ++++ .../not-found.loggable-exception.ts | 3 +- 11 files changed, 553 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/modules/group/domain/group.spec.ts diff --git a/apps/server/src/modules/group/domain/group.spec.ts b/apps/server/src/modules/group/domain/group.spec.ts new file mode 100644 index 00000000000..b5ae8a03321 --- /dev/null +++ b/apps/server/src/modules/group/domain/group.spec.ts @@ -0,0 +1,138 @@ +import { groupFactory, roleFactory, userDoFactory } from '@shared/testing'; + +import { ObjectId } from 'bson'; +import { RoleReference, UserDO } from '@shared/domain'; +import { Group } from './group'; +import { GroupUser } from './group-user'; + +describe('Group (Domain Object)', () => { + describe('removeUser', () => { + describe('when the user is in the group', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groupUser1 = new GroupUser({ + userId: user.id as string, + roleId: new ObjectId().toHexString(), + }); + const groupUser2 = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group: Group = groupFactory.build({ + users: [groupUser1, groupUser2], + }); + + return { + user, + groupUser1, + groupUser2, + group, + }; + }; + + it('should remove the user', () => { + const { user, group, groupUser1 } = setup(); + + group.removeUser(user); + + expect(group.users).not.toContain(groupUser1); + }); + + it('should keep all other users', () => { + const { user, group, groupUser2 } = setup(); + + group.removeUser(user); + + expect(group.users).toContain(groupUser2); + }); + }); + + describe('when the user is not in the group', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groupUser2 = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group: Group = groupFactory.build({ + users: [groupUser2], + }); + + return { + user, + groupUser2, + group, + }; + }; + + it('should do nothing', () => { + const { user, group, groupUser2 } = setup(); + + group.removeUser(user); + + expect(group.users).toEqual([groupUser2]); + }); + }); + + describe('when the group is empty', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const group: Group = groupFactory.build({ users: [] }); + + return { + user, + group, + }; + }; + + it('should stay empty', () => { + const { user, group } = setup(); + + group.removeUser(user); + + expect(group.users).toEqual([]); + }); + }); + }); + + describe('isEmpty', () => { + describe('when no users in group exist', () => { + const setup = () => { + const group: Group = groupFactory.build({ users: [] }); + + return { + group, + }; + }; + + it('should return true', () => { + const { group } = setup(); + + const isEmpty = group.isEmpty(); + + expect(isEmpty).toEqual(true); + }); + }); + + describe('when users in group exist', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + const group: Group = groupFactory.build({ users: [{ userId: user.id as string, roleId: role.id }] }); + + return { + group, + }; + }; + + it('should return false', () => { + const { group } = setup(); + + const isEmpty = group.isEmpty(); + + expect(isEmpty).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index cbc5a416ffe..049043618ac 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -1,4 +1,4 @@ -import { EntityId, ExternalSource } from '@shared/domain'; +import { EntityId, ExternalSource, type UserDO } from '@shared/domain'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { GroupTypes } from './group-types'; import { GroupUser } from './group-user'; @@ -37,4 +37,12 @@ export class Group extends DomainObject { get organizationId(): string | undefined { return this.props.organizationId; } + + removeUser(user: UserDO): void { + this.props.users = this.props.users.filter((groupUser: GroupUser): boolean => groupUser.userId !== user.id); + } + + isEmpty(): boolean { + return this.props.users.length === 0; + } } diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index 6b7c9daf741..358b3c13983 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,8 +1,16 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalSource, SchoolEntity } from '@shared/domain'; +import { ExternalSource, SchoolEntity, UserDO, User } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, groupEntityFactory, groupFactory, schoolFactory } from '@shared/testing'; +import { + cleanupCollections, + groupEntityFactory, + groupFactory, + roleFactory, + schoolFactory, + userDoFactory, + userFactory, +} from '@shared/testing'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; import { GroupEntity, GroupEntityTypes } from '../entity'; import { GroupRepo } from './group.repo'; @@ -82,6 +90,62 @@ describe('GroupRepo', () => { }); }); + describe('findByUser', () => { + describe('when the user has groups', () => { + const setup = async () => { + const userEntity: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.build({ id: userEntity.id }); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + users: [{ user: userEntity, role: roleFactory.buildWithId() }], + }); + + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...groups, ...otherGroups]); + em.clear(); + + return { + user, + groups, + }; + }; + + it('should return the groups', async () => { + const { user, groups } = await setup(); + + const result: Group[] = await repo.findByUser(user); + + expect(result.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( + groups.map((group) => group.id).sort((a, b) => a.localeCompare(b)) + ); + }); + }); + + describe('when the user has no groups exists', () => { + const setup = async () => { + const userEntity: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.build({ id: userEntity.id }); + + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...otherGroups]); + em.clear(); + + return { + user, + }; + }; + + it('should return an empty array', async () => { + const { user } = await setup(); + + const result: Group[] = await repo.findByUser(user); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('findClassesForSchool', () => { describe('when groups of type class for the school exist', () => { const setup = async () => { diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index 2c920b9a39d..920647ee7e8 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -1,6 +1,7 @@ -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; +import { type UserDO } from '@shared/domain'; import { Group, GroupProps } from '../domain'; import { GroupEntity, GroupEntityProps, GroupEntityTypes } from '../entity'; import { GroupDomainMapper } from './group-domain.mapper'; @@ -42,6 +43,20 @@ export class GroupRepo { return domainObject; } + public async findByUser(user: UserDO): Promise { + const entities: GroupEntity[] = await this.em.find(GroupEntity, { + users: { user: new ObjectId(user.id) }, + }); + + const domainObjects = entities.map((entity) => { + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + return new Group(props); + }); + + return domainObjects; + } + public async findClassesForSchool(schoolId: EntityId): Promise { const entities: GroupEntity[] = await this.em.find(GroupEntity, { type: GroupEntityTypes.CLASS, diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 71cc9eaeb6a..19c66f266ee 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -2,7 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { groupFactory } from '@shared/testing'; +import { groupFactory, userDoFactory } from '@shared/testing'; +import { UserDO } from '@shared/domain'; import { Group } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -120,6 +121,50 @@ describe('GroupService', () => { }); }); + describe('findByUser', () => { + describe('when groups with the user exists', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groups: Group[] = groupFactory.buildList(2); + + groupRepo.findByUser.mockResolvedValue(groups); + + return { + user, + groups, + }; + }; + + it('should return the groups', async () => { + const { user, groups } = setup(); + + const result: Group[] = await service.findByUser(user); + + expect(result).toEqual(groups); + }); + }); + + describe('when no groups with the user exists', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + groupRepo.findByUser.mockResolvedValue([]); + + return { + user, + }; + }; + + it('should return empty array', async () => { + const { user } = setup(); + + const result: Group[] = await service.findByUser(user); + + expect(result).toEqual([]); + }); + }); + }); + describe('findClassesForSchool', () => { describe('when the school has groups of type class', () => { const setup = () => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index dcba9377de3..f3ce6a287e8 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { EntityId } from '@shared/domain'; +import { EntityId, type UserDO } from '@shared/domain'; import { AuthorizationLoaderServiceGeneric } from '@src/modules/authorization'; import { Group } from '../domain'; import { GroupRepo } from '../repo'; @@ -31,6 +31,12 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } + public async findByUser(user: UserDO): Promise { + const groups: Group[] = await this.groupRepo.findByUser(user); + + return groups; + } + public async findClassesForSchool(schoolId: EntityId): Promise { const group: Group[] = await this.groupRepo.findClassesForSchool(schoolId); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts index 80bdfdf4c88..2b838159524 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts @@ -98,7 +98,7 @@ describe('OidcStrategy', () => { }; }; - it('should call the OidcProvisioningService.provisionExternalSchool', async () => { + it('should provision school', async () => { const { oauthData } = setup(); await strategy.apply(oauthData); @@ -150,7 +150,7 @@ describe('OidcStrategy', () => { }; }; - it('should call the OidcProvisioningService.provisionExternalUser', async () => { + it('should provision external user', async () => { const { oauthData, schoolId } = setup(); await strategy.apply(oauthData); @@ -198,7 +198,19 @@ describe('OidcStrategy', () => { }; }; - it('should call the OidcProvisioningService.provisionExternalGroup for each group', async () => { + it('should remove external groups and affiliation', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).toHaveBeenCalledWith( + oauthData.externalUser.externalId, + oauthData.externalGroups, + oauthData.system.systemId + ); + }); + + it('should provision every external group', async () => { const { oauthData } = setup(); await strategy.apply(oauthData); @@ -241,7 +253,15 @@ describe('OidcStrategy', () => { }; }; - it('should not call the OidcProvisioningService.provisionExternalGroup', async () => { + it('should not remove external groups and affiliation', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).not.toHaveBeenCalled(); + }); + + it('should not provision groups', async () => { const { oauthData } = setup(); await strategy.apply(oauthData); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts index f51fc49abed..7804f2190f9 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -24,7 +24,11 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { ); if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') && data.externalGroups) { - // TODO: N21-1212 remove user from groups + await this.oidcProvisioningService.removeExternalGroupsAndAffiliation( + data.externalUser.externalId, + data.externalGroups, + data.system.systemId + ); await Promise.all( data.externalGroups.map((externalGroup) => diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts index 1084f21bbf4..c4b27cac34b 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, RoleName, SchoolFeatures } from '@shared/domain'; +import { ExternalSource, LegacySchoolDo, RoleName, RoleReference, SchoolFeatures } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { externalGroupDtoFactory, @@ -11,6 +11,7 @@ import { legacySchoolDoFactory, schoolYearFactory, userDoFactory, + roleFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; import { AccountService } from '@src/modules/account/services/account.service'; @@ -21,6 +22,7 @@ import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { FederalStateService, LegacySchoolService, SchoolYearService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import CryptoJS from 'crypto-js'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; import { OidcProvisioningService } from './oidc-provisioning.service'; @@ -342,6 +344,204 @@ describe('OidcProvisioningService', () => { }); describe('provisionExternalGroup', () => { + describe('when group membership of user has not changed', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + + const existingGroups: Group[] = groupFactory.buildList(2, { + users: [{ userId: user.id as string, roleId: role.id }], + }); + + const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[0].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const secondExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[1].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const externalGroups: ExternalGroupDto[] = [firstExternalGroup, secondExternalGroup]; + + userService.findByExternalId.mockResolvedValue(user); + groupService.findByUser.mockResolvedValue(existingGroups); + + return { + externalGroups, + systemId, + externalUserId, + }; + }; + + it('should not save the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + + it('should not delete the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.delete).not.toHaveBeenCalled(); + }); + }); + + describe('when user is not part of a group anymore', () => { + describe('when group is empty after removal of the User', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + + const firstExistingGroup: Group = groupFactory.build({ + users: [{ userId: user.id as string, roleId: role.id }], + externalSource: new ExternalSource({ + externalId: 'externalId-1', + systemId, + }), + }); + const secondExistingGroup: Group = groupFactory.build({ + users: [{ userId: user.id as string, roleId: role.id }], + externalSource: new ExternalSource({ + externalId: 'externalId-2', + systemId, + }), + }); + const existingGroups = [firstExistingGroup, secondExistingGroup]; + + const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[0].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; + + userService.findByExternalId.mockResolvedValue(user); + groupService.findByUser.mockResolvedValue(existingGroups); + + return { + externalGroups, + systemId, + externalUserId, + existingGroups, + }; + }; + + it('should delete the group', async () => { + const { externalGroups, systemId, externalUserId, existingGroups } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.delete).toHaveBeenCalledWith(existingGroups[1]); + }); + + it('should not save the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + }); + + describe('when group is not empty after removal of the User', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const anotherExternalUserId = 'anotherExternalUserId'; + const role: RoleReference = roleFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: externalUserId }); + const anotherUser: UserDO = userDoFactory.buildWithId({ roles: [role], externalId: anotherExternalUserId }); + + const firstExistingGroup: Group = groupFactory.build({ + users: [ + { userId: user.id as string, roleId: role.id }, + { userId: anotherUser.id as string, roleId: role.id }, + ], + externalSource: new ExternalSource({ + externalId: `externalId-1`, + systemId, + }), + }); + + const secondExistingGroup: Group = groupFactory.build({ + users: [ + { userId: user.id as string, roleId: role.id }, + { userId: anotherUser.id as string, roleId: role.id }, + ], + externalSource: new ExternalSource({ + externalId: `externalId-2`, + systemId, + }), + }); + + const existingGroups: Group[] = [firstExistingGroup, secondExistingGroup]; + + const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + externalId: existingGroups[0].externalSource?.externalId, + users: [{ externalUserId, roleName: role.name }], + }); + const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; + + userService.findByExternalId.mockResolvedValue(user); + groupService.findByUser.mockResolvedValue(existingGroups); + + return { + externalGroups, + systemId, + externalUserId, + existingGroups, + }; + }; + + it('should save the group', async () => { + const { externalGroups, systemId, externalUserId, existingGroups } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.save).toHaveBeenCalledWith(existingGroups[1]); + }); + + it('should not delete the group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + await service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + expect(groupService.delete).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when user could not be found', () => { + const setup = () => { + const systemId = 'systemId'; + const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = [externalGroupDtoFactory.build()]; + + userService.findByExternalId.mockResolvedValue(null); + + return { + systemId, + externalUserId, + externalGroups, + }; + }; + + it('should throw NotFoundLoggableException', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + const func = async () => service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); + + await expect(func).rejects.toThrow(new NotFoundLoggableException('User', 'externalId', externalUserId)); + }); + }); + describe('when the group has no users', () => { const setup = () => { const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ users: [] }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts index 1a16b8578c9..0aef3fdecb9 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts @@ -12,6 +12,7 @@ import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; import CryptoJS from 'crypto-js'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; @@ -185,4 +186,43 @@ export class OidcProvisioningService { return filteredUsers; } + + async removeExternalGroupsAndAffiliation( + externalUserId: EntityId, + externalGroups: ExternalGroupDto[], + systemId: EntityId + ): Promise { + const user: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); + + if (!user) { + throw new NotFoundLoggableException(UserDO.name, 'externalId', externalUserId); + } + + const existingGroupsOfUser: Group[] = await this.groupService.findByUser(user); + + const groupsFromSystem: Group[] = existingGroupsOfUser.filter( + (existingGroup: Group) => existingGroup.externalSource?.systemId === systemId + ); + + const groupsWithoutUser: Group[] = groupsFromSystem.filter((existingGroupFromSystem: Group) => { + const isUserInGroup = externalGroups.some( + (externalGroup: ExternalGroupDto) => + externalGroup.externalId === existingGroupFromSystem.externalSource?.externalId + ); + + return !isUserInGroup; + }); + + await Promise.all( + groupsWithoutUser.map(async (group: Group) => { + group.removeUser(user); + + if (group.isEmpty()) { + await this.groupService.delete(group); + } else { + await this.groupService.save(group); + } + }) + ); + } } diff --git a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts index 261f4161a30..4ffd8e5b70e 100644 --- a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts +++ b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts @@ -1,5 +1,4 @@ import { NotFoundException } from '@nestjs/common'; -import { EntityId } from '@shared/domain'; import { Loggable } from '@src/core/logger/interfaces'; import { ErrorLogMessage } from '@src/core/logger/types'; @@ -7,7 +6,7 @@ export class NotFoundLoggableException extends NotFoundException implements Logg constructor( private readonly resourceName: string, private readonly identifierName: string, - private readonly resourceId: EntityId + private readonly resourceId: string ) { super(); } From c66e1221953a1adf65db328a876ee9610f1bc56a Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Fri, 6 Oct 2023 10:41:23 +0200 Subject: [PATCH 18/34] BC-5461 - Fixed check for undefined props by file permissions (#4453) --- src/services/fileStorage/proxy-service.js | 14 +++++++------- .../fileStorage/utils/filePermissionHelper.js | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/services/fileStorage/proxy-service.js b/src/services/fileStorage/proxy-service.js index 81a798be5c5..a33f261e540 100644 --- a/src/services/fileStorage/proxy-service.js +++ b/src/services/fileStorage/proxy-service.js @@ -422,10 +422,10 @@ const signedUrlService = { throw new NotFound('File seems not to be there.'); } - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release const creatorId = fileObject.creator || - (fileObject.permissions[0].refPermModel !== 'user' ? userId : fileObject.permissions[0].refId); + (fileObject.permissions[0]?.refPermModel !== 'user' ? userId : fileObject.permissions[0]?.refId); if (download && fileObject.securityCheck && fileObject.securityCheck.status === SecurityCheckStatusTypes.BLOCKED) { throw new Forbidden('File access blocked by security check.'); @@ -456,11 +456,11 @@ const signedUrlService = { throw new NotFound('File seems not to be there.'); } - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release const creatorId = - fileObject.creator || fileObject.permissions[0].refPermModel !== 'user' + fileObject.creator || fileObject.permissions[0]?.refPermModel !== 'user' ? userId - : fileObject.permissions[0].refId; + : fileObject.permissions[0]?.refId; return canRead(userId, id) .then(() => @@ -899,8 +899,8 @@ const filePermissionService = { const { refOwnerModel, owner } = fileObj; const rolePermissions = fileObj.permissions.filter(({ refPermModel }) => refPermModel === 'role'); const rolePromises = rolePermissions.map(({ refId }) => RoleModel.findOne({ _id: refId }).lean().exec()); - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release - const isFileCreator = equalIds(fileObj.creator || fileObj.permissions[0].refId, userId); + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release + const isFileCreator = equalIds(fileObj.creator || fileObj.permissions[0]?.refId, userId); const actionMap = { user: () => { diff --git a/src/services/fileStorage/utils/filePermissionHelper.js b/src/services/fileStorage/utils/filePermissionHelper.js index 5993fa25cec..332409445ff 100644 --- a/src/services/fileStorage/utils/filePermissionHelper.js +++ b/src/services/fileStorage/utils/filePermissionHelper.js @@ -34,9 +34,9 @@ const checkTeamPermission = async ({ user, file, permission }) => { rolesToTest = rolesToTest.concat(roleIndex[roleId].roles || []); } - // deprecated: author check via file.permissions[0].refId is deprecated and will be removed in the next release + // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release const { role: creatorRole } = file.owner.userIds.find((_) => - equalIds(_.userId, file.creator || file.permissions[0].refId) + equalIds(_.userId, file.creator || file.permissions[0]?.refId) ); const findRole = (roleId) => (roles) => roles.findIndex((r) => equalIds(r._id, roleId)) > -1; From a4364ecd01056abea5cdae1832f4293f32fa285c Mon Sep 17 00:00:00 2001 From: Majed Mak <132336669+MajedAlaitwniCap@users.noreply.github.com> Date: Fri, 6 Oct 2023 12:12:15 +0200 Subject: [PATCH 19/34] EW-619 DataPort review testing part (#4442) * EW-619 modified test structure related to Data Port Review --- .../controller/account.controller.spec.ts | 24 - .../account/controller/account.controller.ts | 4 +- .../controller/api-test/account.api.spec.ts | 775 +++- .../controller/dto/password-pattern.ts | 1 - .../account-entity-to-dto.mapper.spec.ts | 139 +- .../mapper/account-entity-to-dto.mapper.ts | 2 + .../account-idm-to-dto.mapper.db.spec.ts | 62 +- .../account-idm-to-dto.mapper.idm.spec.ts | 75 +- .../mapper/account-response.mapper.spec.ts | 81 +- .../account/mapper/account-response.mapper.ts | 1 + .../repo/account.repo.integration.spec.ts | 412 +- .../src/modules/account/repo/account.repo.ts | 5 + .../src/modules/account/review-comments.md | 12 + .../services/account-db.service.spec.ts | 995 ++-- .../account/services/account-db.service.ts | 21 +- .../account-idm.service.integration.spec.ts | 172 +- .../services/account-idm.service.spec.ts | 315 +- .../account/services/account-idm.service.ts | 9 +- .../services/account.service.abstract.ts | 3 + .../account.service.integration.spec.ts | 201 +- .../account/services/account.service.spec.ts | 710 +-- .../account/services/account.service.ts | 10 +- .../account.validation.service.spec.ts | 586 ++- .../services/account.validation.service.ts | 6 +- .../account/services/dto/account.dto.ts | 1 + .../src/modules/account/uc/account.uc.spec.ts | 4048 ++++++++++++----- .../src/modules/account/uc/account.uc.ts | 18 +- .../shared/testing/factory/account.factory.ts | 32 +- 28 files changed, 5962 insertions(+), 2758 deletions(-) delete mode 100644 apps/server/src/modules/account/controller/account.controller.spec.ts create mode 100644 apps/server/src/modules/account/review-comments.md diff --git a/apps/server/src/modules/account/controller/account.controller.spec.ts b/apps/server/src/modules/account/controller/account.controller.spec.ts deleted file mode 100644 index ef15672edc5..00000000000 --- a/apps/server/src/modules/account/controller/account.controller.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AccountController } from './account.controller'; -import { AccountUc } from '../uc/account.uc'; - -describe('account.controller', () => { - let module: TestingModule; - let controller: AccountController; - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - AccountController, - { - provide: AccountUc, - useValue: {}, - }, - ], - }).compile(); - controller = module.get(AccountController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/server/src/modules/account/controller/account.controller.ts b/apps/server/src/modules/account/controller/account.controller.ts index 2256cb9fc90..23c07326d82 100644 --- a/apps/server/src/modules/account/controller/account.controller.ts +++ b/apps/server/src/modules/account/controller/account.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; import { ICurrentUser } from '@src/modules/authentication'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { AccountUc } from '../uc/account.uc'; import { AccountByIdBodyParams, @@ -33,6 +33,8 @@ export class AccountController { @Query() query: AccountSearchQueryParams ): Promise { return this.accountUc.searchAccounts(currentUser, query); + + // TODO: mapping from domain to api dto should be a responsability of the controller (also every other function here) } @Get(':id') diff --git a/apps/server/src/modules/account/controller/api-test/account.api.spec.ts b/apps/server/src/modules/account/controller/api-test/account.api.spec.ts index fefdb006bf8..9e7dd1a7d18 100644 --- a/apps/server/src/modules/account/controller/api-test/account.api.spec.ts +++ b/apps/server/src/modules/account/controller/api-test/account.api.spec.ts @@ -1,9 +1,15 @@ -import { EntityManager } from '@mikro-orm/core'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, Permission, RoleName, User } from '@shared/domain'; -import { ICurrentUser } from '@src/modules/authentication'; -import { accountFactory, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + accountFactory, + roleFactory, + schoolFactory, + userFactory, + TestApiClient, + cleanupCollections, +} from '@shared/testing'; import { AccountByIdBodyParams, AccountSearchQueryParams, @@ -11,305 +17,626 @@ import { PatchMyAccountParams, PatchMyPasswordParams, } from '@src/modules/account/controller/dto'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@src/modules/server/server.module'; -import { Request } from 'express'; -import request from 'supertest'; describe('Account Controller (API)', () => { const basePath = '/account'; let app: INestApplication; let em: EntityManager; - - let adminAccount: Account; - let teacherAccount: Account; - let studentAccount: Account; - let superheroAccount: Account; - - let adminUser: User; - let teacherUser: User; - let studentUser: User; - let superheroUser: User; - - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; const defaultPassword = 'DummyPasswd!1'; const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; - const setup = async () => { - const school = schoolFactory.buildWithId(); - - const adminRoles = roleFactory.build({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + const mapUserToAccount = (user: User): Account => + accountFactory.buildWithId({ + userId: user.id, + username: user.email, + password: defaultPasswordHash, }); - const teacherRoles = roleFactory.build({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] }); - const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); - const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); - - adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); - teacherUser = userFactory.buildWithId({ school, roles: [teacherRoles] }); - studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); - superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); - - const mapUserToAccount = (user: User): Account => - accountFactory.buildWithId({ - userId: user.id, - username: user.email, - password: defaultPasswordHash, - }); - adminAccount = mapUserToAccount(adminUser); - teacherAccount = mapUserToAccount(teacherUser); - studentAccount = mapUserToAccount(studentUser); - superheroAccount = mapUserToAccount(superheroUser); - - em.persist(school); - em.persist([adminRoles, teacherRoles, studentRoles, superheroRoles]); - em.persist([adminUser, teacherUser, studentUser, superheroUser]); - em.persist([adminAccount, teacherAccount, studentAccount, superheroAccount]); - await em.flush(); - }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleFixture.createNestApplication(); await app.init(); em = app.get(EntityManager); + testApiClient = new TestApiClient(app, basePath); }); beforeEach(async () => { - await setup(); + await cleanupCollections(em); }); afterAll(async () => { - // await cleanupCollections(em); + await cleanupCollections(em); await app.close(); }); describe('[PATCH] me/password', () => { - it(`should update the current user's (temporary) password`, async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyPasswordParams = { - password: 'Valid12$', - confirmPassword: 'Valid12$', + describe('When patching with a valid password', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const passwordPatchParams: PatchMyPasswordParams = { + password: 'Valid12$', + confirmPassword: 'Valid12$', + }; + + return { passwordPatchParams, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me/password`) - .send(params) - .expect(200); - const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); - expect(updatedAccount.password).not.toEqual(defaultPasswordHash); + it(`should update the current user's (temporary) password`, async () => { + const { passwordPatchParams, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch('/me/password', passwordPatchParams).expect(200); + + const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); + expect(updatedAccount.password).not.toEqual(defaultPasswordHash); + }); }); - it('should reject if new password is weak', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyPasswordParams = { - password: 'weak', - confirmPassword: 'weak', + + describe('When using a weak password', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const passwordPatchParams: PatchMyPasswordParams = { + password: 'weak', + confirmPassword: 'weak', + }; + + return { passwordPatchParams, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me/password`) - .send(params) - .expect(400); + + it('should reject the password change', async () => { + const { passwordPatchParams, loggedInClient } = await setup(); + + await loggedInClient.patch('/me/password', passwordPatchParams).expect(400); + }); }); }); describe('[PATCH] me', () => { - it(`should update a users account`, async () => { - const newEmailValue = 'new@mail.com'; - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyAccountParams = { - passwordOld: defaultPassword, - email: newEmailValue, + describe('When patching the account with account info', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const newEmailValue = 'new@mail.com'; + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + email: newEmailValue, + }; + return { newEmailValue, patchMyAccountParams, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me`) - .send(params) - .expect(200); - const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); - expect(updatedAccount.username).toEqual(newEmailValue); + it(`should update a users account`, async () => { + const { newEmailValue, patchMyAccountParams, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(200); + + const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); + expect(updatedAccount.username).toEqual(newEmailValue); + }); }); - it('should reject if new email is not valid', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyAccountParams = { - passwordOld: defaultPassword, - email: 'invalid', + describe('When patching with a not valid email', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const newEmailValue = 'new@mail.com'; + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + email: 'invalid', + }; + return { newEmailValue, patchMyAccountParams, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me`) - .send(params) - .expect(400); + + it('should reject patch request', async () => { + const { patchMyAccountParams, loggedInClient } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(400); + }); }); }); describe('[GET]', () => { - it('should search for user id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USER_ID, - value: studentUser.id, - skip: 5, - limit: 5, + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USER_ID, + value: studentUser.id, + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should successfully search for user id', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); + // If skip is too big, just return an empty list. // We testing it here, because we are mocking the database in the use case unit tests // and for realistic behavior we need database. - it('should search for user id with large skip', async () => { - currentUser = mapUserToCurrentUser(superheroUser); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USER_ID, - value: studentUser.id, - skip: 50000, - limit: 5, + describe('When searching with a superhero user with large skip', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USER_ID, + value: studentUser.id, + skip: 50000, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should search for user id', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); - it('should search for user name', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USERNAME, - value: '', - skip: 5, - limit: 5, + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USERNAME, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should search for username', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); - it('should reject if type is unknown', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: '' as AccountSearchType, - value: '', - skip: 5, - limit: 5, + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: '' as AccountSearchType, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(400); + + it('should reject if type is unknown', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(400); + }); }); - it('should reject if user is not authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USERNAME, - value: '', - skip: 5, - limit: 5, + describe('When searching with an admin user (not authorized)', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USERNAME, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(403); + + it('should reject search for user', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(403); + }); }); }); describe('[GET] :id', () => { - it('should return account for account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/${studentAccount.id}`) - .expect(200); + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient, studentAccount }; + }; + it('should return account for account id', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.get(`/${studentAccount.id}`).expect(200); + }); }); - it('should reject if user is not a authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/${studentAccount.id}`) - .expect(403); + + describe('When searching with a not authorized user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, studentAccount }; + }; + it('should reject request', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.get(`/${studentAccount.id}`).expect(403); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/000000000000000000000000`) - .expect(404); + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist([school, superheroRoles, superheroUser, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient }; + }; + + it('should reject not existing account id', async () => { + const { loggedInClient } = await setup(); + await loggedInClient.get(`/000000000000000000000000`).expect(404); + }); }); }); describe('[PATCH] :id', () => { - it('should update account', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/${studentAccount.id}`) - .send(body) - .expect(200); + + it('should update account', async () => { + const { body, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch(`/${studentAccount.id}`, body).expect(200); + }); }); - it('should reject if user is not authorized', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + + describe('When the user is not authorized', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/${studentAccount.id}`) - .send(body) - .expect(403); + it('should reject update request', async () => { + const { body, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch(`/${studentAccount.id}`, body).expect(403); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + + describe('When updating with a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/000000000000000000000000`) - .send(body) - .expect(404); + it('should reject not existing account id', async () => { + const { body, loggedInClient } = await setup(); + await loggedInClient.patch('/000000000000000000000000', body).expect(404); + }); }); }); describe('[DELETE] :id', () => { - it('should delete account', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/${studentAccount.id}`) - .expect(200); + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient, studentAccount }; + }; + it('should delete account', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.delete(`/${studentAccount.id}`).expect(200); + }); }); - it('should reject if user is not a authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/${studentAccount.id}`) - .expect(403); + + describe('When using a not authorized (admin) user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, studentAccount }; + }; + + it('should reject delete request', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.delete(`/${studentAccount.id}`).expect(403); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/000000000000000000000000`) - .expect(404); + + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolFactory.buildWithId(); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist([school, superheroRoles, superheroUser, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient }; + }; + + it('should reject not existing account id', async () => { + const { loggedInClient } = await setup(); + await loggedInClient.delete('/000000000000000000000000').expect(404); + }); }); }); }); diff --git a/apps/server/src/modules/account/controller/dto/password-pattern.ts b/apps/server/src/modules/account/controller/dto/password-pattern.ts index 6ca2fd9fab2..d849b8db235 100644 --- a/apps/server/src/modules/account/controller/dto/password-pattern.ts +++ b/apps/server/src/modules/account/controller/dto/password-pattern.ts @@ -1,2 +1 @@ -// TODO Compare with client export const passwordPattern = /^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])(?=.*[-_!<>§$%&/()=?\\;:,.#+*~'])\S.{6,253}\S$/; diff --git a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts index 8e9522434eb..4de5040113f 100644 --- a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts +++ b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts @@ -1,5 +1,5 @@ import { Account } from '@shared/domain'; -import { ObjectId } from 'bson'; +import { accountFactory } from '@shared/testing'; import { AccountEntityToDtoMapper } from './account-entity-to-dto.mapper'; describe('AccountEntityToDtoMapper', () => { @@ -14,101 +14,80 @@ describe('AccountEntityToDtoMapper', () => { }); describe('mapToDto', () => { - it('should map all fields', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: 'id', - createdAt: new Date(), - updatedAt: new Date(), - userId: new ObjectId(), - username: 'username', - activated: true, - credentialHash: 'credentialHash', - expiresAt: new Date(), - lasttriedFailedLogin: new Date(), - password: 'password', - systemId: new ObjectId(), - token: 'token', - }; - const ret = AccountEntityToDtoMapper.mapToDto(testEntity); - - expect(ret.id).toBe(testEntity.id); - expect(ret.createdAt).toEqual(testEntity.createdAt); - expect(ret.updatedAt).toEqual(testEntity.createdAt); - expect(ret.userId).toBe(testEntity.userId?.toString()); - expect(ret.username).toBe(testEntity.username); - expect(ret.activated).toBe(testEntity.activated); - expect(ret.credentialHash).toBe(testEntity.credentialHash); - expect(ret.expiresAt).toBe(testEntity.expiresAt); - expect(ret.lasttriedFailedLogin).toBe(testEntity.lasttriedFailedLogin); - expect(ret.password).toBe(testEntity.password); - expect(ret.systemId).toBe(testEntity.systemId?.toString()); - expect(ret.token).toBe(testEntity.token); - }); + describe('When mapping AccountEntity to AccountDto', () => { + const setup = () => { + const accountEntity = accountFactory.withAllProperties().buildWithId({}, '000000000000000000000001'); + + const missingSystemUserIdEntity: Account = accountFactory.withoutSystemAndUserId().build(); - it('should ignore missing ids', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: 'id', - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), + return { accountEntity, missingSystemUserIdEntity }; }; - const ret = AccountEntityToDtoMapper.mapToDto(testEntity); - expect(ret.userId).toBeUndefined(); - expect(ret.systemId).toBeUndefined(); + it('should map all fields', () => { + const { accountEntity } = setup(); + + const ret = AccountEntityToDtoMapper.mapToDto(accountEntity); + + expect({ ...ret, _id: accountEntity._id }).toMatchObject(accountEntity); + }); + + it('should ignore missing ids', () => { + const { missingSystemUserIdEntity } = setup(); + + const ret = AccountEntityToDtoMapper.mapToDto(missingSystemUserIdEntity); + + expect(ret.userId).toBeUndefined(); + expect(ret.systemId).toBeUndefined(); + }); }); }); describe('mapSearchResult', () => { - it('should use actual date if date is', () => { - const testEntity1: Account = { - _id: new ObjectId(), - id: '1', - username: '1', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testEntity2: Account = { - _id: new ObjectId(), - id: '2', - username: '2', - createdAt: new Date(), - updatedAt: new Date(), + describe('When mapping multiple Account entities', () => { + const setup = () => { + const testEntity1: Account = accountFactory.buildWithId({}, '000000000000000000000001'); + const testEntity2: Account = accountFactory.buildWithId({}, '000000000000000000000002'); + + const testAmount = 10; + + const testEntities = [testEntity1, testEntity2]; + + return { testEntities, testAmount }; }; - const testAmount = 10; - const [accounts, total] = AccountEntityToDtoMapper.mapSearchResult([[testEntity1, testEntity2], testAmount]); + it('should map exact same amount of entities', () => { + const { testEntities, testAmount } = setup(); - expect(total).toBe(testAmount); - expect(accounts).toHaveLength(2); - expect(accounts).toContainEqual(expect.objectContaining({ id: '1' })); - expect(accounts).toContainEqual(expect.objectContaining({ id: '2' })); + const [accounts, total] = AccountEntityToDtoMapper.mapSearchResult([testEntities, testAmount]); + + expect(total).toBe(testAmount); + expect(accounts).toHaveLength(2); + expect(accounts).toContainEqual(expect.objectContaining({ id: '000000000000000000000001' })); + expect(accounts).toContainEqual(expect.objectContaining({ id: '000000000000000000000002' })); + }); }); }); describe('mapAccountsToDto', () => { - it('should use actual date if date is', () => { - const testEntity1: Account = { - _id: new ObjectId(), - username: '1', - id: '1', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testEntity2: Account = { - _id: new ObjectId(), - username: '2', - id: '2', - createdAt: new Date(), - updatedAt: new Date(), + describe('When mapping multiple Account entities', () => { + const setup = () => { + const testEntity1: Account = accountFactory.buildWithId({}, '000000000000000000000001'); + const testEntity2: Account = accountFactory.buildWithId({}, '000000000000000000000002'); + + const testEntities = [testEntity1, testEntity2]; + + return testEntities; }; - const ret = AccountEntityToDtoMapper.mapAccountsToDto([testEntity1, testEntity2]); - expect(ret).toHaveLength(2); - expect(ret).toContainEqual(expect.objectContaining({ id: '1' })); - expect(ret).toContainEqual(expect.objectContaining({ id: '2' })); + it('should map all entities', () => { + const testEntities = setup(); + + const ret = AccountEntityToDtoMapper.mapAccountsToDto(testEntities); + + expect(ret).toHaveLength(2); + expect(ret).toContainEqual(expect.objectContaining({ id: '000000000000000000000001' })); + expect(ret).toContainEqual(expect.objectContaining({ id: '000000000000000000000002' })); + }); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts index 417497b3218..d8af59e6716 100644 --- a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts +++ b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts @@ -19,6 +19,8 @@ export class AccountEntityToDtoMapper { }); } + // TODO: use Counted instead of [Account[], number] + // TODO: adjust naming of accountEntities static mapSearchResult(accountEntities: [Account[], number]): Counted { const foundAccounts = accountEntities[0]; const accountDtos: AccountDto[] = AccountEntityToDtoMapper.mapAccountsToDto(foundAccounts); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts index 2430afe6081..ee7d1644c94 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts @@ -24,9 +24,9 @@ describe('AccountIdmToDtoMapperDb', () => { afterAll(async () => { await module.close(); }); - describe('when mapping from entity to dto', () => { - describe('mapToDto', () => { - it('should map all fields', () => { + describe('mapToDto', () => { + describe('when mapping from entity to dto', () => { + const setup = () => { const testIdmEntity: IdmAccount = { id: 'id', username: 'username', @@ -38,6 +38,12 @@ describe('AccountIdmToDtoMapperDb', () => { attDbcUserId: 'attDbcUserId', attDbcSystemId: 'attDbcSystemId', }; + return testIdmEntity; + }; + + it('should map all fields', () => { + const testIdmEntity = setup(); + const ret = mapper.mapToDto(testIdmEntity); expect(ret).toEqual( @@ -52,30 +58,42 @@ describe('AccountIdmToDtoMapperDb', () => { }) ); }); + }); + + describe('when date is undefined', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; - describe('when date is undefined', () => { - it('should use actual date', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); + it('should use actual date', () => { + const testIdmEntity = setup(); - const now = new Date(); - expect(ret.createdAt).toEqual(now); - expect(ret.updatedAt).toEqual(now); - }); + const ret = mapper.mapToDto(testIdmEntity); + + const now = new Date(); + expect(ret.createdAt).toEqual(now); + expect(ret.updatedAt).toEqual(now); }); + }); - describe('when a fields value is missing', () => { - it('should fill with empty string', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); + describe('when a fields value is missing', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; + + it('should fill with empty string', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDto(testIdmEntity); - expect(ret.id).toBe(''); - expect(ret.username).toBe(''); - }); + expect(ret.id).toBe(''); + expect(ret.username).toBe(''); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts index 0d60a2cc57f..554e2d3025a 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts +++ b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts @@ -30,39 +30,52 @@ describe('AccountIdmToDtoMapperIdm', () => { await module.close(); }); - describe('when mapping from entity to dto', () => { - it('should map all fields', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - username: 'username', - email: 'email', - firstName: 'firstName', - lastName: 'lastName', - createdDate: new Date(), - attDbcAccountId: 'attDbcAccountId', - attDbcUserId: 'attDbcUserId', - attDbcSystemId: 'attDbcSystemId', + describe('mapToDto', () => { + describe('when mapping from entity to dto', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + username: 'username', + email: 'email', + firstName: 'firstName', + lastName: 'lastName', + createdDate: new Date(), + attDbcAccountId: 'attDbcAccountId', + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', + }; + return testIdmEntity; }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret).toEqual( - expect.objectContaining>({ - id: testIdmEntity.id, - idmReferenceId: undefined, - userId: testIdmEntity.attDbcUserId, - systemId: testIdmEntity.attDbcSystemId, - createdAt: testIdmEntity.createdDate, - updatedAt: testIdmEntity.createdDate, - username: testIdmEntity.username, - }) - ); - }); + it('should map all fields', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDto(testIdmEntity); + + expect(ret).toEqual( + expect.objectContaining>({ + id: testIdmEntity.id, + idmReferenceId: undefined, + userId: testIdmEntity.attDbcUserId, + systemId: testIdmEntity.attDbcSystemId, + createdAt: testIdmEntity.createdDate, + updatedAt: testIdmEntity.createdDate, + username: testIdmEntity.username, + }) + ); + }); + }); describe('when date is undefined', () => { - it('should use actual date', () => { + const setup = () => { const testIdmEntity: IdmAccount = { id: 'id', }; + return testIdmEntity; + }; + + it('should use actual date', () => { + const testIdmEntity = setup(); + const ret = mapper.mapToDto(testIdmEntity); expect(ret.createdAt).toEqual(now); @@ -71,10 +84,16 @@ describe('AccountIdmToDtoMapperIdm', () => { }); describe('when a fields value is missing', () => { - it('should fill with empty string', () => { + const setup = () => { const testIdmEntity: IdmAccount = { id: 'id', }; + return testIdmEntity; + }; + + it('should fill with empty string', () => { + const testIdmEntity = setup(); + const ret = mapper.mapToDto(testIdmEntity); expect(ret.username).toBe(''); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts index e3aa1d06c03..05c345f166b 100644 --- a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts +++ b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts @@ -1,62 +1,57 @@ import { Account } from '@shared/domain'; import { AccountDto } from '@src/modules/account/services/dto/account.dto'; -import { ObjectId } from '@mikro-orm/mongodb'; +import { accountDtoFactory, accountFactory } from '@shared/testing'; import { AccountResponseMapper } from '.'; describe('AccountResponseMapper', () => { describe('mapToResponseFromEntity', () => { - it('should map all fields', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: new ObjectId().toString(), - userId: new ObjectId(), - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountResponseMapper.mapToResponseFromEntity(testEntity); + describe('When mapping AccountEntity to AccountResponse', () => { + const setup = () => { + const testEntityAllFields: Account = accountFactory.withAllProperties().buildWithId(); - expect(ret.id).toBe(testEntity.id); - expect(ret.userId).toBe(testEntity.userId?.toString()); - expect(ret.activated).toBe(testEntity.activated); - expect(ret.username).toBe(testEntity.username); - expect(ret.updatedAt).toBe(testEntity.updatedAt); - }); + const testEntityMissingUserId: Account = accountFactory.withoutSystemAndUserId().build(); - it('should ignore missing userId', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: new ObjectId().toString(), - userId: undefined, - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), + return { testEntityAllFields, testEntityMissingUserId }; }; - const ret = AccountResponseMapper.mapToResponseFromEntity(testEntity); - expect(ret.userId).toBeUndefined(); + it('should map all fields', () => { + const { testEntityAllFields } = setup(); + + const ret = AccountResponseMapper.mapToResponseFromEntity(testEntityAllFields); + + expect(ret.id).toBe(testEntityAllFields.id); + expect(ret.userId).toBe(testEntityAllFields.userId?.toString()); + expect(ret.activated).toBe(testEntityAllFields.activated); + expect(ret.username).toBe(testEntityAllFields.username); + }); + + it('should ignore missing userId', () => { + const { testEntityMissingUserId } = setup(); + + const ret = AccountResponseMapper.mapToResponseFromEntity(testEntityMissingUserId); + + expect(ret.userId).toBeUndefined(); + }); }); }); describe('mapToResponse', () => { - it('should map all fields', () => { - const testDto: AccountDto = { - id: new ObjectId().toString(), - userId: new ObjectId().toString(), - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), + describe('When mapping AccountDto to AccountResponse', () => { + const setup = () => { + const testDto: AccountDto = accountDtoFactory.buildWithId(); + return testDto; }; - const ret = AccountResponseMapper.mapToResponse(testDto); - expect(ret.id).toBe(testDto.id); - expect(ret.userId).toBe(testDto.userId?.toString()); - expect(ret.activated).toBe(testDto.activated); - expect(ret.username).toBe(testDto.username); - expect(ret.updatedAt).toBe(testDto.updatedAt); + it('should map all fields', () => { + const testDto = setup(); + + const ret = AccountResponseMapper.mapToResponse(testDto); + + expect(ret.id).toBe(testDto.id); + expect(ret.userId).toBe(testDto.userId?.toString()); + expect(ret.activated).toBe(testDto.activated); + expect(ret.username).toBe(testDto.username); + }); }); }); }); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.ts b/apps/server/src/modules/account/mapper/account-response.mapper.ts index 12d9227163b..94437737df9 100644 --- a/apps/server/src/modules/account/mapper/account-response.mapper.ts +++ b/apps/server/src/modules/account/mapper/account-response.mapper.ts @@ -3,6 +3,7 @@ import { AccountDto } from '@src/modules/account/services/dto/account.dto'; import { AccountResponse } from '../controller/dto'; export class AccountResponseMapper { + // TODO: remove this one static mapToResponseFromEntity(account: Account): AccountResponse { return new AccountResponse({ id: account.id, diff --git a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts index bf4a44119fe..1b1193cf8a4 100644 --- a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts +++ b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts @@ -10,7 +10,6 @@ describe('account repo', () => { let module: TestingModule; let em: EntityManager; let repo: AccountRepo; - let mockAccounts: Account[]; beforeAll(async () => { module = await Test.createTestingModule({ @@ -25,16 +24,6 @@ describe('account repo', () => { await module.close(); }); - beforeEach(async () => { - mockAccounts = [ - accountFactory.build({ username: 'John Doe' }), - accountFactory.build({ username: 'Marry Doe' }), - accountFactory.build({ username: 'Susi Doe' }), - accountFactory.build({ username: 'Tim Doe' }), - ]; - await em.persistAndFlush(mockAccounts); - }); - afterEach(async () => { await cleanupCollections(em); }); @@ -44,183 +33,340 @@ describe('account repo', () => { }); describe('findByUserId', () => { - it('should findByUserId', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUserId(accountToFind.userId ?? ''); - expect(account?.id).toEqual(accountToFind.id); + describe('When calling findByUserId with id', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should find user with id', async () => { + const accountToFind = await setup(); + const account = await repo.findByUserId(accountToFind.userId ?? ''); + expect(account?.id).toEqual(accountToFind.id); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should return account', async () => { - const accountToFind = accountFactory.withSystemId(new ObjectId(10)).build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUsernameAndSystemId(accountToFind.username ?? '', accountToFind.systemId ?? ''); - expect(account?.username).toEqual(accountToFind.username); + describe('When username and systemId are given', () => { + const setup = async () => { + const accountToFind = accountFactory.withSystemId(new ObjectId(10)).build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should return account', async () => { + const accountToFind = await setup(); + const account = await repo.findByUsernameAndSystemId( + accountToFind.username ?? '', + accountToFind.systemId ?? '' + ); + expect(account?.username).toEqual(accountToFind.username); + }); }); - it('should return null', async () => { - const account = await repo.findByUsernameAndSystemId('', new ObjectId(undefined)); - expect(account).toBeNull(); + + describe('When username and systemId are not given', () => { + it('should return null', async () => { + const account = await repo.findByUsernameAndSystemId('', new ObjectId(undefined)); + expect(account).toBeNull(); + }); }); }); describe('findMultipleByUserId', () => { - it('should find multiple user by id', async () => { - const anAccountToFind = accountFactory.build(); - const anotherAccountToFind = accountFactory.build(); - await em.persistAndFlush(anAccountToFind); - await em.persistAndFlush(anotherAccountToFind); - em.clear(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const accounts = await repo.findMultipleByUserId([anAccountToFind.userId!, anotherAccountToFind.userId!]); - expect(accounts).toContainEqual(anAccountToFind); - expect(accounts).toContainEqual(anotherAccountToFind); - expect(accounts).toHaveLength(2); + describe('When multiple user ids are given', () => { + const setup = async () => { + const anAccountToFind = accountFactory.build(); + const anotherAccountToFind = accountFactory.build(); + await em.persistAndFlush(anAccountToFind); + await em.persistAndFlush(anotherAccountToFind); + em.clear(); + + return { anAccountToFind, anotherAccountToFind }; + }; + + it('should find multiple users', async () => { + const { anAccountToFind, anotherAccountToFind } = await setup(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accounts = await repo.findMultipleByUserId([anAccountToFind.userId!, anotherAccountToFind.userId!]); + expect(accounts).toContainEqual(anAccountToFind); + expect(accounts).toContainEqual(anotherAccountToFind); + expect(accounts).toHaveLength(2); + }); }); - it('should return empty list if no results', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const accounts = await repo.findMultipleByUserId(['123456789012', '098765432101']); - expect(accounts).toHaveLength(0); + describe('When not existing user ids are given', () => { + it('should return empty list', async () => { + const accounts = await repo.findMultipleByUserId(['123456789012', '098765432101']); + expect(accounts).toHaveLength(0); + }); }); }); describe('findByUserIdOrFail', () => { - it('should find a user by id', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUserIdOrFail(accountToFind.userId ?? ''); - expect(account.id).toEqual(accountToFind.id); + describe('When existing id is given', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + it('should find a user', async () => { + const accountToFind = await setup(); + const account = await repo.findByUserIdOrFail(accountToFind.userId ?? ''); + expect(account.id).toEqual(accountToFind.id); + }); }); - it('should throw if id does not exist', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - await expect(repo.findByUserIdOrFail('123456789012')).rejects.toThrow(NotFoundError); + describe('When id does not exist', () => { + it('should throw not found error', async () => { + await expect(repo.findByUserIdOrFail('123456789012')).rejects.toThrow(NotFoundError); + }); }); }); describe('getObjectReference', () => { - it('should return a valid reference', async () => { - const user = userFactory.buildWithId(); - const account = accountFactory.build({ userId: user.id }); - await em.persistAndFlush([user, account]); - - const reference = repo.getObjectReference(User, account.userId ?? ''); - - expect(reference).toBe(user); + describe('When a user id is given', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.build({ userId: user.id }); + await em.persistAndFlush([user, account]); + return { user, account }; + }; + it('should return a valid reference', async () => { + const { user, account } = await setup(); + + const reference = repo.getObjectReference(User, account.userId ?? ''); + + expect(reference).toBe(user); + }); }); }); describe('saveWithoutFlush', () => { - it('should add an account to the persist stack', () => { - const account = accountFactory.build(); - - repo.saveWithoutFlush(account); - expect(em.getUnitOfWork().getPersistStack().size).toBe(1); + describe('When calling saveWithoutFlush', () => { + const setup = () => { + const account = accountFactory.build(); + return account; + }; + it('should add an account to the persist stack', () => { + const account = setup(); + + repo.saveWithoutFlush(account); + expect(em.getUnitOfWork().getPersistStack().size).toBe(1); + }); }); }); describe('flush', () => { - it('should flush after save', async () => { - const account = accountFactory.build(); - em.persist(account); + describe('When repo is flushed', () => { + const setup = () => { + const account = accountFactory.build(); + em.persist(account); + return account; + }; + + it('should save account', async () => { + const account = setup(); - expect(account.id).toBeNull(); + expect(account.id).toBeNull(); - await repo.flush(); + await repo.flush(); - expect(account.id).not.toBeNull(); + expect(account.id).not.toBeNull(); + }); }); }); - describe('findByUsername', () => { - it('should find account by user name', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - - const [result] = await repo.searchByUsernameExactMatch('USER@EXAMPLE.COM'); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); - - const [result2] = await repo.searchByUsernamePartialMatch('user'); - expect(result2).toHaveLength(1); - expect(result2[0]).toEqual(expect.objectContaining({ username: originalUsername })); + describe('searchByUsernamePartialMatch', () => { + describe('When searching with a partial user name', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const partialUsername = 'user'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, partialUsername, account }; + }; + + it('should find exact one user', async () => { + const { originalUsername, partialUsername } = await setup(); + const [result] = await repo.searchByUsernamePartialMatch(partialUsername); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); }); - it('should find account by user name, ignoring case', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - - let [accounts] = await repo.searchByUsernameExactMatch('USER@example.COM'); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); - [accounts] = await repo.searchByUsernameExactMatch('user@example.com'); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + describe('searchByUsernameExactMatch', () => { + describe('When searching for an exact match', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, account }; + }; + + it('should find exact one account', async () => { + const { originalUsername } = await setup(); + + const [result] = await repo.searchByUsernameExactMatch(originalUsername); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); }); - it('should not find by wildcard', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - let [accounts] = await repo.searchByUsernameExactMatch('USER@EXAMPLECCOM'); - expect(accounts).toHaveLength(0); + describe('When searching by username', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const partialLowerCaseUsername = 'USER@example.COM'; + const lowercaseUsername = 'user@example.com'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, partialLowerCaseUsername, lowercaseUsername, account }; + }; + + it('should find account by user name, ignoring case', async () => { + const { originalUsername, partialLowerCaseUsername, lowercaseUsername } = await setup(); + + let [accounts] = await repo.searchByUsernameExactMatch(partialLowerCaseUsername); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + + [accounts] = await repo.searchByUsernameExactMatch(lowercaseUsername); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); + }); - [accounts] = await repo.searchByUsernameExactMatch('.*'); - expect(accounts).toHaveLength(0); + describe('When using wildcard', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const missingDotUserName = 'USER@EXAMPLECCOM'; + const wildcard = '.*'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, missingDotUserName, wildcard, account }; + }; + + it('should not find account', async () => { + const { missingDotUserName, wildcard } = await setup(); + + let [accounts] = await repo.searchByUsernameExactMatch(missingDotUserName); + expect(accounts).toHaveLength(0); + + [accounts] = await repo.searchByUsernameExactMatch(wildcard); + expect(accounts).toHaveLength(0); + }); }); }); - describe('deleteId', () => { - it('should delete an account by id', async () => { - const account = accountFactory.buildWithId(); - await em.persistAndFlush([account]); + describe('deleteById', () => { + describe('When an id is given', () => { + const setup = async () => { + const account = accountFactory.buildWithId(); + await em.persistAndFlush([account]); + + return account; + }; - await expect(repo.deleteById(account.id)).resolves.not.toThrow(); + it('should delete an account by id', async () => { + const account = await setup(); - await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + await expect(repo.deleteById(account.id)).resolves.not.toThrow(); + + await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + }); }); }); describe('deleteByUserId', () => { - it('should delete an account by user id', async () => { - const user = userFactory.buildWithId(); - const account = accountFactory.build({ userId: user.id }); - await em.persistAndFlush([user, account]); + describe('When an user id is given', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.build({ userId: user.id }); + await em.persistAndFlush([user, account]); + + return { user, account }; + }; - await expect(repo.deleteByUserId(user.id)).resolves.not.toThrow(); + it('should delete an account by user id', async () => { + const { user, account } = await setup(); - await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + await expect(repo.deleteByUserId(user.id)).resolves.not.toThrow(); + + await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + }); }); }); describe('findMany', () => { - it('should find all accounts', async () => { - const foundAccounts = await repo.findMany(); - expect(foundAccounts).toEqual(mockAccounts); - }); - it('limit the result set ', async () => { - const limit = 1; - const foundAccounts = await repo.findMany(0, limit); - expect(foundAccounts).toHaveLength(limit); - }); - it('skip n entries ', async () => { - const offset = 2; - const foundAccounts = await repo.findMany(offset); - expect(foundAccounts).toHaveLength(mockAccounts.length - offset); + describe('When no limit and offset are given', () => { + const setup = async () => { + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return mockAccounts; + }; + + it('should find all accounts', async () => { + const mockAccounts = await setup(); + const foundAccounts = await repo.findMany(); + expect(foundAccounts).toEqual(mockAccounts); + }); + }); + + describe('When limit is given', () => { + const setup = async () => { + const limit = 1; + + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return { limit, mockAccounts }; + }; + + it('should limit the result set', async () => { + const { limit } = await setup(); + const foundAccounts = await repo.findMany(0, limit); + expect(foundAccounts).toHaveLength(limit); + }); + }); + + describe('When offset is given', () => { + const setup = async () => { + const offset = 2; + + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return { offset, mockAccounts }; + }; + + it('should skip n entries', async () => { + const { offset, mockAccounts } = await setup(); + + const foundAccounts = await repo.findMany(offset); + expect(foundAccounts).toHaveLength(mockAccounts.length - offset); + }); }); }); }); diff --git a/apps/server/src/modules/account/repo/account.repo.ts b/apps/server/src/modules/account/repo/account.repo.ts index fb68f0a759b..e848973c5c6 100644 --- a/apps/server/src/modules/account/repo/account.repo.ts +++ b/apps/server/src/modules/account/repo/account.repo.ts @@ -15,7 +15,9 @@ export class AccountRepo extends BaseRepo { * Finds an account by user id. * @param userId the user id */ + // TODO: here only EntityIds should arrive async findByUserId(userId: EntityId | ObjectId): Promise { + // TODO: you can use userId directly, without constructing an objectId return this._em.findOne(Account, { userId: new ObjectId(userId) }); } @@ -47,6 +49,8 @@ export class AccountRepo extends BaseRepo { await this._em.flush(); } + // TODO: the default values for skip and limit, are they required and/or correct here? + // TODO: use counted for the return type async searchByUsernameExactMatch(username: string, skip = 0, limit = 1): Promise<[Account[], number]> { return this.searchByUsername(username, skip, limit, true); } @@ -80,6 +84,7 @@ export class AccountRepo extends BaseRepo { limit: number, exactMatch: boolean ): Promise<[Account[], number]> { + // TODO: check that injections are not possible, eg make sure sanitizeHTML has been called at some point (for username) // escapes every character, that's not a unicode letter or number const escapedUsername = username.replace(/[^(\p{L}\p{N})]/gu, '\\$&'); const searchUsername = exactMatch ? `^${escapedUsername}$` : escapedUsername; diff --git a/apps/server/src/modules/account/review-comments.md b/apps/server/src/modules/account/review-comments.md new file mode 100644 index 00000000000..fc636019cdd --- /dev/null +++ b/apps/server/src/modules/account/review-comments.md @@ -0,0 +1,12 @@ +# Review Comments 14.7.23 + +- move mapper into repo folder +- write an md file or flow diagram describing how things work +- in what layer do the services belong? + +- naming of DO vs Entity (DO is the leading, "Account", entity is just the datalayer representation "AccountEntity") + +- new decisions for loggables + + +looked at this module only. \ No newline at end of file diff --git a/apps/server/src/modules/account/services/account-db.service.spec.ts b/apps/server/src/modules/account/services/account-db.service.spec.ts index 777ab61a1ad..8cc6a33cbb6 100644 --- a/apps/server/src/modules/account/services/account-db.service.spec.ts +++ b/apps/server/src/modules/account/services/account-db.service.spec.ts @@ -3,9 +3,9 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; -import { Account, EntityId, Permission, Role, RoleName, SchoolEntity, User } from '@shared/domain'; +import { Account, EntityId } from '@shared/domain'; import { IdentityManagementService } from '@shared/infra/identity-management/identity-management.service'; -import { accountFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { accountFactory, setupEntities, userFactory } from '@shared/testing'; import { AccountEntityToDtoMapper } from '@src/modules/account/mapper'; import { AccountDto } from '@src/modules/account/services/dto'; import { IServerConfig } from '@src/modules/server'; @@ -19,23 +19,11 @@ import { AbstractAccountService } from './account.service.abstract'; describe('AccountDbService', () => { let module: TestingModule; let accountService: AbstractAccountService; - let mockAccounts: Account[]; - let accountRepo: AccountRepo; + let accountRepo: DeepMocked; let accountLookupServiceMock: DeepMocked; const defaultPassword = 'DummyPasswd!1'; - let mockSchool: SchoolEntity; - - let mockTeacherUser: User; - let mockStudentUser: User; - let mockUserWithoutAccount: User; - - let mockTeacherAccount: Account; - let mockStudentAccount: Account; - - let mockAccountWithSystemId: Account; - afterAll(async () => { await module.close(); }); @@ -47,69 +35,7 @@ describe('AccountDbService', () => { AccountLookupService, { provide: AccountRepo, - useValue: { - save: jest.fn().mockImplementation((account: Account): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find((tempAccount) => tempAccount.userId === account.userId); - if (accountEntity) { - Object.assign(accountEntity, account); - } - - return Promise.resolve(); - }), - deleteById: jest.fn().mockImplementation((): Promise => Promise.resolve()), - findMultipleByUserId: (userIds: EntityId[]): Promise => { - const accounts = mockAccounts.filter((tempAccount) => - userIds.find((userId) => tempAccount.userId?.toString() === userId) - ); - return Promise.resolve(accounts); - }, - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - findByUserIdOrFail: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }, - findByUsernameAndSystemId: (username: string, systemId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find( - (tempAccount) => tempAccount.username === username && tempAccount.systemId === systemId - ); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - - findById: jest.fn().mockImplementation((accountId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId.toString()); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }), - searchByUsernameExactMatch: jest - .fn() - .mockImplementation((): Promise<[Account[], number]> => Promise.resolve([[mockTeacherAccount], 1])), - searchByUsernamePartialMatch: jest - .fn() - .mockImplementation( - (): Promise<[Account[], number]> => Promise.resolve([mockAccounts, mockAccounts.length]) - ), - deleteByUserId: jest.fn().mockImplementation((): Promise => Promise.resolve()), - findMany: jest.fn().mockImplementation((): Promise => Promise.resolve(mockAccounts)), - }, + useValue: createMock(), }, { provide: LegacyLogger, @@ -125,14 +51,7 @@ describe('AccountDbService', () => { }, { provide: AccountLookupService, - useValue: createMock({ - getInternalId: (id: EntityId | ObjectId): Promise => { - if (ObjectId.isValid(id)) { - return Promise.resolve(new ObjectId(id)); - } - return Promise.resolve(null); - }, - }), + useValue: createMock(), }, ], }).compile(); @@ -143,28 +62,9 @@ describe('AccountDbService', () => { }); beforeEach(() => { + jest.resetAllMocks(); jest.useFakeTimers(); jest.setSystemTime(new Date(2020, 1, 1)); - - mockSchool = schoolFactory.buildWithId(); - - mockTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id, password: defaultPassword }); - mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id, password: defaultPassword }); - - mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); - mockAccounts = [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId]; }); afterEach(() => { @@ -173,294 +73,615 @@ describe('AccountDbService', () => { }); describe('findById', () => { - it( - 'should return accountDto', - async () => { - const resultAccount = await accountService.findById(mockTeacherAccount.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); - }, - 10 * 60 * 1000 - ); + describe('when searching by Id', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.activated = false; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + it( + 'should return accountDto', + async () => { + const { mockTeacherAccount } = setup(); + + const resultAccount = await accountService.findById(mockTeacherAccount.id); + expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }, + 10 * 60 * 1000 + ); + }); }); describe('findByUserId', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUserId(mockTeacherUser.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when user id exists', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findByUserId.mockImplementation((userId: EntityId | ObjectId): Promise => { + if (userId === mockTeacherUser.id) { + return Promise.resolve(mockTeacherAccount); + } + return Promise.resolve(null); + }); + + return { mockTeacherUser, mockTeacherAccount }; + }; + it('should return accountDto', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccount = await accountService.findByUserId(mockTeacherUser.id); + expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); - it('should return null', async () => { - const resultAccount = await accountService.findByUserId('nonExistentId'); - expect(resultAccount).toBeNull(); + + describe('when user id not exists', () => { + it('should return null', async () => { + const resultAccount = await accountService.findByUserId('nonExistentId'); + expect(resultAccount).toBeNull(); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - mockAccountWithSystemId.username, - mockAccountWithSystemId.systemId ?? '' - ); - expect(resultAccount).not.toBe(undefined); + describe('when user name and system id exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + accountRepo.findByUsernameAndSystemId.mockResolvedValue(mockAccountWithSystemId); + return { mockAccountWithSystemId }; + }; + it('should return accountDto', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + mockAccountWithSystemId.username, + mockAccountWithSystemId.systemId ?? '' + ); + expect(resultAccount).not.toBe(undefined); + }); }); - it('should return null if username does not exist', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - 'nonExistentUsername', - mockAccountWithSystemId.systemId ?? '' - ); - expect(resultAccount).toBeNull(); + + describe('when only system id exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + accountRepo.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(mockAccountWithSystemId); + } + return Promise.resolve(null); + } + ); + return { mockAccountWithSystemId }; + }; + it('should return null if username does not exist', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + 'nonExistentUsername', + mockAccountWithSystemId.systemId ?? '' + ); + expect(resultAccount).toBeNull(); + }); }); - it('should return null if system id does not exist', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - mockAccountWithSystemId.username, - 'nonExistentSystemId' ?? '' - ); - expect(resultAccount).toBeNull(); + + describe('when only user name exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + accountRepo.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(mockAccountWithSystemId); + } + return Promise.resolve(null); + } + ); + return { mockAccountWithSystemId }; + }; + it('should return null if system id does not exist', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + mockAccountWithSystemId.username, + 'nonExistentSystemId' ?? '' + ); + expect(resultAccount).toBeNull(); + }); }); }); describe('findMultipleByUserId', () => { - it('should return multiple accountDtos', async () => { - const resultAccounts = await accountService.findMultipleByUserId([mockTeacherUser.id, mockStudentUser.id]); - expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); - expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); - expect(resultAccounts).toHaveLength(2); + describe('when searching for multiple existing ids', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPassword, + }); + + accountRepo.findMultipleByUserId.mockImplementation((userIds: (EntityId | ObjectId)[]): Promise => { + const accounts = [mockStudentAccount, mockTeacherAccount].filter((tempAccount) => + userIds.find((userId) => tempAccount.userId?.toString() === userId) + ); + return Promise.resolve(accounts); + }); + return { mockStudentUser, mockStudentAccount, mockTeacherUser, mockTeacherAccount }; + }; + it('should return multiple accountDtos', async () => { + const { mockStudentUser, mockStudentAccount, mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccounts = await accountService.findMultipleByUserId([mockTeacherUser.id, mockStudentUser.id]); + expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + expect(resultAccounts).toHaveLength(2); + }); }); - it('should return empty array on mismatch', async () => { - const resultAccount = await accountService.findMultipleByUserId(['nonExistentId1']); - expect(resultAccount).toHaveLength(0); + + describe('when only user name exists', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockStudentAccount = accountFactory.buildWithId(); + + accountRepo.findMultipleByUserId.mockImplementation((userIds: (EntityId | ObjectId)[]): Promise => { + const accounts = [mockStudentAccount, mockTeacherAccount].filter((tempAccount) => + userIds.find((userId) => tempAccount.userId?.toString() === userId) + ); + return Promise.resolve(accounts); + }); + return {}; + }; + it('should return empty array on mismatch', async () => { + setup(); + const resultAccount = await accountService.findMultipleByUserId(['nonExistentId1']); + expect(resultAccount).toHaveLength(0); + }); }); }); describe('findByUserIdOrFail', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUserIdOrFail(mockTeacherUser.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when user exists', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findByUserIdOrFail.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherUser, mockTeacherAccount }; + }; + + it('should return accountDto', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccount = await accountService.findByUserIdOrFail(mockTeacherUser.id); + expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); - it('should throw EntityNotFoundError', async () => { - await expect(accountService.findByUserIdOrFail('nonExistentId')).rejects.toThrow(EntityNotFoundError); + + describe('when user does not exist', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + accountRepo.findByUserIdOrFail.mockImplementation((userId: EntityId | ObjectId): Promise => { + if (mockTeacherUser.id === userId) { + return Promise.resolve(mockTeacherAccount); + } + throw new EntityNotFoundError(Account.name); + }); + return {}; + }; + it('should throw EntityNotFoundError', async () => { + setup(); + await expect(accountService.findByUserIdOrFail('nonExistentId')).rejects.toThrow(EntityNotFoundError); + }); }); }); describe('save', () => { - it('should update an existing account', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.activated = false; - const ret = await accountService.save(mockTeacherAccountDto); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccountDto.activated, - systemId: mockTeacherAccount.systemId, - userId: mockTeacherAccount.userId, + describe('when update an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.activated = false; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccountDto, mockTeacherAccount }; + }; + + it('should update account', async () => { + const { mockTeacherAccountDto, mockTeacherAccount } = setup(); + const ret = await accountService.save(mockTeacherAccountDto); + + expect(accountRepo.save).toBeCalledTimes(1); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccountDto.activated, + systemId: mockTeacherAccount.systemId, + userId: mockTeacherAccount.userId, + }); }); }); - it("should update an existing account's system", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.systemId = '123456789012'; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: new ObjectId(mockTeacherAccountDto.systemId), - userId: mockTeacherAccount.userId, + describe("when update an existing account's system", () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.systemId = '123456789012'; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccountDto, mockTeacherAccount }; + }; + it("should update an existing account's system", async () => { + const { mockTeacherAccountDto, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccountDto); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccount.activated, + systemId: new ObjectId(mockTeacherAccountDto.systemId), + userId: mockTeacherAccount.userId, + }); }); }); - it("should update an existing account's user", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.userId = mockStudentUser.id; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: mockTeacherAccount.systemId, - userId: new ObjectId(mockStudentUser.id), + + describe("when update an existing account's user", () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockStudentUser = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.userId = mockStudentUser.id; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockStudentUser, mockTeacherAccountDto, mockTeacherAccount }; + }; + it('should update account', async () => { + const { mockStudentUser, mockTeacherAccountDto, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccountDto); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccount.systemId, + userId: new ObjectId(mockStudentUser.id), + }); }); }); - it("should keep existing account's system undefined on update", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.systemId = undefined; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: mockTeacherAccountDto.systemId, - userId: mockTeacherAccount.userId, + describe("when existing account's system is undefined", () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + + mockTeacherAccountDto.username = 'changedUsername@example.org'; + mockTeacherAccountDto.systemId = undefined; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccountDto, mockTeacherAccount }; + }; + it('should keep undefined on update', async () => { + const { mockTeacherAccountDto, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccountDto); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccountDto.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccountDto.systemId, + userId: mockTeacherAccount.userId, + }); + }); + }); + + describe('when account does not exists', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave: AccountDto = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { accountToSave }; + }; + it('should save a new account', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + username: accountToSave.username, + userId: new ObjectId(accountToSave.userId), + systemId: new ObjectId(accountToSave.systemId), + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }); }); }); - it('should save a new account', async () => { - const accountToSave: AccountDto = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - systemId: '012345678912', - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - username: accountToSave.username, - userId: new ObjectId(accountToSave.userId), - systemId: new ObjectId(accountToSave.systemId), - createdAt: accountToSave.createdAt, - updatedAt: accountToSave.updatedAt, + + describe("when account's system undefined", () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave: AccountDto = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + password: defaultPassword, + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { accountToSave }; + }; + it('should keep undefined on save', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + systemId: undefined, + }); }); }); - it("should keep account's system undefined on save", async () => { - const accountToSave: AccountDto = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - systemId: undefined, + describe('when save account', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { accountToSave }; + }; + it('should encrypt password', async () => { + const { accountToSave } = setup(); + + await accountService.save(accountToSave); + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(ret).not.toMatchObject({ + password: defaultPassword, + }); }); }); - it('should encrypt password', async () => { - const accountToSave = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - systemId: '012345678912', - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await accountService.save(accountToSave); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).not.toMatchObject({ - password: defaultPassword, + describe('when creating a new account', () => { + const setup = () => { + const spy = jest.spyOn(accountRepo, 'save'); + const dto = { + username: 'john.doe@domain.tld', + password: '', + } as AccountDto; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { spy, dto }; + }; + it('should set password to undefined if password is empty', async () => { + const { spy, dto } = setup(); + + await expect(accountService.save(dto)).resolves.not.toThrow(); + expect(accountRepo.findById).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + password: undefined, + }) + ); }); }); - it('should set password to undefined if password is empty while creating a new account', async () => { - const spy = jest.spyOn(accountRepo, 'save'); - const dto = { - username: 'john.doe@domain.tld', - password: '', - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await expect(accountService.save(dto)).resolves.not.toThrow(); - expect(accountRepo.findById).not.toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ + describe('when password is empty while editing an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + const spy = jest.spyOn(accountRepo, 'save'); + const dto = { + id: mockTeacherAccount.id, password: undefined, - }) - ); - }); + } as AccountDto; - it('should not change password if password is empty while editing an existing account', async () => { - const spy = jest.spyOn(accountRepo, 'save'); - const dto = { - id: mockTeacherAccount.id, - // username: 'john.doe@domain.tld', - password: undefined, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await expect(accountService.save(dto)).resolves.not.toThrow(); - expect(accountRepo.findById).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - password: defaultPassword, - }) - ); + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + accountRepo.save.mockResolvedValue(); + + return { mockTeacherAccount, spy, dto }; + }; + it('should not change password', async () => { + const { mockTeacherAccount, spy, dto } = setup(); + await expect(accountService.save(dto)).resolves.not.toThrow(); + expect(accountRepo.findById).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + password: mockTeacherAccount.password, + }) + ); + }); }); }); describe('updateUsername', () => { - it('should update an existing account but no other information', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - const newUsername = 'newUsername'; - const ret = await accountService.updateUsername(mockTeacherAccount.id, newUsername); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccountDto, - username: newUsername, + describe('when updating username', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + const newUsername = 'newUsername'; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount, mockTeacherAccountDto, newUsername }; + }; + it('should update only user name', async () => { + const { mockTeacherAccount, mockTeacherAccountDto, newUsername } = setup(); + const ret = await accountService.updateUsername(mockTeacherAccount.id, newUsername); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + ...mockTeacherAccountDto, + username: newUsername, + }); }); }); }); describe('updateLastTriedFailedLogin', () => { - it('should update last tried failed login', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - const theNewDate = new Date(); - const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccountDto, - lasttriedFailedLogin: theNewDate, + describe('when update last failed Login', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); + const theNewDate = new Date(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount, mockTeacherAccountDto, theNewDate }; + }; + it('should update last tried failed login', async () => { + const { mockTeacherAccount, mockTeacherAccountDto, theNewDate } = setup(); + const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + ...mockTeacherAccountDto, + lasttriedFailedLogin: theNewDate, + }); }); }); }); describe('validatePassword', () => { - it('should validate password', async () => { - const ret = await accountService.validatePassword( - { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, - defaultPassword - ); - expect(ret).toBe(true); + describe('when accepted Password', () => { + const setup = async () => { + const ret = await accountService.validatePassword( + { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, + defaultPassword + ); + + return { ret }; + }; + it('should validate password', async () => { + const { ret } = await setup(); + + expect(ret).toBe(true); + }); }); - it('should report wrong password', async () => { - const ret = await accountService.validatePassword( - { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, - 'incorrectPwd' - ); - expect(ret).toBe(false); + + describe('when wrong Password', () => { + const setup = async () => { + const ret = await accountService.validatePassword( + { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, + 'incorrectPwd' + ); + + return { ret }; + }; + it('should report', async () => { + const { ret } = await setup(); + + expect(ret).toBe(false); + }); }); - it('should report missing account password', async () => { - const ret = await accountService.validatePassword({ password: undefined } as AccountDto, 'incorrectPwd'); - expect(ret).toBe(false); + + describe('when missing account password', () => { + const setup = async () => { + const ret = await accountService.validatePassword({ password: undefined } as AccountDto, 'incorrectPwd'); + + return { ret }; + }; + it('should report', async () => { + const { ret } = await setup(); + + expect(ret).toBe(false); + }); }); }); describe('updatePassword', () => { - it('should update password', async () => { - const newPassword = 'newPassword'; - const ret = await accountService.updatePassword(mockTeacherAccount.id, newPassword); + describe('when update Password', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + const newPassword = 'newPassword'; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount, newPassword }; + }; + it('should update password', async () => { + const { mockTeacherAccount, newPassword } = setup(); - expect(ret).toBeDefined(); - if (ret.password) { - await expect(bcrypt.compare(newPassword, ret.password)).resolves.toBe(true); - } else { - fail('return password is undefined'); - } + const ret = await accountService.updatePassword(mockTeacherAccount.id, newPassword); + + expect(ret).toBeDefined(); + if (ret.password) { + await expect(bcrypt.compare(newPassword, ret.password)).resolves.toBe(true); + } else { + fail('return password is undefined'); + } + }); }); }); describe('delete', () => { - describe('when deleting existing account', () => { + describe('when delete an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherAccount }; + }; it('should delete account via repo', async () => { + const { mockTeacherAccount } = setup(); await accountService.delete(mockTeacherAccount.id); expect(accountRepo.deleteById).toHaveBeenCalledWith(new ObjectId(mockTeacherAccount.id)); }); @@ -468,55 +689,125 @@ describe('AccountDbService', () => { describe('when deleting non existing account', () => { const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); accountLookupServiceMock.getInternalId.mockResolvedValueOnce(null); + + return { mockTeacherAccount }; }; - it('should throw', async () => { - setup(); + it('should throw account not found', async () => { + const { mockTeacherAccount } = setup(); await expect(accountService.delete(mockTeacherAccount.id)).rejects.toThrow(); }); }); }); describe('deleteByUserId', () => { - it('should delete the account with given user id via repo', async () => { - await accountService.deleteByUserId(mockTeacherAccount.userId?.toString() ?? ''); - expect(accountRepo.deleteByUserId).toHaveBeenCalledWith(mockTeacherAccount.userId); + describe('when delete account with given user id', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { mockTeacherUser, mockTeacherAccount }; + }; + it('should delete via repo', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + + await accountService.deleteByUserId(mockTeacherAccount.userId?.toString() ?? ''); + expect(accountRepo.deleteByUserId).toHaveBeenCalledWith(mockTeacherUser.id); + }); }); }); describe('searchByUsernamePartialMatch', () => { - it('should call repo', async () => { - const partialUserName = 'admin'; - const skip = 2; - const limit = 10; - const [accounts, total] = await accountService.searchByUsernamePartialMatch(partialUserName, skip, limit); - expect(accountRepo.searchByUsernamePartialMatch).toHaveBeenCalledWith(partialUserName, skip, limit); - expect(total).toBe(mockAccounts.length); + describe('when searching by part of username', () => { + const setup = () => { + const partialUserName = 'admin'; + const skip = 2; + const limit = 10; + const mockTeacherAccount = accountFactory.buildWithId(); + const mockStudentAccount = accountFactory.buildWithId(); + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); + const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId]; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.searchByUsernamePartialMatch.mockResolvedValue([ + [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId], + 3, + ]); + accountLookupServiceMock.getInternalId.mockResolvedValue(mockTeacherAccount._id); + + return { partialUserName, skip, limit, mockTeacherAccount, mockAccounts }; + }; + it('should call repo', async () => { + const { partialUserName, skip, limit, mockTeacherAccount, mockAccounts } = setup(); + const [accounts, total] = await accountService.searchByUsernamePartialMatch(partialUserName, skip, limit); + expect(accountRepo.searchByUsernamePartialMatch).toHaveBeenCalledWith(partialUserName, skip, limit); + expect(total).toBe(mockAccounts.length); - expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); }); + describe('searchByUsernameExactMatch', () => { - it('should call repo', async () => { - const partialUserName = 'admin'; - const [accounts, total] = await accountService.searchByUsernameExactMatch(partialUserName); - expect(accountRepo.searchByUsernameExactMatch).toHaveBeenCalledWith(partialUserName); - expect(total).toBe(1); - expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when searching by username', () => { + const setup = () => { + const partialUserName = 'admin'; + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.searchByUsernameExactMatch.mockResolvedValue([[mockTeacherAccount], 1]); + + return { partialUserName, mockTeacherAccount }; + }; + it('should call repo', async () => { + const { partialUserName, mockTeacherAccount } = setup(); + const [accounts, total] = await accountService.searchByUsernameExactMatch(partialUserName); + expect(accountRepo.searchByUsernameExactMatch).toHaveBeenCalledWith(partialUserName); + expect(total).toBe(1); + expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + }); }); }); - describe('findMany', () => { - it('should call repo', async () => { - const foundAccounts = await accountService.findMany(1, 1); - expect(accountRepo.findMany).toHaveBeenCalledWith(1, 1); - expect(foundAccounts).toBeDefined(); - }); - it('should call repo', async () => { - const foundAccounts = await accountService.findMany(); - expect(accountRepo.findMany).toHaveBeenCalledWith(0, 100); - expect(foundAccounts).toBeDefined(); + describe('when find many one time', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findMany.mockResolvedValue([mockTeacherAccount]); + + return {}; + }; + it('should call repo', async () => { + setup(); + const foundAccounts = await accountService.findMany(1, 1); + expect(accountRepo.findMany).toHaveBeenCalledWith(1, 1); + expect(foundAccounts).toBeDefined(); + }); + }); + describe('when call find many more than one time', () => { + const setup = () => { + const mockTeacherAccount = accountFactory.buildWithId(); + + accountRepo.findMany.mockResolvedValue([mockTeacherAccount]); + + return {}; + }; + it('should call repo each time', async () => { + setup(); + const foundAccounts = await accountService.findMany(); + expect(accountRepo.findMany).toHaveBeenCalledWith(0, 100); + expect(foundAccounts).toBeDefined(); + }); }); }); }); diff --git a/apps/server/src/modules/account/services/account-db.service.ts b/apps/server/src/modules/account/services/account-db.service.ts index 1209ed86744..2ea02eeb3c4 100644 --- a/apps/server/src/modules/account/services/account-db.service.ts +++ b/apps/server/src/modules/account/services/account-db.service.ts @@ -1,13 +1,15 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; import { EntityNotFoundError } from '@shared/common'; import { Account, Counted, EntityId } from '@shared/domain'; -import { AccountRepo } from '../repo/account.repo'; +import bcrypt from 'bcryptjs'; import { AccountEntityToDtoMapper } from '../mapper'; -import { AccountDto, AccountSaveDto } from './dto'; -import { AbstractAccountService } from './account.service.abstract'; +import { AccountRepo } from '../repo/account.repo'; import { AccountLookupService } from './account-lookup.service'; +import { AbstractAccountService } from './account.service.abstract'; +import { AccountDto, AccountSaveDto } from './dto'; + +// HINT: do more empty lines :) @Injectable() export class AccountServiceDb extends AbstractAccountService { @@ -32,10 +34,7 @@ export class AccountServiceDb extends AbstractAccountService { } async findByUserIdOrFail(userId: EntityId): Promise { - const accountEntity = await this.accountRepo.findByUserId(userId); - if (!accountEntity) { - throw new EntityNotFoundError('Account'); - } + const accountEntity = await this.accountRepo.findByUserIdOrFail(userId); return AccountEntityToDtoMapper.mapToDto(accountEntity); } @@ -46,6 +45,8 @@ export class AccountServiceDb extends AbstractAccountService { async save(accountDto: AccountSaveDto): Promise { let account: Account; + // HINT: mapping could be done by a mapper (though this whole file is subject to be removed in the future) + // HINT: today we have logic to map back into unit work in the baseDO if (accountDto.id) { const internalId = await this.getInternalId(accountDto.id); account = await this.accountRepo.findById(internalId); @@ -75,7 +76,7 @@ export class AccountServiceDb extends AbstractAccountService { credentialHash: accountDto.credentialHash, }); - await this.accountRepo.save(account); + await this.accountRepo.save(account); // HINT: this can be done once in the end } return AccountEntityToDtoMapper.mapToDto(account); } @@ -128,7 +129,7 @@ export class AccountServiceDb extends AbstractAccountService { if (!account.password) { return Promise.resolve(false); } - return bcrypt.compare(comparePassword, account.password); + return bcrypt.compare(comparePassword, account.password); // hint: first get result, then return seperately } private async getInternalId(id: EntityId | ObjectId): Promise { diff --git a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts index 82f54c60fa1..f50e8fcc07e 100644 --- a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts @@ -94,79 +94,125 @@ describe('AccountIdmService Integration', () => { } }); - it('save should create a new account', async () => { - if (!isIdmReachable) return; - const createdAccount = await accountIdmService.save(testAccount); - const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); - - expect(foundAccount).toEqual( - expect.objectContaining({ - id: createdAccount.idmReferenceId ?? '', - username: createdAccount.username, - attDbcAccountId: testDbcAccountId, - attDbcUserId: createdAccount.userId, - attDbcSystemId: createdAccount.systemId, - }) - ); + describe('save', () => { + describe('when account does not exists', () => { + it('should create a new account', async () => { + if (!isIdmReachable) return; + const createdAccount = await accountIdmService.save(testAccount); + const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); + + expect(foundAccount).toEqual( + expect.objectContaining({ + id: createdAccount.idmReferenceId ?? '', + username: createdAccount.username, + attDbcAccountId: createdAccount.id, + attDbcUserId: createdAccount.userId, + attDbcSystemId: createdAccount.systemId, + }) + ); + }); + }); }); - it('save should update existing account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const idmId = await createAccount(); - - await accountIdmService.save({ - id: testDbcAccountId, - username: newUsername, + describe('save', () => { + describe('when account exists', () => { + const setup = async () => { + const newUserName = 'jane.doe@mail.tld'; + const idmId = await createAccount(); + + return { idmId, newUserName }; + }; + it('should update account', async () => { + if (!isIdmReachable) return; + const { idmId, newUserName } = await setup(); + + await accountIdmService.save({ + id: testDbcAccountId, + username: newUserName, + }); + + const foundAccount = await identityManagementService.findAccountById(idmId); + + expect(foundAccount).toEqual( + expect.objectContaining({ + id: idmId, + username: newUserName, + }) + ); + }); }); - const foundAccount = await identityManagementService.findAccountById(idmId); - - expect(foundAccount).toEqual( - expect.objectContaining({ - id: idmId, - username: newUsername, - }) - ); }); - it('updateUsername should update username', async () => { - if (!isIdmReachable) return; - const newUserName = 'jane.doe@mail.tld'; - const idmId = await createAccount(); - await accountIdmService.updateUsername(testDbcAccountId, newUserName); - - const foundAccount = await identityManagementService.findAccountById(idmId); - - expect(foundAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); + describe('updateUsername', () => { + describe('when updating username', () => { + const setup = async () => { + const newUserName = 'jane.doe@mail.tld'; + const idmId = await createAccount(); + + return { newUserName, idmId }; + }; + it('should update only username', async () => { + if (!isIdmReachable) return; + const { newUserName, idmId } = await setup(); + + await accountIdmService.updateUsername(testDbcAccountId, newUserName); + const foundAccount = await identityManagementService.findAccountById(idmId); + + expect(foundAccount).toEqual( + expect.objectContaining>({ + username: newUserName, + }) + ); + }); + }); }); - it('updatePassword should update password', async () => { - if (!isIdmReachable) return; - await createAccount(); - await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); + describe('updatePassword', () => { + describe('when updating with permitted password', () => { + const setup = async () => { + await createAccount(); + }; + it('should update password', async () => { + if (!isIdmReachable) return; + await setup(); + await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); + }); + }); }); - it('delete should remove account', async () => { - if (!isIdmReachable) return; - const idmId = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - - await accountIdmService.delete(testDbcAccountId); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + describe('delete', () => { + describe('when delete account', () => { + const setup = async () => { + const idmId = await createAccount(); + const foundAccount = await identityManagementService.findAccountById(idmId); + return { idmId, foundAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { idmId, foundAccount } = await setup(); + expect(foundAccount).toBeDefined(); + + await accountIdmService.delete(testDbcAccountId); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + }); + }); }); - it('deleteByUserId should remove account', async () => { - if (!isIdmReachable) return; - const idmId = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - - await accountIdmService.deleteByUserId(testAccount.userId ?? ''); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + describe('deleteByUserId', () => { + describe('when deleting by UserId', () => { + const setup = async () => { + const idmId = await createAccount(); + const foundAccount = await identityManagementService.findAccountById(idmId); + return { idmId, foundAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { idmId, foundAccount } = await setup(); + expect(foundAccount).toBeDefined(); + + await accountIdmService.deleteByUserId(testAccount.userId ?? ''); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + }); + }); }); }); diff --git a/apps/server/src/modules/account/services/account-idm.service.spec.ts b/apps/server/src/modules/account/services/account-idm.service.spec.ts index 4b997d1b3fe..1669b4ca4c4 100644 --- a/apps/server/src/modules/account/services/account-idm.service.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.spec.ts @@ -76,155 +76,203 @@ describe('AccountIdmService', () => { }); describe('save', () => { - const setup = () => { - idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); - }; - - it('should update an existing account', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); - - const mockAccountDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', + describe('when save an existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + + const mockAccountDto = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + }; + return { updateSpy, createSpy, mockAccountDto }; }; - const ret = await accountIdmService.save(mockAccountDto); - - expect(updateSpy).toHaveBeenCalled(); - expect(createSpy).not.toHaveBeenCalled(); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + + it('should update account information', async () => { + const { updateSpy, createSpy, mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); + + expect(updateSpy).toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); - it('should update an existing accounts password', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const updatePasswordSpy = jest.spyOn(idmServiceMock, 'updateAccountPassword'); - - const mockAccountDto: AccountSaveDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', - password: 'password', + describe('when save an existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const updatePasswordSpy = jest.spyOn(idmServiceMock, 'updateAccountPassword'); + + const mockAccountDto: AccountSaveDto = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + password: 'password', + }; + return { updateSpy, updatePasswordSpy, mockAccountDto }; }; - const ret = await accountIdmService.save(mockAccountDto); + it('should update account password', async () => { + const { updateSpy, updatePasswordSpy, mockAccountDto } = setup(); - expect(updateSpy).toHaveBeenCalled(); - expect(updatePasswordSpy).toHaveBeenCalled(); - expect(ret).toBeDefined(); + const ret = await accountIdmService.save(mockAccountDto); + + expect(updateSpy).toHaveBeenCalled(); + expect(updatePasswordSpy).toHaveBeenCalled(); + expect(ret).toBeDefined(); + }); }); - it('should create a new account', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + describe('when save not existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + + const mockAccountDto = { username: 'testUserName', id: undefined, userId: 'userId', systemId: 'systemId' }; + + return { updateSpy, createSpy, mockAccountDto }; + }; + it('should create a new account', async () => { + const { updateSpy, createSpy, mockAccountDto } = setup(); - const mockAccountDto = { username: 'testUserName', id: undefined, userId: 'userId', systemId: 'systemId' }; - const ret = await accountIdmService.save(mockAccountDto); + const ret = await accountIdmService.save(mockAccountDto); - expect(updateSpy).not.toHaveBeenCalled(); - expect(createSpy).toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + expect(createSpy).toHaveBeenCalled(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); - it('should create a new account on update error', async () => { - setup(); - accountLookupServiceMock.getExternalId.mockResolvedValue(null); - const mockAccountDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', - }; - - const ret = await accountIdmService.save(mockAccountDto); - expect(idmServiceMock.createAccount).toHaveBeenCalled(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when save not existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + accountLookupServiceMock.getExternalId.mockResolvedValue(null); + const mockAccountDto = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + }; + + return { mockAccountDto }; + }; + it('should create a new account on update error', async () => { + const { mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); + + expect(idmServiceMock.createAccount).toHaveBeenCalled(); + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('updateUsername', () => { - it('should map result correctly', async () => { - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); - const ret = await accountIdmService.updateUsername(mockIdmAccountRefId, 'any'); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when update Username', () => { + const setup = () => { + accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); + }; + it('should map result correctly', async () => { + setup(); + const ret = await accountIdmService.updateUsername(mockIdmAccountRefId, 'any'); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('updatePassword', () => { - it('should map result correctly', async () => { - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); - const ret = await accountIdmService.updatePassword(mockIdmAccountRefId, 'any'); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when update password', () => { + const setup = () => { + accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); + }; + it('should map result correctly', async () => { + setup(); + const ret = await accountIdmService.updatePassword(mockIdmAccountRefId, 'any'); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('validatePassword', () => { - const setup = (acceptPassword: boolean) => { - idmOauthServiceMock.resourceOwnerPasswordGrant.mockResolvedValue( - acceptPassword ? '{ "alg": "HS256", "typ": "JWT" }' : undefined - ); - }; - it('should validate password by checking JWT', async () => { - setup(true); - const ret = await accountIdmService.validatePassword( - { username: 'username' } as unknown as AccountDto, - 'password' - ); - expect(ret).toBe(true); - }); - it('should report wrong password, i. e. non successful JWT creation', async () => { - setup(false); - const ret = await accountIdmService.validatePassword( - { username: 'username' } as unknown as AccountDto, - 'password' - ); - expect(ret).toBe(false); + describe('when validate password', () => { + const setup = (acceptPassword: boolean) => { + idmOauthServiceMock.resourceOwnerPasswordGrant.mockResolvedValue( + acceptPassword ? '{ "alg": "HS256", "typ": "JWT" }' : undefined + ); + }; + it('should validate password by checking JWT', async () => { + setup(true); + const ret = await accountIdmService.validatePassword( + { username: 'username' } as unknown as AccountDto, + 'password' + ); + expect(ret).toBe(true); + }); + it('should report wrong password, i. e. non successful JWT creation', async () => { + setup(false); + const ret = await accountIdmService.validatePassword( + { username: 'username' } as unknown as AccountDto, + 'password' + ); + expect(ret).toBe(false); + }); }); }); @@ -248,7 +296,7 @@ describe('AccountIdmService', () => { accountLookupServiceMock.getExternalId.mockResolvedValue(null); }; - it('should throw error', async () => { + it('should throw account not found error', async () => { setup(); await expect(accountIdmService.delete(mockIdmAccountRefId)).rejects.toThrow(); }); @@ -256,16 +304,19 @@ describe('AccountIdmService', () => { }); describe('deleteByUserId', () => { - const setup = () => { - idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); - }; + describe('when deleting an account by user id', () => { + const setup = () => { + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); + const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); + return { deleteSpy }; + }; - it('should delete the account with given user id via repo', async () => { - setup(); - const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); + it('should delete the account with given user id via repo', async () => { + const { deleteSpy } = setup(); - await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); - expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); + await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); + expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); + }); }); }); @@ -287,7 +338,7 @@ describe('AccountIdmService', () => { idmServiceMock.findAccountById.mockRejectedValue(new Error()); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); await expect(accountIdmService.findById('notExistingId')).rejects.toThrow(); }); @@ -359,7 +410,7 @@ describe('AccountIdmService', () => { idmServiceMock.findAccountByDbcUserId.mockResolvedValue(undefined as unknown as IdmAccount); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); await expect(accountIdmService.findByUserIdOrFail('notExistingId')).rejects.toThrow(EntityNotFoundError); }); @@ -465,7 +516,7 @@ describe('AccountIdmService', () => { }); }); - it('findMany should throw', async () => { + it('findMany should throw not implemented Exception', async () => { await expect(accountIdmService.findMany(0, 0)).rejects.toThrow(NotImplementedException); }); }); diff --git a/apps/server/src/modules/account/services/account-idm.service.ts b/apps/server/src/modules/account/services/account-idm.service.ts index 68bcfb42bae..039db80eddf 100644 --- a/apps/server/src/modules/account/services/account-idm.service.ts +++ b/apps/server/src/modules/account/services/account-idm.service.ts @@ -1,13 +1,13 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, NotImplementedException } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; +import { IdentityManagementOauthService, IdentityManagementService } from '@shared/infra/identity-management'; import { Counted, EntityId, IdmAccount, IdmAccountUpdate } from '@shared/domain'; -import { IdentityManagementService, IdentityManagementOauthService } from '@shared/infra/identity-management'; import { LegacyLogger } from '@src/core/logger'; import { AccountIdmToDtoMapper } from '../mapper'; +import { AccountLookupService } from './account-lookup.service'; import { AbstractAccountService } from './account.service.abstract'; import { AccountDto, AccountSaveDto } from './dto'; -import { AccountLookupService } from './account-lookup.service'; @Injectable() export class AccountServiceIdm extends AbstractAccountService { @@ -27,6 +27,7 @@ export class AccountServiceIdm extends AbstractAccountService { return account; } + // TODO: this needs a better solution. probably needs followup meeting to come up with something async findMultipleByUserId(userIds: EntityId[]): Promise { const results = new Array(); for (const userId of userIds) { @@ -34,6 +35,7 @@ export class AccountServiceIdm extends AbstractAccountService { // eslint-disable-next-line no-await-in-loop results.push(await this.identityManager.findAccountByDbcUserId(userId)); } catch { + // TODO: dont simply forget errors. maybe use a filter instead? // ignore entry } } @@ -46,6 +48,7 @@ export class AccountServiceIdm extends AbstractAccountService { const result = await this.identityManager.findAccountByDbcUserId(userId); return this.accountIdmToDtoMapper.mapToDto(result); } catch { + // TODO: dont simply forget errors return null; } } @@ -93,8 +96,10 @@ export class AccountServiceIdm extends AbstractAccountService { attDbcUserId: accountDto.userId, attDbcSystemId: accountDto.systemId, }; + // TODO: probably do some method extraction here if (accountDto.id) { let idmId: string | undefined; + // TODO: extract into a method that hides the trycatch try { idmId = await this.getIdmAccountId(accountDto.id); } catch { diff --git a/apps/server/src/modules/account/services/account.service.abstract.ts b/apps/server/src/modules/account/services/account.service.abstract.ts index b2e198f6a86..d25dbc0ac4a 100644 --- a/apps/server/src/modules/account/services/account.service.abstract.ts +++ b/apps/server/src/modules/account/services/account.service.abstract.ts @@ -2,6 +2,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Counted, EntityId } from '@shared/domain'; import { AccountDto, AccountSaveDto } from './dto'; +// TODO: split functions which are only needed for feathers + export abstract class AbstractAccountService { abstract findById(id: EntityId): Promise; @@ -11,6 +13,7 @@ export abstract class AbstractAccountService { abstract findByUserIdOrFail(userId: EntityId): Promise; + // HINT: it would be preferable to use entityId here. Needs to be checked if this is blocked by lecacy code abstract findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise; abstract save(accountDto: AccountSaveDto): Promise; diff --git a/apps/server/src/modules/account/services/account.service.integration.spec.ts b/apps/server/src/modules/account/services/account.service.integration.spec.ts index d001925000b..5d5caa24263 100644 --- a/apps/server/src/modules/account/services/account.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account.service.integration.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { createMock } from '@golevelup/ts-jest'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import { EntityManager } from '@mikro-orm/mongodb'; @@ -158,95 +159,151 @@ describe('AccountService Integration', () => { ); }; - it('save should create a new account', async () => { - if (!isIdmReachable) return; - const account = await accountService.save(testAccount); - await compareDbAccount(account.id, account); - await compareIdmAccount(account.idmReferenceId ?? '', account); - }); + describe('save', () => { + describe('when account not exists', () => { + it('should create a new account', async () => { + if (!isIdmReachable) return; + const account = await accountService.save(testAccount); + await compareDbAccount(account.id, account); + await compareIdmAccount(account.idmReferenceId ?? '', account); + }); + }); - it('save should update existing account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const [dbId, idmId] = await createAccount(); - const originalAccount = await accountService.findById(dbId); - const updatedAccount = await accountService.save({ - ...originalAccount, - username: newUsername, + describe('when account exists', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const [dbId, idmId] = await createAccount(); + const originalAccount = await accountService.findById(dbId); + return { newUsername, dbId, idmId, originalAccount }; + }; + it('save should update existing account', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, idmId, originalAccount } = await setup(); + const updatedAccount = await accountService.save({ + ...originalAccount, + username: newUsername, + }); + await compareDbAccount(dbId, updatedAccount); + await compareIdmAccount(idmId, updatedAccount); + }); }); - await compareDbAccount(dbId, updatedAccount); - await compareIdmAccount(idmId, updatedAccount); - }); - it('save should create idm account for existing db account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const dbId = await createDbAccount(); - const originalAccount = await accountService.findById(dbId); - const updatedAccount = await accountService.save({ - ...originalAccount, - username: newUsername, + describe('when only db account exists', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const dbId = await createDbAccount(); + const originalAccount = await accountService.findById(dbId); + return { newUsername, dbId, originalAccount }; + }; + it('should create idm account for existing db account', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, originalAccount } = await setup(); + + const updatedAccount = await accountService.save({ + ...originalAccount, + username: newUsername, + }); + await compareDbAccount(dbId, updatedAccount); + await compareIdmAccount(updatedAccount.idmReferenceId ?? '', updatedAccount); + }); }); - await compareDbAccount(dbId, updatedAccount); - await compareIdmAccount(updatedAccount.idmReferenceId ?? '', updatedAccount); }); - it('updateUsername should update username', async () => { - if (!isIdmReachable) return; - const newUserName = 'jane.doe@mail.tld'; - const [dbId, idmId] = await createAccount(); - await accountService.updateUsername(dbId, newUserName); + describe('updateUsername', () => { + describe('when updating Username', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const [dbId, idmId] = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); + return { newUsername, dbId, idmId }; + }; + it('should update username', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, idmId } = await setup(); + + await accountService.updateUsername(dbId, newUsername); + const foundAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); + + expect(foundAccount).toEqual( + expect.objectContaining>({ + username: newUsername, + }) + ); + expect(foundDbAccount).toEqual( + expect.objectContaining>({ + username: newUsername, + }) + ); + }); + }); }); - it('updatePassword should update password', async () => { - if (!isIdmReachable) return; - const [dbId] = await createAccount(); + describe('updatePassword', () => { + describe('when updating password', () => { + const setup = async () => { + const [dbId] = await createAccount(); + + const foundDbAccountBefore = await accountRepo.findById(dbId); + const previousPasswordHash = foundDbAccountBefore.password; + const foundDbAccountAfter = await accountRepo.findById(dbId); - const foundDbAccountBefore = await accountRepo.findById(dbId); - const previousPasswordHash = foundDbAccountBefore.password; + return { dbId, previousPasswordHash, foundDbAccountAfter }; + }; + it('should update password', async () => { + if (!isIdmReachable) return; + const { dbId, previousPasswordHash, foundDbAccountAfter } = await setup(); - await expect(accountService.updatePassword(dbId, 'newPassword')).resolves.not.toThrow(); + await expect(accountService.updatePassword(dbId, 'newPassword')).resolves.not.toThrow(); - const foundDbAccountAfter = await accountRepo.findById(dbId); - expect(foundDbAccountAfter.password).not.toEqual(previousPasswordHash); + expect(foundDbAccountAfter.password).not.toEqual(previousPasswordHash); + }); + }); }); - it('delete should remove account', async () => { - if (!isIdmReachable) return; - const [dbId, idmId] = await createAccount(); - const foundIdmAccount = await identityManagementService.findAccountById(idmId); - expect(foundIdmAccount).toBeDefined(); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toBeDefined(); + describe('delete', () => { + describe('when delete an account', () => { + const setup = async () => { + const [dbId, idmId] = await createAccount(); + const foundIdmAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); + + return { dbId, idmId, foundIdmAccount, foundDbAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { dbId, idmId, foundIdmAccount, foundDbAccount } = await setup(); - await accountService.delete(dbId); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); - await expect(accountRepo.findById(dbId)).rejects.toThrow(); + expect(foundIdmAccount).toBeDefined(); + expect(foundDbAccount).toBeDefined(); + + await accountService.delete(dbId); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + await expect(accountRepo.findById(dbId)).rejects.toThrow(); + }); + }); }); - it('deleteByUserId should remove account', async () => { - if (!isIdmReachable) return; - const [dbId, idmId] = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toBeDefined(); + describe('deleteByUserId', () => { + describe('when delete an account by User Id', () => { + const setup = async () => { + const [dbId, idmId] = await createAccount(); + const foundIdmAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); - await accountService.deleteByUserId(testAccount.userId ?? ''); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); - await expect(accountRepo.findById(dbId)).rejects.toThrow(); + return { dbId, idmId, foundIdmAccount, foundDbAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { dbId, idmId, foundIdmAccount, foundDbAccount } = await setup(); + + expect(foundIdmAccount).toBeDefined(); + expect(foundDbAccount).toBeDefined(); + + await accountService.deleteByUserId(testAccount.userId ?? ''); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + await expect(accountRepo.findById(dbId)).rejects.toThrow(); + }); + }); }); }); diff --git a/apps/server/src/modules/account/services/account.service.spec.ts b/apps/server/src/modules/account/services/account.service.spec.ts index 33dc783d4eb..834bc5b0f89 100644 --- a/apps/server/src/modules/account/services/account.service.spec.ts +++ b/apps/server/src/modules/account/services/account.service.spec.ts @@ -63,402 +63,568 @@ describe('AccountService', () => { }); describe('findById', () => { - it('should call findById in accountServiceDb', async () => { - await expect(accountService.findById('id')).resolves.not.toThrow(); - expect(accountServiceDb.findById).toHaveBeenCalledTimes(1); + describe('When calling findById in accountService', () => { + it('should call findById in accountServiceDb', async () => { + await expect(accountService.findById('id')).resolves.not.toThrow(); + expect(accountServiceDb.findById).toHaveBeenCalledTimes(1); + }); + }); + + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findById('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + }); }); }); describe('findByUserId', () => { - it('should call findByUserId in accountServiceDb', async () => { - await expect(accountService.findByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUserId).toHaveBeenCalledTimes(1); + describe('When calling findByUserId in accountService', () => { + it('should call findByUserId in accountServiceDb', async () => { + await expect(accountService.findByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUserId).toHaveBeenCalledTimes(1); + }); + }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUserId).toHaveBeenCalledTimes(1); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should call findByUsernameAndSystemId in accountServiceDb', async () => { - await expect(accountService.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + describe('When calling findByUsernameAndSystemId in accountService', () => { + it('should call findByUsernameAndSystemId in accountServiceDb', async () => { + await expect(accountService.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + }); }); - }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('findMultipleByUserId', () => { - it('should call findMultipleByUserId in accountServiceDb', async () => { - await expect(accountService.findMultipleByUserId(['userId1, userId2'])).resolves.not.toThrow(); - expect(accountServiceDb.findMultipleByUserId).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + }); }); }); - describe('findByUserIdOrFail', () => { - it('should call findByUserIdOrFail in accountServiceDb', async () => { - await expect(accountService.findByUserIdOrFail('userId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUserIdOrFail).toHaveBeenCalledTimes(1); + describe('findMultipleByUserId', () => { + describe('When calling findMultipleByUserId in accountService', () => { + it('should call findMultipleByUserId in accountServiceDb', async () => { + await expect(accountService.findMultipleByUserId(['userId1, userId2'])).resolves.not.toThrow(); + expect(accountServiceDb.findMultipleByUserId).toHaveBeenCalledTimes(1); + }); }); - }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('save', () => { - it('should call save in accountServiceDb', async () => { - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceDb.save).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findMultipleByUserId(['userId'])).resolves.not.toThrow(); + expect(accountServiceIdm.findMultipleByUserId).toHaveBeenCalledTimes(1); + }); }); - it('should call save in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); + }); - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + describe('findByUserIdOrFail', () => { + describe('When calling findByUserIdOrFail in accountService', () => { + it('should call findByUserIdOrFail in accountServiceDb', async () => { + await expect(accountService.findByUserIdOrFail('userId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUserIdOrFail).toHaveBeenCalledTimes(1); + }); }); - it('should not call save in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceIdm.save).not.toHaveBeenCalled(); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUserIdOrFail('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUserIdOrFail).toHaveBeenCalledTimes(1); + }); }); }); - describe('saveWithValidation', () => { - it('should not sanitize username for external user', async () => { - const spy = jest.spyOn(accountService, 'save'); - const params: AccountSaveDto = { - username: ' John.Doe@domain.tld ', - systemId: 'ABC123', - }; - await accountService.saveWithValidation(params); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - username: ' John.Doe@domain.tld ', - }) - ); - spy.mockRestore(); - }); - it('should throw if username for a local user is not an email', async () => { - const params: AccountSaveDto = { - username: 'John Doe', - password: 'JohnsPassword', - }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username is not an email'); - }); - it('should not throw if username for an external user is not an email', async () => { - const params: AccountSaveDto = { - username: 'John Doe', - systemId: 'ABC123', - }; - await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + describe('save', () => { + describe('When calling save in accountService', () => { + it('should call save in accountServiceDb', async () => { + await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); + expect(accountServiceDb.save).toHaveBeenCalledTimes(1); + }); }); - it('should not throw if username for an external user is a ldap search string', async () => { - const params: AccountSaveDto = { - username: 'dc=schul-cloud,dc=org/fake.ldap', - systemId: 'ABC123', + describe('When calling save in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); }; - await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + it('should call save in accountServiceIdm', async () => { + setup(); + + await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); - it('should throw if no password is provided for an internal user', async () => { - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', + describe('When calling save in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('No password provided'); + it('should not call save in accountServiceIdm', async () => { + setup(); + + await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); + expect(accountServiceIdm.save).not.toHaveBeenCalled(); + }); }); - it('should throw if account already exists', async () => { - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - password: 'JohnsPassword', - userId: 'userId123', + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); }; - accountServiceDb.findByUserId.mockResolvedValueOnce({ id: 'foundAccount123' } as AccountDto); - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Account already exists'); - }); - it('should throw if username already exists', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - password: 'JohnsPassword', - }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.save({ username: 'username' })).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); }); - describe('updateUsername', () => { - it('should call updateUsername in accountServiceDb', async () => { - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceDb.updateUsername).toHaveBeenCalledTimes(1); + describe('saveWithValidation', () => { + describe('When calling saveWithValidation on accountService', () => { + const setup = () => { + const spy = jest.spyOn(accountService, 'save'); + return spy; + }; + it('should not sanitize username for external user', async () => { + const spy = setup(); + + const params: AccountSaveDto = { + username: ' John.Doe@domain.tld ', + systemId: 'ABC123', + }; + await accountService.saveWithValidation(params); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + username: ' John.Doe@domain.tld ', + }) + ); + spy.mockRestore(); + }); }); - it('should call updateUsername in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + describe('When username for a local user is not an email', () => { + it('should throw username is not an email error', async () => { + const params: AccountSaveDto = { + username: 'John Doe', + password: 'JohnsPassword', + }; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username is not an email'); + }); }); - it('should not call updateUsername in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).not.toHaveBeenCalled(); + describe('When username for an external user is not an email', () => { + it('should not throw an error', async () => { + const params: AccountSaveDto = { + username: 'John Doe', + systemId: 'ABC123', + }; + await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + }); }); - }); - describe('updateLastTriedFailedLogin', () => { - it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { - await expect(accountService.updateLastTriedFailedLogin('accountId', {} as Date)).resolves.not.toThrow(); - expect(accountServiceDb.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + describe('When username for an external user is a ldap search string', () => { + it('should not throw an error', async () => { + const params: AccountSaveDto = { + username: 'dc=schul-cloud,dc=org/fake.ldap', + systemId: 'ABC123', + }; + await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); + }); }); - }); - describe('updatePassword', () => { - it('should call updatePassword in accountServiceDb', async () => { - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceDb.updatePassword).toHaveBeenCalledTimes(1); + describe('When no password is provided for an internal user', () => { + it('should throw no password provided error', async () => { + const params: AccountSaveDto = { + username: 'john.doe@mail.tld', + }; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('No password provided'); + }); }); - it('should call updatePassword in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + describe('When account already exists', () => { + it('should throw account already exists', async () => { + const params: AccountSaveDto = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword', + userId: 'userId123', + }; + accountServiceDb.findByUserId.mockResolvedValueOnce({ id: 'foundAccount123' } as AccountDto); + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Account already exists'); + }); }); - it('should not call updatePassword in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).not.toHaveBeenCalled(); + describe('When username already exists', () => { + const setup = () => { + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + }; + it('should throw username already exists', async () => { + setup(); + const params: AccountSaveDto = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword', + }; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + }); }); - }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('validatePassword', () => { - const setup = () => { - configService.get.mockReturnValue(true); - return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); - }; - it('should call validatePassword in accountServiceDb', async () => { - await expect(accountService.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(0); - expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(1); - }); - it('should call validatePassword in accountServiceIdm if feature is enabled', async () => { - const service = setup(); - await expect(service.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); - expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(0); - expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + setup(); + await expect( + accountService.saveWithValidation({ username: 'username@mail.tld', password: 'password' }) + ).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); }); - describe('delete', () => { - it('should call delete in accountServiceDb', async () => { - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceDb.delete).toHaveBeenCalledTimes(1); + describe('updateUsername', () => { + describe('When calling updateUsername in accountService', () => { + it('should call updateUsername in accountServiceDb', async () => { + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceDb.updateUsername).toHaveBeenCalledTimes(1); + }); }); - it('should call delete in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); - }); - it('should not call delete in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When calling updateUsername in accountService if idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call updateUsername in accountServiceIdm', async () => { + setup(); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).not.toHaveBeenCalled(); + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + }); }); - }); - describe('deleteByUserId', () => { - it('should call deleteByUserId in accountServiceDb', async () => { - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceDb.deleteByUserId).toHaveBeenCalledTimes(1); - }); - it('should call deleteByUserId in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); + describe('When calling updateUsername in accountService if idm feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call updateUsername in accountServiceIdm', async () => { + setup(); - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).not.toHaveBeenCalled(); + }); }); - it('should not call deleteByUserId in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).not.toHaveBeenCalled(); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + }); }); }); - describe('findMany', () => { - it('should call findMany in accountServiceDb', async () => { - await expect(accountService.findMany()).resolves.not.toThrow(); - expect(accountServiceDb.findMany).toHaveBeenCalledTimes(1); + describe('updateLastTriedFailedLogin', () => { + describe('When calling updateLastTriedFailedLogin in accountService', () => { + it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { + await expect(accountService.updateLastTriedFailedLogin('accountId', {} as Date)).resolves.not.toThrow(); + expect(accountServiceDb.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + }); }); - }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('searchByUsernamePartialMatch', () => { - it('should call searchByUsernamePartialMatch in accountServiceDb', async () => { - await expect(accountService.searchByUsernamePartialMatch('username', 1, 1)).resolves.not.toThrow(); - expect(accountServiceDb.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.updateLastTriedFailedLogin('accountId', new Date())).resolves.not.toThrow(); + expect(accountServiceIdm.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + }); }); }); - describe('searchByUsernameExactMatch', () => { - it('should call searchByUsernameExactMatch in accountServiceDb', async () => { - await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); - expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + describe('updatePassword', () => { + describe('When calling updatePassword in accountService', () => { + it('should call updatePassword in accountServiceDb', async () => { + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceDb.updatePassword).toHaveBeenCalledTimes(1); + }); }); - }); - describe('executeIdmMethod', () => { - it('should throw an error object', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - const spyLogger = jest.spyOn(logger, 'error'); - const testError = new Error('error'); + describe('When calling updatePassword in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call updatePassword in accountServiceIdm', async () => { + setup(); - const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); - deleteByUserIdMock.mockImplementationOnce(() => { - throw testError; + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); }); - - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(spyLogger).toHaveBeenCalledWith(testError, expect.anything()); }); - it('should throw an non error object', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - const spyLogger = jest.spyOn(logger, 'error'); + describe('When calling updatePassword in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call updatePassword in accountServiceIdm', async () => { + setup(); - const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); - deleteByUserIdMock.mockImplementationOnce(() => { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw 'a non error object'; + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).not.toHaveBeenCalled(); }); + }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(spyLogger).toHaveBeenCalledWith('a non error object'); + it('should call idm implementation', async () => { + setup(); + await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + }); }); }); - describe('when identity management is primary', () => { - const setup = () => { - configService.get.mockReturnValue(true); - return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); - }; - - describe('findById', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findById('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + describe('validatePassword', () => { + describe('When calling validatePassword in accountService', () => { + it('should call validatePassword in accountServiceDb', async () => { + await expect(accountService.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(0); + expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(1); }); }); - describe('findMultipleByUserId', () => { - it('should call idm implementation', async () => { + describe('When calling validatePassword in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; + it('should call validatePassword in accountServiceIdm', async () => { const service = setup(); - await expect(service.findMultipleByUserId(['userId'])).resolves.not.toThrow(); - expect(accountServiceIdm.findMultipleByUserId).toHaveBeenCalledTimes(1); + await expect(service.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); + expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(0); + expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(1); }); }); + }); - describe('findByUserId', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUserId).toHaveBeenCalledTimes(1); + describe('delete', () => { + describe('When calling delete in accountService', () => { + it('should call delete in accountServiceDb', async () => { + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceDb.delete).toHaveBeenCalledTimes(1); }); }); - describe('findByUserIdOrFail', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUserIdOrFail('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUserIdOrFail).toHaveBeenCalledTimes(1); + describe('When calling delete in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call delete in accountServiceIdm', async () => { + setup(); + + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); }); }); - describe('findByUsernameAndSystemId', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + describe('When calling delete in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call delete in accountServiceIdm', async () => { + setup(); + + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).not.toHaveBeenCalled(); }); }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('searchByUsernamePartialMatch', () => { it('should call idm implementation', async () => { - const service = setup(); - await expect(service.searchByUsernamePartialMatch('username', 0, 1)).resolves.not.toThrow(); - expect(accountServiceIdm.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + setup(); + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); }); }); + }); - describe('searchByUsernameExactMatch', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.searchByUsernameExactMatch('username')).resolves.not.toThrow(); - expect(accountServiceIdm.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + describe('deleteByUserId', () => { + describe('When calling deleteByUserId in accountService', () => { + it('should call deleteByUserId in accountServiceDb', async () => { + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceDb.deleteByUserId).toHaveBeenCalledTimes(1); }); }); - describe('save', () => { - it('should call idm implementation', async () => { + describe('When calling deleteByUserId in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + it('should call deleteByUserId in accountServiceIdm', async () => { setup(); - await expect(accountService.save({ username: 'username' })).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); }); }); - describe('saveWithValidation', () => { - it('should call idm implementation', async () => { + describe('When calling deleteByUserId in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + it('should not call deleteByUserId in accountServiceIdm', async () => { setup(); - await expect( - accountService.saveWithValidation({ username: 'username@mail.tld', password: 'password' }) - ).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).not.toHaveBeenCalled(); }); }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('updateUsername', () => { it('should call idm implementation', async () => { setup(); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); }); }); + }); - describe('updateLastTriedFailedLogin', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.updateLastTriedFailedLogin('accountId', new Date())).resolves.not.toThrow(); - expect(accountServiceIdm.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + describe('findMany', () => { + describe('When calling findMany in accountService', () => { + it('should call findMany in accountServiceDb', async () => { + await expect(accountService.findMany()).resolves.not.toThrow(); + expect(accountServiceDb.findMany).toHaveBeenCalledTimes(1); }); }); + }); - describe('updatePassword', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + describe('searchByUsernamePartialMatch', () => { + describe('When calling searchByUsernamePartialMatch in accountService', () => { + it('should call searchByUsernamePartialMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernamePartialMatch('username', 1, 1)).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); }); }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('delete', () => { it('should call idm implementation', async () => { - setup(); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); + const service = setup(); + await expect(service.searchByUsernamePartialMatch('username', 0, 1)).resolves.not.toThrow(); + expect(accountServiceIdm.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); }); }); + }); + + describe('searchByUsernameExactMatch', () => { + describe('When calling searchByUsernameExactMatch in accountService', () => { + it('should call searchByUsernameExactMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); + }; - describe('deleteByUserId', () => { it('should call idm implementation', async () => { - setup(); + const service = setup(); + await expect(service.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceIdm.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('executeIdmMethod', () => { + describe('When idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + const testError = new Error('error'); + accountServiceIdm.deleteByUserId.mockImplementationOnce(() => { + throw testError; + }); + + const spyLogger = jest.spyOn(logger, 'error'); + + return { testError, spyLogger }; + }; + it('should call executeIdmMethod and throw an error object', async () => { + const { testError, spyLogger } = setup(); + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith(testError, expect.anything()); + }); + }); + + describe('When idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + const spyLogger = jest.spyOn(logger, 'error'); + const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); + deleteByUserIdMock.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'a non error object'; + }); + return { spyLogger }; + }; + it('should call executeIdmMethod and throw an error object', async () => { + const { spyLogger } = setup(); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(spyLogger).toHaveBeenCalledWith('a non error object'); }); }); }); diff --git a/apps/server/src/modules/account/services/account.service.ts b/apps/server/src/modules/account/services/account.service.ts index 6c2070550ab..3c8a5ff2058 100644 --- a/apps/server/src/modules/account/services/account.service.ts +++ b/apps/server/src/modules/account/services/account.service.ts @@ -4,7 +4,8 @@ import { ConfigService } from '@nestjs/config'; import { ValidationError } from '@shared/common'; import { Counted } from '@shared/domain'; import { isEmail, validateOrReject } from 'class-validator'; -import { LegacyLogger } from '../../../core/logger'; +import { LegacyLogger } from '../../../core/logger'; // TODO: use path alias +// TODO: account needs to define its own config, which is made available for the server import { IServerConfig } from '../../server/server.config'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; @@ -12,6 +13,11 @@ import { AbstractAccountService } from './account.service.abstract'; import { AccountValidationService } from './account.validation.service'; import { AccountDto, AccountSaveDto } from './dto'; +/* TODO: extract a service that contains all things required by feathers, +which is responsible for the additionally required validation + +it should be clearly visible which functions are only needed for feathers, and easy to remove them */ + @Injectable() export class AccountService extends AbstractAccountService { private readonly accountImpl: AbstractAccountService; @@ -78,6 +84,7 @@ export class AccountService extends AbstractAccountService { } async saveWithValidation(dto: AccountSaveDto): Promise { + // TODO: move as much as possible into the class validator await validateOrReject(dto); // sanatizeUsername ✔ if (!dto.systemId) { @@ -108,6 +115,7 @@ export class AccountService extends AbstractAccountService { // dto.password = undefined; // } + // TODO: split validation from saving, so it can be used independently await this.save(dto); } diff --git a/apps/server/src/modules/account/services/account.validation.service.spec.ts b/apps/server/src/modules/account/services/account.validation.service.spec.ts index dba1e2bf02a..c152f01a59b 100644 --- a/apps/server/src/modules/account/services/account.validation.service.spec.ts +++ b/apps/server/src/modules/account/services/account.validation.service.spec.ts @@ -1,9 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { Account, EntityId, Permission, Role, RoleName, User } from '@shared/domain'; +import { Permission, Role, RoleName } from '@shared/domain'; import { UserRepo } from '@shared/repo'; import { accountFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; import { ObjectId } from 'bson'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { AccountRepo } from '../repo/account.repo'; import { AccountValidationService } from './account.validation.service'; @@ -11,26 +11,8 @@ describe('AccountValidationService', () => { let module: TestingModule; let accountValidationService: AccountValidationService; - let mockTeacherUser: User; - let mockTeacherAccount: Account; - - let mockStudentUser: User; - let mockStudentAccount: Account; - - let mockOtherTeacherUser: User; - let mockOtherTeacherAccount: Account; - - let mockAdminUser: User; - - let mockExternalUser: User; - let mockExternalUserAccount: Account; - let mockOtherExternalUser: User; - let mockOtherExternalUserAccount: Account; - - let oprhanAccount: Account; - - let mockUsers: User[]; - let mockAccounts: Account[]; + let userRepo: DeepMocked; + let accountRepo: DeepMocked; afterAll(async () => { await module.close(); @@ -42,237 +24,405 @@ describe('AccountValidationService', () => { AccountValidationService, { provide: AccountRepo, - useValue: { - findById: jest.fn().mockImplementation((accountId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }), - searchByUsernameExactMatch: jest - .fn() - .mockImplementation((username: string): Promise<[Account[], number]> => { - const account = mockAccounts.find((tempAccount) => tempAccount.username === username); - - if (account) { - return Promise.resolve([[account], 1]); - } - if (username === 'not@available.username') { - return Promise.resolve([[mockOtherTeacherAccount], 1]); - } - if (username === 'multiple@account.username') { - return Promise.resolve([mockAccounts, mockAccounts.length]); - } - return Promise.resolve([[], 0]); - }), - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - }, + useValue: createMock(), }, { provide: UserRepo, - useValue: { - findById: jest.fn().mockImplementation((userId: EntityId): Promise => { - const user = mockUsers.find((tempUser) => tempUser.id === userId); - if (user) { - return Promise.resolve(user); - } - throw new EntityNotFoundError(User.name); - }), - findByEmail: jest.fn().mockImplementation((email: string): Promise => { - const user = mockUsers.find((tempUser) => tempUser.email === email); - - if (user) { - return Promise.resolve([user]); - } - if (email === 'multiple@user.email') { - return Promise.resolve(mockUsers); - } - return Promise.resolve([]); - }), - }, + useValue: createMock(), }, ], }).compile(); accountValidationService = module.get(AccountValidationService); + + userRepo = module.get(UserRepo); + accountRepo = module.get(AccountRepo); + await setupEntities(); }); beforeEach(() => { - mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); + jest.resetAllMocks(); + }); - mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); - mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); - mockOtherTeacherAccount = accountFactory.buildWithId({ - userId: mockOtherTeacherUser.id, - }); - const externalSystemA = systemFactory.buildWithId(); - const externalSystemB = systemFactory.buildWithId(); - mockExternalUserAccount = accountFactory.buildWithId({ - userId: mockExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemA.id, + describe('isUniqueEmail', () => { + describe('When new email is available', () => { + const setup = () => { + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + }; + it('should return true', async () => { + setup(); + + const res = await accountValidationService.isUniqueEmail('an@available.email'); + expect(res).toBe(true); + }); }); - mockOtherExternalUserAccount = accountFactory.buildWithId({ - userId: mockOtherExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemB.id, + + describe('When new email is available', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + + return { mockStudentUser }; + }; + it('should return true and ignore current user', async () => { + const { mockStudentUser } = setup(); + const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); + expect(res).toBe(true); + }); }); - oprhanAccount = accountFactory.buildWithId({ - username: 'orphan@account', - userId: undefined, - systemId: new ObjectId(), + describe('When new email is available', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return true and ignore current users account', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockStudentAccount.username, + mockStudentUser.id, + mockStudentAccount.id + ); + expect(res).toBe(true); + }); }); - mockAccounts = [ - mockTeacherAccount, - mockStudentAccount, - mockOtherTeacherAccount, - mockExternalUserAccount, - mockOtherExternalUserAccount, - oprhanAccount, - ]; - mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, + describe('When new email already in use by another user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockAdminUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), ], - }), - ], - }); - mockUsers = [ - mockTeacherUser, - mockStudentUser, - mockOtherTeacherUser, - mockAdminUser, - mockExternalUser, - mockOtherExternalUser, - ]; - }); + }); + const mockAdminAccount = accountFactory.buildWithId({ userId: mockAdminUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); - describe('isUniqueEmail', () => { - it('should return true if new email is available', async () => { - const res = await accountValidationService.isUniqueEmail('an@available.email'); - expect(res).toBe(true); - }); - it('should return true if new email is available and ignore current user', async () => { - const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - it('should return true if new email is available and ignore current users account', async () => { - const res = await accountValidationService.isUniqueEmail( - mockStudentAccount.username, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - it('should return false if new email already in use by another user', async () => { - const res = await accountValidationService.isUniqueEmail( - mockAdminUser.email, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(false); + userRepo.findByEmail.mockResolvedValueOnce([mockAdminUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockAdminAccount], 1]); + + return { mockAdminUser, mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockAdminUser, mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockAdminUser.email, + mockStudentUser.id, + mockStudentAccount.id + ); + expect(res).toBe(false); + }); }); - it('should return false if new email is already in use by any user, system id is given', async () => { - const res = await accountValidationService.isUniqueEmail( - mockTeacherAccount.username, - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by any user and system id is given', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockTeacherUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockTeacherAccount], 1]); + + return { mockTeacherAccount, mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockTeacherAccount, mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockTeacherAccount.username, + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should return false if new email already in use by multiple users', async () => { - const res = await accountValidationService.isUniqueEmail( - 'multiple@user.email', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by multiple users', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + const mockUsers = [mockTeacherUser, mockStudentUser, mockOtherTeacherUser]; + + userRepo.findByEmail.mockResolvedValueOnce(mockUsers); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + 'multiple@user.email', + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should return false if new email already in use by multiple accounts', async () => { - const res = await accountValidationService.isUniqueEmail( - 'multiple@account.username', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by multiple accounts', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + }); + + const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockOtherTeacherAccount]; + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([mockAccounts, mockAccounts.length]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + 'multiple@account.username', + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should ignore existing username if other system', async () => { - const res = await accountValidationService.isUniqueEmail( - mockExternalUser.email, - mockExternalUser.id, - mockExternalUserAccount.id, - mockOtherExternalUserAccount.systemId?.toString() - ); - expect(res).toBe(true); + + describe('When its another system', () => { + const setup = () => { + const mockExternalUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherExternalUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const externalSystemA = systemFactory.buildWithId(); + const externalSystemB = systemFactory.buildWithId(); + const mockExternalUserAccount = accountFactory.buildWithId({ + userId: mockExternalUser.id, + username: 'unique.within@system', + systemId: externalSystemA.id, + }); + const mockOtherExternalUserAccount = accountFactory.buildWithId({ + userId: mockOtherExternalUser.id, + username: 'unique.within@system', + systemId: externalSystemB.id, + }); + + userRepo.findByEmail.mockResolvedValueOnce([mockExternalUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockExternalUserAccount], 1]); + + return { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount }; + }; + it('should ignore existing username', async () => { + const { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockExternalUser.email, + mockExternalUser.id, + mockExternalUserAccount.id, + mockOtherExternalUserAccount.systemId?.toString() + ); + expect(res).toBe(true); + }); }); }); describe('isUniqueEmailForUser', () => { - it('should return true, if its the email of the given user', async () => { - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); + describe('When its the email of the given user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findByUserId.mockResolvedValueOnce(mockStudentAccount); + + return { mockStudentUser }; + }; + it('should return true', async () => { + const { mockStudentUser } = setup(); + const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); + expect(res).toBe(true); + }); }); - it('should return false, if not the given users email', async () => { - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); - expect(res).toBe(false); + + describe('When its not the given users email', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + const mockAdminUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockAdminAccount = accountFactory.buildWithId({ userId: mockAdminUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findByUserId.mockResolvedValueOnce(mockAdminAccount); + + return { mockStudentUser, mockAdminUser }; + }; + it('should return false', async () => { + const { mockStudentUser, mockAdminUser } = setup(); + const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); + expect(res).toBe(false); + }); }); }); describe('isUniqueEmailForAccount', () => { - it('should return true, if its the email of the given user', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(mockStudentUser.email, mockStudentAccount.id); - expect(res).toBe(true); + describe('When its the email of the given user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findById.mockResolvedValueOnce(mockStudentAccount); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return true', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount( + mockStudentUser.email, + mockStudentAccount.id + ); + expect(res).toBe(true); + }); }); - it('should return false, if not the given users email', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(mockStudentUser.email, mockTeacherAccount.id); - expect(res).toBe(false); + describe('When its not the given users email', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findById.mockResolvedValueOnce(mockTeacherAccount); + + return { mockStudentUser, mockTeacherAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockTeacherAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount( + mockStudentUser.email, + mockTeacherAccount.id + ); + expect(res).toBe(false); + }); }); - it('should ignore missing user for a given account', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); - expect(res).toBe(true); + + describe('When user is missing in account', () => { + const setup = () => { + const oprhanAccount = accountFactory.buildWithId({ + username: 'orphan@account', + userId: undefined, + systemId: new ObjectId(), + }); + + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + accountRepo.findById.mockResolvedValueOnce(oprhanAccount); + + return { oprhanAccount }; + }; + it('should ignore missing user for given account', async () => { + const { oprhanAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); + expect(res).toBe(true); + }); }); }); }); diff --git a/apps/server/src/modules/account/services/account.validation.service.ts b/apps/server/src/modules/account/services/account.validation.service.ts index fc47569ed71..2cabc9eabb3 100644 --- a/apps/server/src/modules/account/services/account.validation.service.ts +++ b/apps/server/src/modules/account/services/account.validation.service.ts @@ -5,9 +5,11 @@ import { AccountEntityToDtoMapper } from '../mapper/account-entity-to-dto.mapper import { AccountRepo } from '../repo/account.repo'; @Injectable() +// TODO: naming? export class AccountValidationService { constructor(private accountRepo: AccountRepo, private userRepo: UserRepo) {} + // TODO: this should be refactored and rewritten more nicely async isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { const [foundUsers, [accounts]] = await Promise.all([ // Test coverage: Missing branch null check; unreachable @@ -27,12 +29,12 @@ export class AccountValidationService { } async isUniqueEmailForUser(email: string, userId: EntityId): Promise { - const account = await this.accountRepo.findByUserId(userId); + const account = await this.accountRepo.findByUserId(userId); // TODO: findOrFail? return this.isUniqueEmail(email, userId, account?.id, account?.systemId?.toString()); } async isUniqueEmailForAccount(email: string, accountId: EntityId): Promise { - const account = await this.accountRepo.findById(accountId); + const account = await this.accountRepo.findById(accountId); // TODO: findOrFail? return this.isUniqueEmail(email, account.userId?.toString(), account.id, account?.systemId?.toString()); } } diff --git a/apps/server/src/modules/account/services/dto/account.dto.ts b/apps/server/src/modules/account/services/dto/account.dto.ts index 760be1f2453..c3765576e50 100644 --- a/apps/server/src/modules/account/services/dto/account.dto.ts +++ b/apps/server/src/modules/account/services/dto/account.dto.ts @@ -1,6 +1,7 @@ import { EntityId } from '@shared/domain'; import { AccountSaveDto } from './account-save.dto'; +// TODO: this vs account-save.dto? please clean up :) export class AccountDto extends AccountSaveDto { readonly id: EntityId; diff --git a/apps/server/src/modules/account/uc/account.uc.spec.ts b/apps/server/src/modules/account/uc/account.uc.spec.ts index 0df73ab93d9..e210a5c9ab5 100644 --- a/apps/server/src/modules/account/uc/account.uc.spec.ts +++ b/apps/server/src/modules/account/uc/account.uc.spec.ts @@ -4,13 +4,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthorizationError, EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; import { Account, - Counted, EntityId, Permission, PermissionService, Role, RoleName, - SchoolEntity, SchoolRolePermission, SchoolRoles, User, @@ -37,62 +35,19 @@ import { AccountUc } from './account.uc'; describe('AccountUc', () => { let module: TestingModule; let accountUc: AccountUc; - let userRepo: UserRepo; - let accountService: AccountService; - let accountValidationService: AccountValidationService; + let userRepo: DeepMocked; + let accountService: DeepMocked; + let accountValidationService: DeepMocked; let configService: DeepMocked; - let mockSchool: SchoolEntity; - let mockOtherSchool: SchoolEntity; - let mockSchoolWithStudentVisibility: SchoolEntity; - - let mockSuperheroUser: User; - let mockAdminUser: User; - let mockTeacherUser: User; - let mockOtherTeacherUser: User; - let mockTeacherNoUserNoSchoolPermissionUser: User; - let mockTeacherNoUserPermissionUser: User; - let mockStudentSchoolPermissionUser: User; - let mockStudentUser: User; - let mockOtherStudentUser: User; - let mockDifferentSchoolAdminUser: User; - let mockDifferentSchoolTeacherUser: User; - let mockDifferentSchoolStudentUser: User; - let mockUnknownRoleUser: User; - let mockExternalUser: User; - let mockUserWithoutAccount: User; - let mockUserWithoutRole: User; - let mockStudentUserWithoutAccount: User; - let mockOtherStudentSchoolPermissionUser: User; - - let mockSuperheroAccount: Account; - let mockTeacherAccount: Account; - let mockOtherTeacherAccount: Account; - let mockTeacherNoUserPermissionAccount: Account; - let mockTeacherNoUserNoSchoolPermissionAccount: Account; - let mockAdminAccount: Account; - let mockStudentAccount: Account; - let mockStudentSchoolPermissionAccount: Account; - let mockDifferentSchoolAdminAccount: Account; - let mockDifferentSchoolTeacherAccount: Account; - let mockDifferentSchoolStudentAccount: Account; - let mockUnknownRoleUserAccount: Account; - let mockExternalUserAccount: Account; - let mockAccountWithoutRole: Account; - let mockAccountWithoutUser: Account; - let mockAccountWithSystemId: Account; - let mockAccountWithLastFailedLogin: Account; - let mockAccountWithOldLastFailedLogin: Account; - let mockAccountWithNoLastFailedLogin: Account; - let mockAccounts: Account[]; - let mockUsers: User[]; - const defaultPassword = 'DummyPasswd!1'; const otherPassword = 'DummyPasswd!2'; const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; const LOGIN_BLOCK_TIME = 15; afterAll(async () => { + jest.restoreAllMocks(); + jest.resetAllMocks(); await module.close(); }); @@ -102,103 +57,7 @@ describe('AccountUc', () => { AccountUc, { provide: AccountService, - useValue: { - saveWithValidation: jest.fn().mockImplementation((account: AccountDto): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find( - (tempAccount) => tempAccount.userId?.toString() === account.userId - ); - if (accountEntity) { - Object.assign(accountEntity, account); - return Promise.resolve(); - } - return Promise.reject(); - }), - save: jest.fn().mockImplementation((account: AccountDto): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find( - (tempAccount) => tempAccount.userId?.toString() === account.userId - ); - if (accountEntity) { - Object.assign(accountEntity, account); - return Promise.resolve(); - } - return Promise.reject(); - }), - delete: (id: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id?.toString() === id); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - create: (): Promise => Promise.resolve(), - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - return Promise.resolve(null); - }, - findByUserIdOrFail: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - if (userId === 'accountWithoutUser') { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); - } - throw new EntityNotFoundError(Account.name); - }, - findById: (accountId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - findByUsernameAndSystemId: (username: string, systemId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find( - (tempAccount) => tempAccount.username === username && tempAccount.systemId === systemId - ); - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - searchByUsernameExactMatch: (username: string): Promise> => { - const account = mockAccounts.find((tempAccount) => tempAccount.username === username); - - if (account) { - return Promise.resolve([[AccountEntityToDtoMapper.mapToDto(account)], 1]); - } - if (username === 'not@available.username') { - return Promise.resolve([[AccountEntityToDtoMapper.mapToDto(mockOtherTeacherAccount)], 1]); - } - if (username === 'multiple@account.username') { - return Promise.resolve([ - mockAccounts.map((mockAccount) => AccountEntityToDtoMapper.mapToDto(mockAccount)), - mockAccounts.length, - ]); - } - return Promise.resolve([[], 0]); - }, - searchByUsernamePartialMatch: (): Promise> => - Promise.resolve([ - mockAccounts.map((mockAccount) => AccountEntityToDtoMapper.mapToDto(mockAccount)), - mockAccounts.length, - ]), - updateLastTriedFailedLogin: jest.fn(), - validatePassword: jest.fn().mockResolvedValue(true), - }, + useValue: createMock(), }, { provide: ConfigService, @@ -206,42 +65,12 @@ describe('AccountUc', () => { }, { provide: UserRepo, - useValue: { - findById: (userId: EntityId): Promise => { - const user = mockUsers.find((tempUser) => tempUser.id === userId); - if (user) { - return Promise.resolve(user); - } - throw new EntityNotFoundError(User.name); - }, - findByEmail: (email: string): Promise => { - const user = mockUsers.find((tempUser) => tempUser.email === email); - - if (user) { - return Promise.resolve([user]); - } - if (email === 'not@available.email') { - return Promise.resolve([mockExternalUser]); - } - if (email === 'multiple@user.email') { - return Promise.resolve(mockUsers); - } - return Promise.resolve([]); - }, - save: jest.fn().mockImplementation((user: User): Promise => { - if (user.firstName === 'failToUpdate' || user.email === 'user-fail@to.update') { - return Promise.reject(); - } - return Promise.resolve(); - }), - }, + useValue: createMock(), }, PermissionService, { provide: AccountValidationService, - useValue: { - isUniqueEmail: jest.fn().mockResolvedValue(true), - }, + useValue: createMock(), }, ], }).compile(); @@ -249,983 +78,3052 @@ describe('AccountUc', () => { accountUc = module.get(AccountUc); userRepo = module.get(UserRepo); accountService = module.get(AccountService); - await setupEntities(); accountValidationService = module.get(AccountValidationService); configService = module.get(ConfigService); + await setupEntities(); }); beforeEach(() => { - mockSchool = schoolFactory.buildWithId(); - mockOtherSchool = schoolFactory.buildWithId(); - mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); - mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); - mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); - mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; - - mockSuperheroUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.SUPERHERO, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], - }), - ], + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe('updateMyAccount', () => { + describe('When user does not exist', () => { + const setup = () => { + userRepo.findById.mockImplementation(() => { + throw new EntityNotFoundError(User.name); + }); + }; + + it('should throw EntityNotFoundError', async () => { + setup(); + await expect(accountUc.updateMyAccount('accountWithoutUser', { passwordOld: defaultPassword })).rejects.toThrow( + EntityNotFoundError + ); + }); }); - mockAdminUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, + + describe('When account does not exists', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), ], - }), - ], - }); - mockTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockOtherTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockTeacherNoUserPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [], - }), - ], - }); - mockTeacherNoUserNoSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [], - }), - ], - }); - mockStudentSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherStudentSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockDifferentSchoolAdminUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockAdminUser.roles], - }); - mockDifferentSchoolTeacherUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockTeacherUser.roles], - }); - mockDifferentSchoolStudentUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockStudentUser.roles], - }); - mockUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], - }), - ], - }); - mockUserWithoutRole = userFactory.buildWithId({ - school: mockSchool, - roles: [], - }); - mockUnknownRoleUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], - }); - mockExternalUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockStudentUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); + }); - mockSuperheroAccount = accountFactory.buildWithId({ - userId: mockSuperheroUser.id, - password: defaultPasswordHash, - }); - mockTeacherAccount = accountFactory.buildWithId({ - userId: mockTeacherUser.id, - password: defaultPasswordHash, - }); - mockOtherTeacherAccount = accountFactory.buildWithId({ - userId: mockOtherTeacherUser.id, - password: defaultPasswordHash, - }); - mockTeacherNoUserPermissionAccount = accountFactory.buildWithId({ - userId: mockTeacherNoUserPermissionUser.id, - password: defaultPasswordHash, - }); - mockTeacherNoUserNoSchoolPermissionAccount = accountFactory.buildWithId({ - userId: mockTeacherNoUserNoSchoolPermissionUser.id, - password: defaultPasswordHash, - }); - mockAdminAccount = accountFactory.buildWithId({ - userId: mockAdminUser.id, - password: defaultPasswordHash, - }); - mockStudentAccount = accountFactory.buildWithId({ - userId: mockStudentUser.id, - password: defaultPasswordHash, - }); - mockStudentSchoolPermissionAccount = accountFactory.buildWithId({ - userId: mockStudentSchoolPermissionUser.id, - password: defaultPasswordHash, - }); - mockAccountWithoutRole = accountFactory.buildWithId({ - userId: mockUserWithoutRole.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolAdminAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolAdminUser.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolTeacherAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolTeacherUser.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolStudentAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolStudentUser.id, - password: defaultPasswordHash, - }); - mockUnknownRoleUserAccount = accountFactory.buildWithId({ - userId: mockUnknownRoleUser.id, - password: defaultPasswordHash, - }); - const externalSystem = systemFactory.buildWithId(); - mockExternalUserAccount = accountFactory.buildWithId({ - userId: mockExternalUser.id, - password: defaultPasswordHash, - systemId: externalSystem.id, - }); - mockAccountWithoutUser = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - }); - mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); - mockAccountWithLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - lasttriedFailedLogin: new Date(), - }); - mockAccountWithOldLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - lasttriedFailedLogin: new Date(new Date().getTime() - LOGIN_BLOCK_TIME - 1), - }); - mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemFactory.buildWithId().id, - lasttriedFailedLogin: undefined, - }); + accountService.findByUserIdOrFail.mockImplementation((): Promise => { + throw new EntityNotFoundError(Account.name); + }); - mockUsers = [ - mockSuperheroUser, - mockAdminUser, - mockTeacherUser, - mockOtherTeacherUser, - mockTeacherNoUserPermissionUser, - mockTeacherNoUserNoSchoolPermissionUser, - mockStudentUser, - mockStudentSchoolPermissionUser, - mockDifferentSchoolAdminUser, - mockDifferentSchoolTeacherUser, - mockDifferentSchoolStudentUser, - mockUnknownRoleUser, - mockExternalUser, - mockUserWithoutRole, - mockUserWithoutAccount, - mockStudentUserWithoutAccount, - mockOtherStudentUser, - mockOtherStudentSchoolPermissionUser, - ]; - - mockAccounts = [ - mockSuperheroAccount, - mockAdminAccount, - mockTeacherAccount, - mockOtherTeacherAccount, - mockTeacherNoUserPermissionAccount, - mockTeacherNoUserNoSchoolPermissionAccount, - mockStudentAccount, - mockStudentSchoolPermissionAccount, - mockDifferentSchoolAdminAccount, - mockDifferentSchoolTeacherAccount, - mockDifferentSchoolStudentAccount, - mockUnknownRoleUserAccount, - mockExternalUserAccount, - mockAccountWithoutRole, - mockAccountWithoutUser, - mockAccountWithSystemId, - mockAccountWithLastFailedLogin, - mockAccountWithOldLastFailedLogin, - mockAccountWithNoLastFailedLogin, - ]; - }); + return { mockUserWithoutAccount }; + }; - describe('updateMyAccount', () => { - it('should throw if user does not exist', async () => { - mockStudentUser.forcePasswordChange = true; - mockStudentUser.preferences = { firstLogin: true }; - await expect(accountUc.updateMyAccount('accountWithoutUser', { passwordOld: defaultPassword })).rejects.toThrow( - EntityNotFoundError - ); - }); - it('should throw if account does not exist', async () => { - await expect( - accountUc.updateMyAccount(mockUserWithoutAccount.id, { - passwordOld: defaultPassword, - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account is external', async () => { - await expect( - accountUc.updateMyAccount(mockExternalUserAccount.userId?.toString() ?? '', { - passwordOld: defaultPassword, - }) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw if password does not match', async () => { - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: 'DoesNotMatch', - }) - ).rejects.toThrow(AuthorizationError); - }); - it('should throw if changing own name is not allowed', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).rejects.toThrow(ForbiddenOperationError); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should allow to update email', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: 'an@available.mail', - }) - ).resolves.not.toThrow(); - }); - it('should use email as account user name in lower case', async () => { - const accountSaveSpy = jest.spyOn(accountService, 'save'); - const testMail = 'AN@AVAILABLE.MAIL'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); - }); - it('should use email as user email in lower case', async () => { - const userUpdateSpy = jest.spyOn(userRepo, 'save'); - const testMail = 'AN@AVAILABLE.MAIL'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); - }); - it('should always update account user name AND user email together.', async () => { - const accountSaveSpy = jest.spyOn(accountService, 'save'); - const userUpdateSpy = jest.spyOn(userRepo, 'save'); - const testMail = 'an@available.mail'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); - expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); - }); - it('should throw if new email already in use', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: mockAdminUser.email, - }) - ).rejects.toThrow(ValidationError); - }); - it('should allow to update with strong password', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - passwordNew: otherPassword, - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if teacher', async () => { - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if admin', async () => { - await expect( - accountUc.updateMyAccount(mockAdminUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockAdminUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if superhero', async () => { - await expect( - accountUc.updateMyAccount(mockSuperheroUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockSuperheroUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should throw if user can not be updated', async () => { - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - firstName: 'failToUpdate', - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account can not be updated', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: 'fail@to.update', - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should not update password if no new password', async () => { - const spy = jest.spyOn(accountService, 'save'); - await accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - passwordNew: undefined, - email: 'newemail@to.update', - }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - password: undefined, - }) - ); + it('should throw entity not found error', async () => { + const { mockUserWithoutAccount } = setup(); + await expect( + accountUc.updateMyAccount(mockUserWithoutAccount.id, { + passwordOld: defaultPassword, + }) + ).rejects.toThrow(EntityNotFoundError); + }); }); - }); + describe('When account is external', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); - describe('replaceMyTemporaryPassword', () => { - it('should throw if passwords do not match', async () => { - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - 'FooPasswd!1' - ) - ).rejects.toThrow(ForbiddenOperationError); - }); + const mockExternalUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const externalSystem = systemFactory.buildWithId(); + const mockExternalUserAccount = accountFactory.buildWithId({ + userId: mockExternalUser.id, + password: defaultPasswordHash, + systemId: externalSystem.id, + }); - it('should throw if account does not exist', async () => { - await expect( - accountUc.replaceMyTemporaryPassword(mockUserWithoutAccount.id, defaultPassword, defaultPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if user does not exist', async () => { - await expect( - accountUc.replaceMyTemporaryPassword('accountWithoutUser', defaultPassword, defaultPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account is external', async () => { - await expect( - accountUc.replaceMyTemporaryPassword( - mockExternalUserAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw if not the users password is temporary', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: true }; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if old password is the same as new password', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if old password is undefined', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentAccount.password = undefined; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(Error); - }); - it('should allow to set strong password, if the admin manipulated the users password', async () => { - mockStudentUser.forcePasswordChange = true; - mockStudentUser.preferences = { firstLogin: true }; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should allow to set strong password, if this is the users first login', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should allow to set strong password, if this is the users first login (if undefined)', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = undefined; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should throw if user can not be updated', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentUser.firstName = 'failToUpdate'; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account can not be updated', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentAccount.username = 'fail@to.update'; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - }); + accountService.findByUserIdOrFail.mockResolvedValueOnce( + AccountEntityToDtoMapper.mapToDto(mockExternalUserAccount) + ); - describe('searchAccounts', () => { - it('should return one account, if search type is userId', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams - ); - const expected = new AccountSearchListResponse( - [AccountResponseMapper.mapToResponseFromEntity(mockStudentAccount)], - 1, - 0, - 1 - ); - expect(accounts).toStrictEqual(expected); - }); - it('should return empty list, if account is not found', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchQueryParams - ); - const expected = new AccountSearchListResponse([], 0, 0, 0); - expect(accounts).toStrictEqual(expected); - }); - it('should return one or more accounts, if search type is username', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USERNAME, value: '' } as AccountSearchQueryParams - ); - expect(accounts.skip).toEqual(0); - expect(accounts.limit).toEqual(10); - expect(accounts.total).toBeGreaterThan(1); - expect(accounts.data.length).toBeGreaterThan(1); - }); - it('should throw, if user has not the right permissions', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockTeacherUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - - await expect( - accountUc.searchAccounts( - { userId: mockStudentUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockOtherStudentUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - - await expect( - accountUc.searchAccounts( - { userId: mockStudentUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if search type is unknown', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: '' as AccountSearchType } as AccountSearchQueryParams - ) - ).rejects.toThrow('Invalid search type.'); - }); - it('should throw, if user is no superhero', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockTeacherUser.id } as ICurrentUser, - { type: AccountSearchType.USERNAME, value: mockStudentUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); + return { mockExternalUserAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockExternalUserAccount } = setup(); - describe('hasPermissionsToAccessAccount', () => { - beforeEach(() => { - configService.get.mockReturnValue(false); - }); - it('admin can access teacher of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockExternalUserAccount.userId?.toString() ?? '', { + passwordOld: defaultPassword, + }) + ).rejects.toThrow(ForbiddenOperationError); }); - it('admin can access student of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - }); - it('admin can not access admin of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + + describe('When password does not match', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentUser }; + }; + it('should throw AuthorizationError', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: 'DoesNotMatch', + }) + ).rejects.toThrow(AuthorizationError); }); - it('admin can not access any account of a foreign school via user id', async () => { - const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + }); + + describe('When changing own name is not allowed', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); - let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + return { mockStudentUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).rejects.toThrow(ForbiddenOperationError); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).rejects.toThrow(ForbiddenOperationError); }); - it('teacher can access teacher of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockOtherTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should allow to update email', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: 'an@available.mail', + }) + ).resolves.not.toThrow(); }); - it('teacher can access student of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + const accountSaveSpy = jest.spyOn(accountService, 'save'); + + return { mockStudentUser, accountSaveSpy }; + }; + it('should use email as account user name in lower case', async () => { + const { mockStudentUser, accountSaveSpy } = setup(); + + const testMail = 'AN@AVAILABLE.MAIL'; + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); }); - it('teacher can not access admin of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + + const userUpdateSpy = jest.spyOn(userRepo, 'save'); + + return { mockStudentUser, userUpdateSpy }; + }; + it('should use email as user email in lower case', async () => { + const { mockStudentUser, userUpdateSpy } = setup(); + const testMail = 'AN@AVAILABLE.MAIL'; + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); }); - it('teacher can not access any account of a foreign school via user id', async () => { - const currentUser = { userId: mockDifferentSchoolTeacherUser.id } as ICurrentUser; + }); + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); - let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const accountSaveSpy = jest.spyOn(accountService, 'save'); + const userUpdateSpy = jest.spyOn(userRepo, 'save'); + + return { mockStudentUser, accountSaveSpy, userUpdateSpy }; + }; + it('should always update account user name AND user email together.', async () => { + const { mockStudentUser, accountSaveSpy, userUpdateSpy } = setup(); + const testMail = 'an@available.mail'; + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); + expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); }); - it('teacher can access student of the same school via user id if school has global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockTeacherNoUserPermissionUser.id } as ICurrentUser; - const params = { - type: AccountSearchType.USER_ID, - value: mockStudentSchoolPermissionUser.id, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + + return { mockStudentUser }; + }; + it('should throw if new email already in use', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: 'already@in.use', + }) + ).rejects.toThrow(ValidationError); }); - it('teacher can not access student of the same school if school has no global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockTeacherNoUserNoSchoolPermissionUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using student user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should allow to update with strong password', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + passwordNew: otherPassword, + }) + ).resolves.not.toThrow(); }); + }); + + describe('When using teacher user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); - it('student can not access student of the same school if school has global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockStudentSchoolPermissionUser.id } as ICurrentUser; - const params = { - type: AccountSearchType.USER_ID, - value: mockOtherStudentSchoolPermissionUser.id, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + userRepo.findById.mockResolvedValue(mockTeacherUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockTeacherUser }; + }; + it('should allow to update first and last name', async () => { + const { mockTeacherUser } = setup(); + await expect( + accountUc.updateMyAccount(mockTeacherUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockTeacherUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); }); - it('student can not access any other account via user id', async () => { - const currentUser = { userId: mockStudentUser.id } as ICurrentUser; + }); + + describe('When using admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); - let params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); - params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + userRepo.findById.mockResolvedValue(mockAdminUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockAdminAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + return { mockAdminUser }; + }; + it('should allow to update first and last name', async () => { + const { mockAdminUser } = setup(); + await expect( + accountUc.updateMyAccount(mockAdminUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockAdminUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); }); - it('superhero can access any account via username', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockSuperheroAccount = accountFactory.buildWithId({ + userId: mockSuperheroUser.id, + password: defaultPasswordHash, + }); - let params = { type: AccountSearchType.USERNAME, value: mockAdminAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + userRepo.findById.mockResolvedValue(mockSuperheroUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockSuperheroAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { type: AccountSearchType.USERNAME, value: mockTeacherAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + return { mockSuperheroUser }; + }; + it('should allow to update first and last name ', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.updateMyAccount(mockSuperheroUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockSuperheroUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); + }); + }); - params = { type: AccountSearchType.USERNAME, value: mockStudentAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolAdminAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolTeacherAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + userRepo.findById.mockResolvedValue(mockTeacherUser); + userRepo.save.mockRejectedValueOnce(undefined); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + accountService.validatePassword.mockResolvedValue(true); - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolStudentAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + return { mockTeacherUser, mockTeacherAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockTeacherUser } = setup(); + await expect( + accountUc.updateMyAccount(mockTeacherUser.id, { + passwordOld: defaultPassword, + firstName: 'failToUpdate', + }) + ).rejects.toThrow(EntityNotFoundError); }); }); - }); - describe('findAccountById', () => { - it('should return an account, if the current user is a superhero', async () => { - const account = await accountUc.findAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ); - expect(account).toStrictEqual( - expect.objectContaining({ - id: mockStudentAccount.id, - username: mockStudentAccount.username, + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id, - activated: mockStudentAccount.activated, - }) - ); - }); - it('should throw, if the current user is no superhero', async () => { - await expect( - accountUc.findAccountById( - { userId: mockTeacherUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if no account matches the search term', async () => { - await expect( - accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, { id: 'xxx' } as AccountByIdParams) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw, if target account has no user', async () => { - await expect( - accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, { id: 'xxx' } as AccountByIdParams) - ).rejects.toThrow(EntityNotFoundError); - }); - }); + password: defaultPasswordHash, + }); - describe('saveAccount', () => { - afterEach(() => { - jest.clearAllMocks(); + userRepo.findById.mockResolvedValue(mockStudentUser); + userRepo.save.mockResolvedValueOnce(undefined); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + accountService.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + email: 'fail@to.update', + }) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should call account service', async () => { - const spy = jest.spyOn(accountService, 'saveWithValidation'); - const params: AccountSaveDto = { - username: 'john.doe@domain.tld', - password: defaultPassword, + describe('When no new password is given', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + const spyAccountServiceSave = jest.spyOn(accountService, 'save'); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, spyAccountServiceSave }; }; - await accountUc.saveAccount(params); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - username: 'john.doe@domain.tld', - }) - ); + it('should not update password', async () => { + const { mockStudentUser, spyAccountServiceSave } = setup(); + await accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + passwordNew: undefined, + email: 'newemail@to.update', + }); + expect(spyAccountServiceSave).toHaveBeenCalledWith( + expect.objectContaining({ + password: undefined, + }) + ); + }); }); }); - describe('updateAccountById', () => { - it('should throw if executing user does not exist', async () => { - const currentUser = { userId: '000000000000000' } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if target account does not exist', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: '000000000000000' } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should update target account password', async () => { - const previousPasswordHash = mockStudentAccount.password; - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { password: defaultPassword } as AccountByIdBodyParams; - expect(mockStudentUser.forcePasswordChange).toBeFalsy(); - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.password).not.toBe(previousPasswordHash); - expect(mockStudentUser.forcePasswordChange).toBeTruthy(); - }); - it('should update target account username', async () => { - const newUsername = 'newUsername'; - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: newUsername } as AccountByIdBodyParams; - expect(mockStudentAccount.username).not.toBe(newUsername); - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.username).toBe(newUsername.toLowerCase()); - }); - it('should update target account activation state', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { activated: false } as AccountByIdBodyParams; - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.activated).toBeFalsy(); - }); - it('should throw if account can not be updated', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: 'fail@to.update' } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if user can not be updated', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: 'user-fail@to.update' } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); + describe('replaceMyTemporaryPassword', () => { + describe('When passwords do not match', () => { + it('should throw ForbiddenOperationError', async () => { + await expect(accountUc.replaceMyTemporaryPassword('userId', defaultPassword, 'FooPasswd!1')).rejects.toThrow( + ForbiddenOperationError + ); + }); }); - it('should throw if target account has no user', async () => { - await expect( - accountUc.updateAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockAccountWithoutUser.id } as AccountByIdParams, - { username: 'user-fail@to.update' } as AccountByIdBodyParams - ) - ).rejects.toThrow(EntityNotFoundError); + + describe('When account does not exists', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockUserWithoutAccount); + accountService.findByUserIdOrFail.mockImplementation(() => { + throw new EntityNotFoundError(Account.name); + }); + + return { mockUserWithoutAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockUserWithoutAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword(mockUserWithoutAccount.id, defaultPassword, defaultPassword) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should throw if new username already in use', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: mockOtherTeacherAccount.username } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ValidationError); + + describe('When user does not exist', () => { + const setup = () => { + userRepo.findById.mockRejectedValueOnce(undefined); + }; + it('should throw EntityNotFoundError', async () => { + setup(); + await expect( + accountUc.replaceMyTemporaryPassword('accountWithoutUser', defaultPassword, defaultPassword) + ).rejects.toThrow(EntityNotFoundError); + }); }); - describe('hasPermissionsToUpdateAccount', () => { - it('admin can edit teacher', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + describe('When account is external', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockExternalUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const externalSystem = systemFactory.buildWithId(); + const mockExternalUserAccount = accountFactory.buildWithId({ + userId: mockExternalUser.id, + password: defaultPasswordHash, + systemId: externalSystem.id, + }); + + userRepo.findById.mockResolvedValueOnce(mockExternalUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce( + AccountEntityToDtoMapper.mapToDto(mockExternalUserAccount) + ); + + return { mockExternalUserAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockExternalUserAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockExternalUserAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); }); - it('teacher can edit student', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + }); + describe('When not the users password is temporary', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: true }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When old password is the same as new password', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(true); + + return { mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When old password is undefined', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: undefined, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(true); + + return { mockStudentAccount }; + }; + it('should throw Error', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(Error); + }); + }); + + describe('When the admin manipulate the users password', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: true, + preferences: { firstLogin: true }, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + return { mockStudentAccount }; + }; + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('when a user logs in for the first time', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('when a user logs in for the first time (if undefined)', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + }); + mockStudentUser.preferences = undefined; + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + firstName: 'failToUpdate', + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + preferences: { firstLogin: false }, + forcePasswordChange: false, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + userRepo.save.mockRejectedValueOnce(undefined); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + username: 'fail@to.update', + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + userRepo.save.mockResolvedValueOnce(); + accountService.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + accountService.save.mockRejectedValueOnce(undefined); + accountService.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountUc.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('searchAccounts', () => { + describe('When search type is userId', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); + mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); + mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); + mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findByUserId.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentUser, mockStudentAccount }; + }; + it('should return one account', async () => { + const { mockSuperheroUser, mockStudentUser, mockStudentAccount } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams + ); + const expected = new AccountSearchListResponse( + [AccountResponseMapper.mapToResponseFromEntity(mockStudentAccount)], + 1, + 0, + 1 + ); + expect(accounts).toStrictEqual(expected); + }); + }); + + describe('When account is not found', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockUserWithoutAccount); + + return { mockSuperheroUser, mockUserWithoutAccount }; + }; + it('should return empty list', async () => { + const { mockSuperheroUser, mockUserWithoutAccount } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchQueryParams + ); + const expected = new AccountSearchListResponse([], 0, 0, 0); + expect(accounts).toStrictEqual(expected); + }); + }); + describe('When search type is username', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockSuperheroUser); + accountService.searchByUsernamePartialMatch.mockResolvedValueOnce([ + [ + AccountEntityToDtoMapper.mapToDto(mockStudentAccount), + AccountEntityToDtoMapper.mapToDto(mockStudentAccount), + ], + 2, + ]); + + return { mockSuperheroUser }; + }; + it('should return one or more accounts, ', async () => { + const { mockSuperheroUser } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USERNAME, value: '' } as AccountSearchQueryParams + ); + expect(accounts.skip).toEqual(0); + expect(accounts.limit).toEqual(10); + expect(accounts.total).toBeGreaterThan(1); + expect(accounts.data.length).toBeGreaterThan(1); + }); + }); + + describe('When user has not the right permissions', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockAdminUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockOtherStudentUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockTeacherUser); + + return { mockTeacherUser, mockAdminUser, mockStudentUser, mockOtherStudentUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockAdminUser, mockStudentUser, mockOtherStudentUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockTeacherUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + + await expect( + accountUc.searchAccounts( + { userId: mockStudentUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockOtherStudentUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + + await expect( + accountUc.searchAccounts( + { userId: mockStudentUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When search type is unknown', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + + return { mockSuperheroUser }; + }; + it('should throw Invalid search type', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: '' as AccountSearchType } as AccountSearchQueryParams + ) + ).rejects.toThrow('Invalid search type.'); + }); + }); + + describe('When user is not superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockStudentUser); + + return { mockStudentUser, mockTeacherUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockStudentUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockTeacherUser.id } as ICurrentUser, + { type: AccountSearchType.USERNAME, value: mockStudentUser.id } as AccountSearchQueryParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + describe('hasPermissionsToAccessAccount', () => { + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockTeacherUser); + + return { mockAdminUser, mockTeacherUser }; + }; + it('should be able to access teacher of the same school via user id', async () => { + const { mockAdminUser, mockTeacherUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + + return { mockAdminUser, mockStudentUser }; + }; + it('should be able to access student of the same school via user id', async () => { + const { mockAdminUser, mockStudentUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockAdminUser); + + return { mockAdminUser }; + }; + + it('should not be able to access admin of the same school via user id', async () => { + const { mockAdminUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockDifferentSchoolAdminUser).mockResolvedValueOnce(mockTeacherUser); + userRepo.findById.mockResolvedValueOnce(mockDifferentSchoolAdminUser).mockResolvedValueOnce(mockStudentUser); + + return { mockDifferentSchoolAdminUser, mockTeacherUser, mockStudentUser }; + }; + it('should not be able to access any account of a foreign school via user id', async () => { + const { mockDifferentSchoolAdminUser, mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockOtherTeacherUser); + + return { mockTeacherUser, mockOtherTeacherUser }; + }; + it('should be able to access teacher of the same school via user id', async () => { + const { mockTeacherUser, mockOtherTeacherUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockOtherTeacherUser.id, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockStudentUser); + + return { mockTeacherUser, mockStudentUser }; + }; + it('should be able to access student of the same school via user id', async () => { + const { mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockAdminUser); + + return { mockTeacherUser, mockAdminUser }; + }; + it('should not be able to access admin of the same school via user id', async () => { + const { mockTeacherUser, mockAdminUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolTeacherUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockTeacherUser.roles], + }); + + configService.get.mockReturnValue(false); + userRepo.findById + .mockResolvedValueOnce(mockDifferentSchoolTeacherUser) + .mockResolvedValueOnce(mockTeacherUser); + userRepo.findById + .mockResolvedValueOnce(mockDifferentSchoolTeacherUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockDifferentSchoolTeacherUser, mockTeacherUser, mockStudentUser }; + }; + it('should not be able to access any account of a foreign school via user id', async () => { + const { mockDifferentSchoolTeacherUser, mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockDifferentSchoolTeacherUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + describe('When using a teacher', () => { + const setup = () => { + const mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); + mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); + mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); + mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; + + const mockTeacherNoUserPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [], + }), + ], + }); + const mockStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(true); + userRepo.findById + .mockResolvedValueOnce(mockTeacherNoUserPermissionUser) + .mockResolvedValueOnce(mockStudentSchoolPermissionUser); + + return { mockTeacherNoUserPermissionUser, mockStudentSchoolPermissionUser }; + }; + it('should be able to access student of the same school via user id if school has global permission', async () => { + const { mockTeacherNoUserPermissionUser, mockStudentSchoolPermissionUser } = setup(); + + const currentUser = { userId: mockTeacherNoUserPermissionUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockStudentSchoolPermissionUser.id, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherNoUserNoSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById + .mockResolvedValueOnce(mockTeacherNoUserNoSchoolPermissionUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockTeacherNoUserNoSchoolPermissionUser, mockStudentUser }; + }; + it('should not be able to access student of the same school if school has no global permission', async () => { + const { mockTeacherNoUserNoSchoolPermissionUser, mockStudentUser } = setup(); + configService.get.mockReturnValue(true); + const currentUser = { userId: mockTeacherNoUserNoSchoolPermissionUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using a student', () => { + const setup = () => { + const mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); + mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); + mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); + mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; + + const mockStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById + .mockResolvedValueOnce(mockStudentSchoolPermissionUser) + .mockResolvedValueOnce(mockOtherStudentSchoolPermissionUser); + + return { mockStudentSchoolPermissionUser, mockOtherStudentSchoolPermissionUser }; + }; + it('should not be able to access student of the same school if school has global permission', async () => { + const { mockStudentSchoolPermissionUser, mockOtherStudentSchoolPermissionUser } = setup(); + configService.get.mockReturnValue(true); + const currentUser = { userId: mockStudentSchoolPermissionUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockOtherStudentSchoolPermissionUser.id, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using a student', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockAdminUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockTeacherUser); + userRepo.findById.mockResolvedValueOnce(mockStudentUser).mockResolvedValueOnce(mockStudentUser); + + return { mockStudentUser, mockAdminUser, mockTeacherUser }; + }; + it('should not be able to access any other account via user id', async () => { + const { mockStudentUser, mockAdminUser, mockTeacherUser } = setup(); + const currentUser = { userId: mockStudentUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); + }); + + describe('When using a superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + const mockDifferentSchoolTeacherUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockTeacherUser.roles], + }); + const mockDifferentSchoolStudentUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockStudentUser.roles], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockDifferentSchoolAdminAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolAdminUser.id, + password: defaultPasswordHash, + }); + const mockDifferentSchoolTeacherAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolTeacherUser.id, + password: defaultPasswordHash, + }); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + const mockDifferentSchoolStudentAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolStudentUser.id, + password: defaultPasswordHash, + }); + + configService.get.mockReturnValue(false); + userRepo.findById.mockResolvedValue(mockSuperheroUser); + accountService.searchByUsernamePartialMatch.mockResolvedValue([[], 0]); + + return { + mockSuperheroUser, + mockAdminAccount, + mockTeacherAccount, + mockStudentAccount, + mockDifferentSchoolAdminAccount, + mockDifferentSchoolTeacherAccount, + mockDifferentSchoolStudentAccount, + }; + }; + it('should be able to access any account via username', async () => { + const { + mockSuperheroUser, + mockAdminAccount, + mockTeacherAccount, + mockStudentAccount, + mockDifferentSchoolAdminAccount, + mockDifferentSchoolTeacherAccount, + mockDifferentSchoolStudentAccount, + } = setup(); + + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + + let params = { + type: AccountSearchType.USERNAME, + value: mockAdminAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { type: AccountSearchType.USERNAME, value: mockTeacherAccount.username } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { type: AccountSearchType.USERNAME, value: mockStudentAccount.username } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolAdminAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolTeacherAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolStudentAccount.username, + } as AccountSearchQueryParams; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + }); + }); + + describe('findAccountById', () => { + describe('When the current user is a superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentUser, mockStudentAccount }; + }; + it('should return an account', async () => { + const { mockSuperheroUser, mockStudentUser, mockStudentAccount } = setup(); + const account = await accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ); + expect(account).toStrictEqual( + expect.objectContaining({ + id: mockStudentAccount.id, + username: mockStudentAccount.username, + userId: mockStudentUser.id, + activated: mockStudentAccount.activated, + }) + ); + }); + }); + + describe('When the current user is no superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser); + + return { mockTeacherUser, mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockStudentAccount } = setup(); + await expect( + accountUc.findAccountById( + { userId: mockTeacherUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When no account matches the search term', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(Account.name); + }); + + return { mockSuperheroUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: 'xxx' } as AccountByIdParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When target account has no user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(Account.name); + }); + + return { mockSuperheroUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: 'xxx' } as AccountByIdParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('saveAccount', () => { + describe('When saving an account', () => { + const setup = () => { + const spy = jest.spyOn(accountService, 'saveWithValidation'); + + return { spy }; + }; + it('should call account service', async () => { + const { spy } = setup(); + + const params: AccountSaveDto = { + username: 'john.doe@domain.tld', + password: defaultPassword, + }; + await accountUc.saveAccount(params); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'john.doe@domain.tld', + }) + ); + }); + }); + }); + + describe('updateAccountById', () => { + describe('when updating a user that does not exist', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(User.name); + }); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + const currentUser = { userId: '000000000000000' } as ICurrentUser; const params = { id: mockStudentAccount.id } as AccountByIdParams; const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); - it('admin can edit student', async () => { + }); + + describe('When target account does not exist', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockAccountWithoutUser = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + }); + + userRepo.findById.mockResolvedValue(mockAdminUser); + accountService.findById.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockAccountWithoutUser)); + + return { mockAdminUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockAdminUser } = setup(); const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; + const params = { id: '000000000000000' } as AccountByIdParams; const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); - it('teacher cannot edit other teacher', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { id: mockOtherTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockImplementation((account: AccountSaveDto): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + }); + + return { mockStudentAccount, mockStudentUser, mockSuperheroUser }; + }; + it('should update target account password', async () => { + const { mockStudentAccount, mockSuperheroUser, mockStudentUser } = setup(); + const previousPasswordHash = mockStudentAccount.password; + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { password: defaultPassword } as AccountByIdBodyParams; + expect(mockStudentUser.forcePasswordChange).toBeFalsy(); + await accountUc.updateAccountById(currentUser, params, body); + expect(mockStudentAccount.password).not.toBe(previousPasswordHash); + expect(mockStudentUser.forcePasswordChange).toBeTruthy(); }); - it("other school's admin cannot edit teacher", async () => { - const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; - const params = { id: mockTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockImplementation((account: AccountSaveDto): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + }); + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentAccount, mockSuperheroUser }; + }; + it('should update target account username', async () => { + const { mockStudentAccount, mockSuperheroUser } = setup(); + const newUsername = 'newUsername'; + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: newUsername } as AccountByIdBodyParams; + expect(mockStudentAccount.username).not.toBe(newUsername); + await accountUc.updateAccountById(currentUser, params, body); + expect(mockStudentAccount.username).toBe(newUsername.toLowerCase()); }); - it('superhero can edit admin', async () => { + }); + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockImplementation((account: AccountSaveDto): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + }); + + return { mockStudentAccount, mockSuperheroUser }; + }; + it('should update target account activation state', async () => { + const { mockStudentAccount, mockSuperheroUser } = setup(); const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockAdminAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { activated: false } as AccountByIdBodyParams; + await accountUc.updateAccountById(currentUser, params, body); + expect(mockStudentAccount.activated).toBeFalsy(); }); - it('undefined user role fails by default', async () => { - const currentUser = { userId: mockUnknownRoleUser.id } as ICurrentUser; - const params = { id: mockAccountWithoutRole.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockResolvedValue(); + accountService.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should throw if account can not be updated', async () => { + const { mockStudentAccount, mockAdminUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: 'fail@to.update' } as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); - it('user without role cannot be edited', async () => { + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount, mockAdminUser } = setup(); const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockUnknownRoleUserAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: 'user-fail@to.update' } as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); }); }); - }); - describe('deleteAccountById', () => { - it('should delete an account, if current user is authorized', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).resolves.not.toThrow(); + describe('if target account has no user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAccountWithoutUser = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockAccountWithoutUser)); + + return { mockSuperheroUser, mockAccountWithoutUser }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser, mockAccountWithoutUser } = setup(); + await expect( + accountUc.updateAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: mockAccountWithoutUser.id } as AccountByIdParams, + { username: 'user-fail@to.update' } as AccountByIdBodyParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should throw, if the current user is no superhero', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockAdminUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).rejects.toThrow(ForbiddenOperationError); + + describe('When new username already in use', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + userRepo.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + + return { mockStudentAccount, mockAdminUser, mockOtherTeacherAccount }; + }; + it('should throw ValidationError', async () => { + const { mockStudentAccount, mockAdminUser, mockOtherTeacherAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = { username: mockOtherTeacherAccount.username } as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ValidationError); + }); }); - it('should throw, if no account matches the search term', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: 'xxx' } as AccountByIdParams - ) - ).rejects.toThrow(EntityNotFoundError); + + describe('hasPermissionsToUpdateAccount', () => { + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + + return { mockAdminUser, mockTeacherAccount }; + }; + it('should not throw error when editing a teacher', async () => { + const { mockAdminUser, mockTeacherAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockTeacherAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockStudentAccount, mockTeacherUser }; + }; + it('should not throw error when editing a student', async () => { + const { mockTeacherUser, mockStudentAccount } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should not throw error when editing a student', async () => { + const { mockAdminUser, mockStudentAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockStudentAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher user to edit another teacher', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockTeacherUser).mockResolvedValueOnce(mockOtherTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockOtherTeacherAccount)); + + return { mockOtherTeacherAccount, mockTeacherUser }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockTeacherUser, mockOtherTeacherAccount } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { id: mockOtherTeacherAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using an admin user of other school', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + const mockOtherSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockDifferentSchoolAdminUser).mockResolvedValueOnce(mockTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + + return { mockDifferentSchoolAdminUser, mockTeacherAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockDifferentSchoolAdminUser, mockTeacherAccount } = setup(); + const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + const params = { id: mockTeacherAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When using a superhero user', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockSuperheroUser).mockResolvedValueOnce(mockAdminUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockAdminAccount)); + + return { mockAdminAccount, mockSuperheroUser }; + }; + it('should not throw error when editing a admin', async () => { + const { mockSuperheroUser, mockAdminAccount } = setup(); + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const params = { id: mockAdminAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + }); + }); + + describe('When using an user with undefined role', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockUserWithoutRole = userFactory.buildWithId({ + school: mockSchool, + roles: [], + }); + const mockUnknownRoleUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], + }); + const mockAccountWithoutRole = accountFactory.buildWithId({ + userId: mockUserWithoutRole.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockUnknownRoleUser).mockResolvedValueOnce(mockUserWithoutRole); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockAccountWithoutRole)); + + return { mockAccountWithoutRole, mockUnknownRoleUser }; + }; + it('should fail by default', async () => { + const { mockUnknownRoleUser, mockAccountWithoutRole } = setup(); + const currentUser = { userId: mockUnknownRoleUser.id } as ICurrentUser; + const params = { id: mockAccountWithoutRole.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When editing an user without role', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockUnknownRoleUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], + }); + const mockUnknownRoleUserAccount = accountFactory.buildWithId({ + userId: mockUnknownRoleUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockAdminUser).mockResolvedValueOnce(mockUnknownRoleUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDtoMapper.mapToDto(mockUnknownRoleUserAccount)); + + return { mockAdminUser, mockUnknownRoleUserAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockAdminUser, mockUnknownRoleUserAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { id: mockUnknownRoleUserAccount.id } as AccountByIdParams; + const body = {} as AccountByIdBodyParams; + await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + }); + }); }); }); - describe('checkBrutForce', () => { - let updateMock: jest.Mock; - beforeAll(() => { - configService.get.mockReturnValue(LOGIN_BLOCK_TIME); - }); - afterAll(() => { - configService.get.mockRestore(); + describe('deleteAccountById', () => { + describe('When current user is authorized', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValue(mockSuperheroUser); + + accountService.findById.mockResolvedValue(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentAccount }; + }; + it('should delete an account', async () => { + const { mockSuperheroUser, mockStudentAccount } = setup(); + await expect( + accountUc.deleteAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ) + ).resolves.not.toThrow(); + }); }); - beforeEach(() => { - // eslint-disable-next-line jest/unbound-method - updateMock = accountService.updateLastTriedFailedLogin as jest.Mock; - updateMock.mockClear(); + + describe('When the current user is not superhero', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockImplementation((userId: EntityId): Promise => { + if (mockAdminUser.id === userId) { + return Promise.resolve(mockAdminUser); + } + throw new EntityNotFoundError(User.name); + }); + + return { mockAdminUser, mockStudentAccount }; + }; + it('should throw ForbiddenOperationError', async () => { + const { mockAdminUser, mockStudentAccount } = setup(); + await expect( + accountUc.deleteAccountById( + { userId: mockAdminUser.id } as ICurrentUser, + { id: mockStudentAccount.id } as AccountByIdParams + ) + ).rejects.toThrow(ForbiddenOperationError); + }); }); - it('should throw, if time difference < the allowed time', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accountUc.checkBrutForce(mockAccountWithLastFailedLogin.username, mockAccountWithLastFailedLogin.systemId!) - ).rejects.toThrow(BruteForcePrevention); + + describe('When no account matches the search term', () => { + const setup = () => { + const mockSchool = schoolFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + userRepo.findById.mockImplementation((userId: EntityId): Promise => { + if (mockSuperheroUser.id === userId) { + return Promise.resolve(mockSuperheroUser); + } + throw new EntityNotFoundError(User.name); + }); + + accountService.findById.mockImplementation((id: EntityId): Promise => { + if (id === 'xxx') { + throw new EntityNotFoundError(Account.name); + } + return Promise.reject(); + }); + + return { mockSuperheroUser }; + }; + it('should throw, if no account matches the search term', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.deleteAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + { id: 'xxx' } as AccountByIdParams + ) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should not throw Error, if the time difference > the allowed time', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accountUc.checkBrutForce(mockAccountWithSystemId.username, mockAccountWithSystemId.systemId!) - ).resolves.not.toThrow(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(updateMock.mock.calls[0][0]).toEqual(mockAccountWithSystemId.id); - const newDate = new Date().getTime() - 10000; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect((updateMock.mock.calls[0][1] as Date).getTime()).toBeGreaterThan(newDate); + }); + + describe('checkBrutForce', () => { + describe('When time difference < the allowed time', () => { + const setup = () => { + const mockAccountWithLastFailedLogin = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + lasttriedFailedLogin: new Date(), + }); + + configService.get.mockReturnValue(LOGIN_BLOCK_TIME); + + accountService.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if ( + mockAccountWithLastFailedLogin.username === username && + mockAccountWithLastFailedLogin.systemId === systemId + ) { + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockAccountWithLastFailedLogin)); + } + throw new EntityNotFoundError(Account.name); + } + ); + + return { mockAccountWithLastFailedLogin }; + }; + + it('should throw BruteForcePrevention', async () => { + const { mockAccountWithLastFailedLogin } = setup(); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + accountUc.checkBrutForce(mockAccountWithLastFailedLogin.username, mockAccountWithLastFailedLogin.systemId!) + ).rejects.toThrow(BruteForcePrevention); + }); }); - it('should not throw, if lasttriedFailedLogin is undefined', async () => { - await expect( - accountUc.checkBrutForce( - mockAccountWithNoLastFailedLogin.username, + + describe('When the time difference > the allowed time', () => { + const setup = () => { + const mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); + + // eslint-disable-next-line jest/unbound-method + const updateMock = accountService.updateLastTriedFailedLogin as jest.Mock; + + configService.get.mockReturnValue(LOGIN_BLOCK_TIME); + + accountService.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockAccountWithSystemId)); + } + throw new EntityNotFoundError(Account.name); + } + ); + + return { mockAccountWithSystemId, updateMock }; + }; + + it('should not throw Error, ', async () => { + const { mockAccountWithSystemId, updateMock } = setup(); + + await expect( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockAccountWithNoLastFailedLogin.systemId! - ) - ).resolves.not.toThrow(); + accountUc.checkBrutForce(mockAccountWithSystemId.username, mockAccountWithSystemId.systemId!) + ).resolves.not.toThrow(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(updateMock.mock.calls[0][0]).toEqual(mockAccountWithSystemId.id); + const newDate = new Date().getTime() - 10000; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect((updateMock.mock.calls[0][1] as Date).getTime()).toBeGreaterThan(newDate); + }); + }); + + describe('When lasttriedFailedLogin is undefined', () => { + const setup = () => { + const mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ + userId: undefined, + password: defaultPasswordHash, + systemId: systemFactory.buildWithId().id, + lasttriedFailedLogin: undefined, + }); + + configService.get.mockReturnValue(LOGIN_BLOCK_TIME); + + accountService.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if ( + mockAccountWithNoLastFailedLogin.username === username && + mockAccountWithNoLastFailedLogin.systemId === systemId + ) { + return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockAccountWithNoLastFailedLogin)); + } + throw new EntityNotFoundError(Account.name); + } + ); + + return { mockAccountWithNoLastFailedLogin }; + }; + it('should not throw error', async () => { + const { mockAccountWithNoLastFailedLogin } = setup(); + await expect( + accountUc.checkBrutForce( + mockAccountWithNoLastFailedLogin.username, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mockAccountWithNoLastFailedLogin.systemId! + ) + ).resolves.not.toThrow(); + }); }); }); }); diff --git a/apps/server/src/modules/account/uc/account.uc.ts b/apps/server/src/modules/account/uc/account.uc.ts index fa1d7ca4c60..2db4ed8843f 100644 --- a/apps/server/src/modules/account/uc/account.uc.ts +++ b/apps/server/src/modules/account/uc/account.uc.ts @@ -8,6 +8,7 @@ import { } from '@shared/common/error'; import { Account, EntityId, Permission, PermissionService, Role, RoleName, SchoolEntity, User } from '@shared/domain'; import { UserRepo } from '@shared/repo'; +// TODO: module internals should be imported with relative paths import { AccountService } from '@src/modules/account/services/account.service'; import { AccountDto } from '@src/modules/account/services/dto/account.dto'; @@ -42,6 +43,14 @@ export class AccountUc { private readonly configService: ConfigService ) {} + /* HINT: there is a lot of logic here that would belong into service layer, + but since that wasnt decided when this code was written this work is not prioritised right now + + Also this is mostly directly ported feathers code, that needs a general refactoring/rewrite pass + + also it should use the new authorisation service + */ + /** * This method processes the request on the GET account search endpoint from the account controller. * @@ -55,7 +64,9 @@ export class AccountUc { const limit = query.limit ?? 10; const executingUser = await this.userRepo.findById(currentUser.userId, true); + // HINT: this can be extracted if (query.type === AccountSearchType.USERNAME) { + // HINT: even superheroes should in the future be permission based if (!(await this.isSuperhero(currentUser))) { throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); } @@ -72,8 +83,10 @@ export class AccountUc { } const account = await this.accountService.findByUserId(query.value); if (account) { + // HINT: skip and limit should be from the query return new AccountSearchListResponse([AccountResponseMapper.mapToResponse(account)], 1, 0, 1); } + // HINT: skip and limit should be from the query return new AccountSearchListResponse([], 0, 0, 0); } @@ -93,7 +106,7 @@ export class AccountUc { throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); } const account = await this.accountService.findById(params.id); - return AccountResponseMapper.mapToResponse(account); + return AccountResponseMapper.mapToResponse(account); // TODO: mapping should be done in controller } async saveAccount(dto: AccountSaveDto): Promise { @@ -162,6 +175,8 @@ export class AccountUc { throw new EntityNotFoundError(Account.name); } } + // TODO: mapping from domain to api dto should be a responsability of the controller + return AccountResponseMapper.mapToResponse(targetAccount); } @@ -300,6 +315,7 @@ export class AccountUc { } } + // TODO: remove /** * * @deprecated this is for legacy login strategies only. Login strategies in Nest.js should use {@link AuthenticationService} diff --git a/apps/server/src/shared/testing/factory/account.factory.ts b/apps/server/src/shared/testing/factory/account.factory.ts index b0c0b8434c1..a3568dbf80a 100644 --- a/apps/server/src/shared/testing/factory/account.factory.ts +++ b/apps/server/src/shared/testing/factory/account.factory.ts @@ -5,6 +5,8 @@ import { ObjectId } from 'bson'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; +export const defaultTestPassword = 'DummyPasswd!1'; +export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; class AccountFactory extends BaseFactory { withSystemId(id: EntityId | ObjectId): this { const params: DeepPartial = { systemId: id }; @@ -21,10 +23,36 @@ class AccountFactory extends BaseFactory { return this.params(params); } + + withAllProperties(): this { + return this.params({ + userId: new ObjectId(), + username: 'username', + activated: true, + credentialHash: 'credentialHash', + expiresAt: new Date(), + lasttriedFailedLogin: new Date(), + password: defaultTestPassword, + systemId: new ObjectId(), + token: 'token', + }).afterBuild((acc) => { + return { + ...acc, + createdAt: new Date(), + updatedAt: new Date(), + }; + }); + } + + withoutSystemAndUserId(): this { + return this.params({ + username: 'username', + systemId: undefined, + userId: undefined, + }); + } } -export const defaultTestPassword = 'DummyPasswd!1'; -export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; // !!! important username should not be contain a space !!! export const accountFactory = AccountFactory.define(Account, ({ sequence }) => { return { From 5c573c2ff047fb5cec7b05ed045e60ea2aec000f Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Fri, 6 Oct 2023 13:21:57 +0200 Subject: [PATCH 20/34] N21-1332 adjust group provisioning (#4456) * N21-1332 changes group provisioning to add only current user instead of whole group --- .../src/modules/group/domain/group.spec.ts | 60 ++++++++++++++++++- apps/server/src/modules/group/domain/group.ts | 6 ++ .../service/oidc-provisioning.service.spec.ts | 2 +- .../oidc/service/oidc-provisioning.service.ts | 13 ++-- .../sanis/sanis-response.mapper.spec.ts | 10 +--- .../strategy/sanis/sanis-response.mapper.ts | 5 +- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/apps/server/src/modules/group/domain/group.spec.ts b/apps/server/src/modules/group/domain/group.spec.ts index b5ae8a03321..f2a22e1dc37 100644 --- a/apps/server/src/modules/group/domain/group.spec.ts +++ b/apps/server/src/modules/group/domain/group.spec.ts @@ -1,7 +1,7 @@ +import { RoleReference, UserDO } from '@shared/domain'; import { groupFactory, roleFactory, userDoFactory } from '@shared/testing'; import { ObjectId } from 'bson'; -import { RoleReference, UserDO } from '@shared/domain'; import { Group } from './group'; import { GroupUser } from './group-user'; @@ -135,4 +135,62 @@ describe('Group (Domain Object)', () => { }); }); }); + + describe('addUser', () => { + describe('when the user already exists in the group', () => { + const setup = () => { + const existingUser: GroupUser = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group = groupFactory.build({ + users: [existingUser], + }); + + return { + group, + existingUser, + }; + }; + + it('should not add the user', () => { + const { group, existingUser } = setup(); + + group.addUser(existingUser); + + expect(group.users.length).toEqual(1); + }); + }); + + describe('when the user does not exist in the group', () => { + const setup = () => { + const newUser: GroupUser = new GroupUser({ + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }); + const group = groupFactory.build({ + users: [ + { + userId: new ObjectId().toHexString(), + roleId: new ObjectId().toHexString(), + }, + ], + }); + + return { + group, + newUser, + }; + }; + + it('should add the user', () => { + const { group, newUser } = setup(); + + group.addUser(newUser); + + expect(group.users).toContain(newUser); + expect(group.users.length).toEqual(2); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 049043618ac..826bbd36b22 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -45,4 +45,10 @@ export class Group extends DomainObject { isEmpty(): boolean { return this.props.users.length === 0; } + + addUser(user: GroupUser): void { + if (!this.users.find((u) => u.userId === user.userId)) { + this.users.push(user); + } + } } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts index c4b27cac34b..7e6e05c7b2e 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts @@ -661,7 +661,7 @@ describe('OidcProvisioningService', () => { describe('when provision group', () => { const setup = () => { - const group: Group = groupFactory.build(); + const group: Group = groupFactory.build({ users: [] }); groupService.findByExternalSource.mockResolvedValue(group); const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: 'schoolId' }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts index 0aef3fdecb9..31b2d1ab8f3 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts @@ -119,10 +119,6 @@ export class OidcProvisioningService { } async provisionExternalGroup(externalGroup: ExternalGroupDto, systemId: EntityId): Promise { - if (externalGroup.users.length === 0) { - return; - } - const existingGroup: Group | null = await this.groupService.findByExternalSource( externalGroup.externalId, systemId @@ -145,6 +141,10 @@ export class OidcProvisioningService { const users: GroupUser[] = await this.getFilteredGroupUsers(externalGroup, systemId); + if (!users.length) { + return; + } + const group: Group = new Group({ id: existingGroup ? existingGroup.id : new ObjectId().toHexString(), name: externalGroup.name, @@ -156,8 +156,9 @@ export class OidcProvisioningService { organizationId, validFrom: externalGroup.from, validUntil: externalGroup.until, - users, + users: existingGroup ? existingGroup.users : [], }); + users.forEach((user: GroupUser) => group.addUser(user)); await this.groupService.save(group); } @@ -168,7 +169,7 @@ export class OidcProvisioningService { const user: UserDO | null = await this.userService.findByExternalId(externalGroupUser.externalUserId, systemId); const roles: RoleDto[] = await this.roleService.findByNames([externalGroupUser.roleName]); - if (!user || !user.id || roles.length !== 1 || !roles[0].id) { + if (!user?.id || roles.length !== 1 || !roles[0].id) { this.logger.info(new UserForGroupNotFoundLoggable(externalGroupUser)); return null; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index 902e2e850f2..c52e654155b 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -171,10 +171,6 @@ describe('SanisResponseMapper', () => { until: group.gruppe.laufzeit.bis, externalId: group.gruppe.id, users: [ - { - externalUserId: group.sonstige_gruppenzugehoerige![0].ktid, - roleName: RoleName.STUDENT, - }, { externalUserId: personenkontext.id, roleName: RoleName.TEACHER, @@ -206,9 +202,7 @@ describe('SanisResponseMapper', () => { describe('when a group role mapping is missing', () => { const setup = () => { const { sanisResponse } = setupSanisResponse(); - sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige![0].rollen = [ - SanisGroupRole.SCHOOL_SUPPORT, - ]; + sanisResponse.personenkontexte[0].gruppen![0]!.gruppenzugehoerigkeit.rollen = [SanisGroupRole.SCHOOL_SUPPORT]; return { sanisResponse, @@ -220,7 +214,7 @@ describe('SanisResponseMapper', () => { const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); - expect(result![0].users).toHaveLength(1); + expect(result![0].users).toHaveLength(0); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 34a7ff4029c..e11e57e9068 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -66,7 +66,7 @@ export class SanisResponseMapper { } mapToExternalGroupDtos(source: SanisResponse): ExternalGroupDto[] | undefined { - const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0].gruppen; + const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0]?.gruppen; if (!groups) { return undefined; @@ -81,12 +81,11 @@ export class SanisResponseMapper { } const sanisGroupUsers: SanisSonstigeGruppenzugehoerigeResponse[] = [ - ...(group.sonstige_gruppenzugehoerige ?? []), { ktid: source.personenkontexte[0].id, rollen: group.gruppenzugehoerigkeit.rollen, }, - ].sort((a, b) => a.ktid.localeCompare(b.ktid)); + ].filter((sanisGroupUser) => sanisGroupUser.ktid && sanisGroupUser.rollen); const gruppenzugehoerigkeiten: ExternalGroupUserDto[] = sanisGroupUsers .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) From ab0e6ba4afae16ed9af46ce203b2616192d56f2d Mon Sep 17 00:00:00 2001 From: WahlMartin <132356096+WahlMartin@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:00:41 +0200 Subject: [PATCH 21/34] EW-622: Refactoring of Keycloak "clean" nest job (#4436) * working on user deletion * adding integration test * cleaning up code * fixes linter * renaming option variable to make purpose clearer * decreases await calls * Temp commit * Temp commit 2 * Makes integration test work * cleans up test case * Revert "Temp commit 2" This reverts commit 9c7d04679ccf169590647b58e672590dfde7c993. * Revert "Temp commit" This reverts commit 952075c3b263e58014a9c97f1f08dfbcfec62edd. * Revert "cleans up test case" This reverts commit 2d116cd58bf41f65c8205c114b4b5f660b44d32d. * Revert "Makes integration test work" This reverts commit e0c1af95524c2384c546056b31c6e7f2d4d000b1. * use setup function instead of beforeEach --------- Co-authored-by: psachmann --- .../console/keycloak-configuration.console.ts | 22 ++++- .../keycloak-seed.service.integration.spec.ts | 91 +++++++++++++++++++ .../service/keycloak-seed.service.spec.ts | 39 ++++++-- .../service/keycloak-seed.service.ts | 32 ++++--- .../uc/keycloak-configuration.uc.ts | 4 +- package-lock.json | 23 +++++ package.json | 1 + 7 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts index 57dfd7cdde0..1d597e7020a 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/console/keycloak-configuration.console.ts @@ -15,6 +15,10 @@ interface IMigrationOptions { query?: string; verbose?: boolean; } + +interface ICleanOptions { + pageSize?: number; +} @Console({ command: 'idm', description: 'Prefixes all Identity Management (IDM) related console commands.' }) export class KeycloakConsole { constructor( @@ -53,21 +57,29 @@ export class KeycloakConsole { } /** - * For local development. Cleans user from IDM + * Cleans users from IDM * * @param options */ @Command({ command: 'clean', description: 'Remove all users from the IDM.', - options: KeycloakConsole.retryFlags, + options: [ + ...KeycloakConsole.retryFlags, + { + flags: '- mps, --maxPageSize ', + description: 'Maximum users to delete per Keycloak API session. Default 100.', + required: false, + defaultValue: 100, + }, + ], }) - async clean(options: IRetryOptions): Promise { + async clean(options: IRetryOptions & ICleanOptions): Promise { await this.repeatCommand( 'clean', async () => { - const count = await this.keycloakConfigurationUc.clean(); - this.console.info(`Cleaned ${count} users into IDM`); + const count = await this.keycloakConfigurationUc.clean(options.pageSize ? Number(options.pageSize) : 100); + this.console.info(`Cleaned ${count} users in IDM`); return count; }, options.retryCount, diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts new file mode 100644 index 00000000000..87f28a28d76 --- /dev/null +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.integration.spec.ts @@ -0,0 +1,91 @@ +import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; +import { faker } from '@faker-js/faker'; +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { LoggerModule } from '@src/core/logger'; +import { v1 } from 'uuid'; +import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; +import { KeycloakConfigurationModule } from '../keycloak-configuration.module'; +import { KeycloakSeedService } from './keycloak-seed.service'; + +describe('KeycloakSeedService Integration', () => { + let module: TestingModule; + let keycloak: KeycloakAdminClient; + let keycloakSeedService: KeycloakSeedService; + let keycloakAdministrationService: KeycloakAdministrationService; + let isKeycloakAvailable = false; + const numberOfIdmUsers = 1009; + + const testRealm = `test-realm-${v1().toString()}`; + + const createIdmUser = async (index: number): Promise => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + await keycloak.users.create({ + username: `${index}.${lastName}@sp-sh.de`, + firstName, + lastName, + email: `${index}.${lastName}@sp-sh.de`, + }); + }; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + KeycloakConfigurationModule, + LoggerModule, + MongoMemoryDatabaseModule.forRoot(), + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + ignoreEnvVars: true, + validate: () => { + return { + FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: true, + }; + }, + }), + ], + providers: [], + }).compile(); + keycloakAdministrationService = module.get(KeycloakAdministrationService); + isKeycloakAvailable = await keycloakAdministrationService.testKcConnection(); + if (isKeycloakAvailable) { + keycloak = await keycloakAdministrationService.callKcAdminClient(); + } + keycloakSeedService = module.get(KeycloakSeedService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + if (isKeycloakAvailable) { + await keycloak.realms.del({ realm: testRealm }); + } + }); + + // Execute this test for a test run against a running Keycloak instance + describe('clean', () => { + describe('Given all users are able to delete', () => { + const setup = async () => { + await keycloak.realms.create({ realm: testRealm, enabled: true }); + keycloak.setConfig({ realmName: testRealm }); + let i = 1; + for (i = 1; i <= numberOfIdmUsers; i += 1) { + // eslint-disable-next-line no-await-in-loop + await createIdmUser(i); + } + }; + + it('should delete all users in the IDM', async () => { + if (!isKeycloakAvailable) return; + await setup(); + const deletedUsers = await keycloakSeedService.clean(500); + expect(deletedUsers).toBe(numberOfIdmUsers); + }, 60000); + }); + }); +}); diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts index 8d411a5356a..19a3b28326e 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { LegacyLogger } from '@src/core/logger'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { AuthenticationManagement } from '@keycloak/keycloak-admin-client/lib/resources/authenticationManagement'; @@ -40,8 +41,12 @@ jest.mock('node:fs/promises', () => { describe('KeycloakSeedService', () => { let module: TestingModule; let serviceUnderTest: KeycloakSeedService; + let logger: DeepMocked; let settings: IKeycloakSettings; + let infoLogSpy: jest.SpyInstance; + let errorLogSpy: jest.SpyInstance; + let kcAdminClient: DeepMocked; const kcApiUsersMock = createMock(); const kcApiAuthenticationManagementMock = createMock(); @@ -142,6 +147,10 @@ describe('KeycloakSeedService', () => { }, }, }, + { + provide: LegacyLogger, + useValue: createMock(), + }, ], }).compile(); serviceUnderTest = module.get(KeycloakSeedService); @@ -177,16 +186,33 @@ describe('KeycloakSeedService', () => { ]; kcApiUsersMock.create.mockResolvedValue({ id: '' }); kcApiUsersMock.del.mockImplementation(async (): Promise => Promise.resolve()); - kcApiUsersMock.find.mockImplementation(async (arg): Promise => { - if (arg?.username) { + kcApiUsersMock.find + .mockImplementationOnce(async (arg): Promise => { + if (arg?.username) { + return Promise.resolve([]); + } + const userArray = [adminUser, ...users]; + return Promise.resolve(userArray); + }) + .mockImplementationOnce(async (arg): Promise => { + if (arg?.username) { + return Promise.resolve([]); + } return Promise.resolve([]); - } - const userArray = [adminUser, ...users]; - return Promise.resolve(userArray); - }); + }) + .mockImplementationOnce(async (): Promise => { + const userArray = [adminUser, ...users]; + return Promise.resolve(userArray); + }) + .mockImplementation(async (): Promise => Promise.resolve([])); + logger = module.get(LegacyLogger); + infoLogSpy = jest.spyOn(logger, 'log'); + errorLogSpy = jest.spyOn(logger, 'error'); }); beforeEach(() => { + infoLogSpy.mockReset(); + errorLogSpy.mockReset(); kcApiUsersMock.create.mockClear(); kcApiUsersMock.del.mockClear(); kcApiUsersMock.find.mockClear(); @@ -208,7 +234,6 @@ describe('KeycloakSeedService', () => { it('should clean all users, but the admin', async () => { const deleteSpy = jest.spyOn(kcApiUsersMock, 'del'); await serviceUnderTest.clean(); - users.forEach((user) => { expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({ id: user.id })); }); diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts index 078601d3248..eaf08cebe27 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/service/keycloak-seed.service.ts @@ -1,5 +1,6 @@ import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { Inject } from '@nestjs/common'; +import { LegacyLogger } from '@src/core/logger'; import fs from 'node:fs/promises'; import { IJsonAccount } from '../interface/json-account.interface'; import { IJsonUser } from '../interface/json-user.interface'; @@ -12,6 +13,7 @@ import { export class KeycloakSeedService { constructor( private readonly kcAdmin: KeycloakAdministrationService, + private readonly logger: LegacyLogger, @Inject(KeycloakConfigurationInputFiles) private readonly inputFiles: IKeycloakConfigurationInputFiles ) {} @@ -30,23 +32,29 @@ export class KeycloakSeedService { return userCount; } - public async clean(): Promise { - let kc = await this.kcAdmin.callKcAdminClient(); + public async clean(pageSize = 100): Promise { + let foundUsers = 1; + let deletedUsers = 0; const adminUser = this.kcAdmin.getAdminUser(); - const users = (await kc.users.find()).filter((user) => user.username !== adminUser); - - // eslint-disable-next-line no-restricted-syntax - for (const user of users) { - // needs to be called once per minute. To be save we call it in the loop. Ineffcient but ok, since only used to locally revert seeding + let kc = await this.kcAdmin.callKcAdminClient(); + this.logger.log(`Starting to delete users...`); + while (foundUsers > 0) { // eslint-disable-next-line no-await-in-loop kc = await this.kcAdmin.callKcAdminClient(); // eslint-disable-next-line no-await-in-loop - await kc.users.del({ - // can not be undefined, see filter above - id: user.id ?? '', - }); + const users = (await kc.users.find({ max: pageSize })).filter((user) => user.username !== adminUser); + foundUsers = users.length; + this.logger.log(`Amount of found Users: ${foundUsers}`); + for (const user of users) { + // eslint-disable-next-line no-await-in-loop + await kc.users.del({ + id: user.id ?? '', + }); + } + deletedUsers += foundUsers; + this.logger.log(`...deleted ${deletedUsers} users so far.`); } - return users.length; + return deletedUsers; } private async createOrUpdateIdmAccount(account: IJsonAccount, user: IJsonUser): Promise { diff --git a/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts b/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts index 0da4a04df4d..eabe596055e 100644 --- a/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts +++ b/apps/server/src/shared/infra/identity-management/keycloak-configuration/uc/keycloak-configuration.uc.ts @@ -17,8 +17,8 @@ export class KeycloakConfigurationUc { return this.kcAdmin.testKcConnection(); } - public async clean(): Promise { - return this.keycloakSeedService.clean(); + public async clean(pageSize?: number): Promise { + return this.keycloakSeedService.clean(pageSize); } public async seed(): Promise { diff --git a/package-lock.json b/package-lock.json index c19ca347f18..92a97849e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", + "@faker-js/faker": "^8.0.2", "@golevelup/ts-jest": "^0.3.4", "@jest-mock/express": "^1.4.5", "@nestjs/cli": "^10.1.17", @@ -2911,6 +2912,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@faker-js/faker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.1.0.tgz", + "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@feathers-plus/batch-loader": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@feathers-plus/batch-loader/-/batch-loader-0.3.6.tgz", @@ -26605,6 +26622,12 @@ } } }, + "@faker-js/faker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.1.0.tgz", + "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", + "dev": true + }, "@feathers-plus/batch-loader": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@feathers-plus/batch-loader/-/batch-loader-0.3.6.tgz", diff --git a/package.json b/package.json index 99a99b681d4..9a8c78c9377 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", + "@faker-js/faker": "^8.0.2", "@golevelup/ts-jest": "^0.3.4", "@jest-mock/express": "^1.4.5", "@nestjs/cli": "^10.1.17", From 2b27ebe46e724e5f61de316aa563834fcbbb2e0e Mon Sep 17 00:00:00 2001 From: wolfganggreschus Date: Tue, 10 Oct 2023 08:01:07 +0200 Subject: [PATCH 22/34] BC-4956 - due date validation (#4408) * change submissionControllerElement.dueDate from undefined to null --- .../content-element-update-content.spec.ts | 25 +++++++++++-------- .../update-element-content.body.params.ts | 1 + ...ssion-container-element-response.mapper.ts | 2 +- .../board/repo/board-do.builder-impl.ts | 5 +--- .../board/repo/recursive-save.visitor.ts | 5 +--- .../service/content-element-update.visitor.ts | 3 ++- .../board/service/content-element.service.ts | 1 - .../server/src/modules/board/uc/element.uc.ts | 1 - .../board/content-element.factory.ts | 1 + .../board/submission-container-element.do.ts | 6 ++--- ...ubmission-container-element-node.entity.ts | 4 +-- ...bmission-container-element-node.factory.ts | 4 ++- 12 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index bee1ad63f0f..16ae21dee78 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -11,6 +11,8 @@ import { SubmissionContainerElementNode, } from '@shared/domain'; import { + TestApiClient, + UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, @@ -19,8 +21,6 @@ import { fileElementNodeFactory, richTextElementNodeFactory, submissionContainerElementNodeFactory, - TestApiClient, - UserAndAccountTestFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server/server.module'; @@ -63,7 +63,10 @@ describe(`content element update content (api)`, () => { const parentCard = cardNodeFactory.buildWithId({ parent: column }); const richTextElement = richTextElementNodeFactory.buildWithId({ parent: parentCard }); const fileElement = fileElementNodeFactory.buildWithId({ parent: parentCard }); - const submissionContainerElement = submissionContainerElementNodeFactory.buildWithId({ parent: parentCard }); + const submissionContainerElement = submissionContainerElementNodeFactory.buildWithId({ + parent: parentCard, + dueDate: null, + }); const tomorrow = new Date(Date.now() + 86400000); const submissionContainerElementWithDueDate = submissionContainerElementNodeFactory.buildWithId({ @@ -166,7 +169,6 @@ describe(`content element update content (api)`, () => { it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => { const { loggedInClient, submissionContainerElement } = await setup(); - const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { data: { content: {}, @@ -177,7 +179,7 @@ describe(`content element update content (api)`, () => { expect(response.statusCode).toEqual(204); }); - it('should not change dueDate value without dueDate parameter for submission container element', async () => { + it('should not change dueDate when not proviced in submission container element without dueDate', async () => { const { loggedInClient, submissionContainerElement } = await setup(); await loggedInClient.patch(`${submissionContainerElement.id}/content`, { @@ -187,11 +189,10 @@ describe(`content element update content (api)`, () => { }, }); const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElement.id); - - expect(result.dueDate).toBeUndefined(); + expect(result.dueDate).toBeNull(); }); - it('should set dueDate value when dueDate parameter is provided for submission container element', async () => { + it('should set dueDate value when provided for submission container element', async () => { const { loggedInClient, submissionContainerElement } = await setup(); const inThreeDays = new Date(Date.now() + 259200000); @@ -207,18 +208,20 @@ describe(`content element update content (api)`, () => { expect(result.dueDate).toEqual(inThreeDays); }); - it('should unset dueDate value when dueDate parameter is not provided for submission container element', async () => { + it('should unset dueDate value when dueDate parameter is null for submission container element', async () => { const { loggedInClient, submissionContainerElementWithDueDate } = await setup(); await loggedInClient.patch(`${submissionContainerElementWithDueDate.id}/content`, { data: { - content: {}, + content: { + dueDate: null, + }, type: 'submissionContainer', }, }); const result = await em.findOneOrFail(SubmissionContainerElementNode, submissionContainerElementWithDueDate.id); - expect(result.dueDate).toBeUndefined(); + expect(result.dueDate).toBeNull(); }); it('should return status 400 for wrong date format for submission container element', async () => { diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 05856e9ef5f..208ec7d1d2d 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -56,6 +56,7 @@ export class SubmissionContainerContentBody { @IsOptional() @ApiPropertyOptional({ required: false, + nullable: true, description: 'The point in time until when a submission can be handed in.', }) dueDate?: Date; diff --git a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts index 8b3dc6ae54f..fc68da31d13 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts @@ -19,7 +19,7 @@ export class SubmissionContainerElementResponseMapper implements BaseResponseMap timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), type: ContentElementType.SUBMISSION_CONTAINER, content: new SubmissionContainerElementContent({ - dueDate: element.dueDate || null, + dueDate: element.dueDate, }), }); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index af58280b33f..0b1b2b59cb0 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -128,12 +128,9 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { children: elements, createdAt: boardNode.createdAt, updatedAt: boardNode.updatedAt, + dueDate: boardNode.dueDate, }); - if (boardNode.dueDate) { - element.dueDate = boardNode.dueDate; - } - return element; } diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index 5561e636267..d35b80a93aa 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -130,12 +130,9 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { id: submissionContainerElement.id, parent: parentData?.boardNode, position: parentData?.position, + dueDate: submissionContainerElement.dueDate, }); - if (submissionContainerElement.dueDate) { - boardNode.dueDate = submissionContainerElement.dueDate; - } - this.createOrUpdateBoardNode(boardNode); this.visitChildren(submissionContainerElement, boardNode); } diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index dfd430aa250..d5d950890d6 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -59,7 +59,8 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitor { visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { if (this.content instanceof SubmissionContainerContentBody) { - submissionContainerElement.dueDate = this.content.dueDate ?? undefined; + if (this.content.dueDate === undefined) return; + submissionContainerElement.dueDate = this.content.dueDate; } else { this.throwNotHandled(submissionContainerElement); } diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index bef5d076fc6..2a55ff17a08 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -47,7 +47,6 @@ export class ContentElementService { async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { const updater = new ContentElementUpdateVisitor(content); - element.accept(updater); const parent = await this.boardDoRepo.findParentOfId(element.id); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index 0dafd9eb98f..b66ce1bd247 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -31,7 +31,6 @@ export class ElementUc { const element = await this.elementService.findById(elementId); await this.checkPermission(userId, element, Action.write); - await this.elementService.update(element, content); } diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index fb476d2dbd0..ff8966aa77f 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -65,6 +65,7 @@ export class ContentElementFactory { private buildSubmissionContainer() { const element = new SubmissionContainerElement({ id: new ObjectId().toHexString(), + dueDate: null, children: [], createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts index 09756153a90..0980eb7a569 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts @@ -3,11 +3,11 @@ import { SubmissionItem } from './submission-item.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; export class SubmissionContainerElement extends BoardComposite { - get dueDate(): Date | undefined { + get dueDate(): Date | null { return this.props.dueDate; } - set dueDate(value: Date | undefined) { + set dueDate(value: Date | null) { this.props.dueDate = value; } @@ -26,7 +26,7 @@ export class SubmissionContainerElement extends BoardComposite(SubmissionContainerElementNode, () => { - return {}; + return { + dueDate: null, + }; }); From 4d5a69c7fbb1ecffc539ca507ab107ef7e436e4d Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Tue, 10 Oct 2023 14:03:42 +0200 Subject: [PATCH 23/34] BC-5044 - prevent password logging of failed edusharing requests --- src/middleware/errorHandler.js | 2 ++ src/services/edusharing/services/EduSharingConnectorV6.js | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 0cc57312116..463e924ee98 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -125,6 +125,7 @@ const secretDataKeys = (() => 'gradeComment', '_csrf', 'searchUserPassword', + 'authorization', ].map((k) => k.toLocaleLowerCase()))(); const filterSecretValue = (key, value) => { @@ -174,6 +175,7 @@ const filterSecrets = (error, req, res, next) => { if (error) { // req.url = filterQuery(req.url); req.originalUrl = filterQuery(req.originalUrl); + req.headers = filter(req.headers); req.body = filter(req.body); error.data = filter(error.data); error.options = filter(error.options); diff --git a/src/services/edusharing/services/EduSharingConnectorV6.js b/src/services/edusharing/services/EduSharingConnectorV6.js index 95a924a1eda..cc9f67f8230 100644 --- a/src/services/edusharing/services/EduSharingConnectorV6.js +++ b/src/services/edusharing/services/EduSharingConnectorV6.js @@ -114,7 +114,9 @@ class EduSharingConnector { if (err.statusCode === 404) { return null; } - logger.error(`Edu-Sharing failed request with error ${err.statusCode} ${err.message}`, options); + // eslint-disable-next-line no-unused-vars + const { headers, ...logOptions } = options; + logger.error(`Edu-Sharing failed request with error ${err.statusCode} ${err.message}`, logOptions); if (retried === true) { throw new GeneralError('Edu-Sharing Request failed'); } else { From a8f7cda9f399153caec5ce9340152b8a516f8c16 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:05:45 +0200 Subject: [PATCH 24/34] BC-5189 - link element (#4444) First implementation of LinkElement to be used on cards of columnBoards. * implemented open-graph-proxy-service to gather open graph data from urls * open-graph-data is fetched during updateElement and enriches the input from the user (=url) * invalid urls are handled in the open-graph-proxy * if multiple open-graph-images are provided the smallest one (exceeding a min-width) will be chosen * feature toogle is used to disable the feature for the moment --------- Co-authored-by: Oliver Happe --- apps/server/src/modules/board/board.module.ts | 2 + .../api-test/card-create.api.spec.ts | 11 +- .../content-element-update-content.spec.ts | 8 +- .../board/controller/card.controller.ts | 15 +- .../controller/dto/card/card.response.ts | 22 +- .../element/any-content-element.response.ts | 2 + .../board/controller/dto/element/index.ts | 5 +- .../dto/element/link-element.response.ts | 45 ++ .../update-element-content.body.params.ts | 18 + .../board/controller/element.controller.ts | 39 +- .../content-element-response.factory.spec.ts | 35 +- .../content-element-response.factory.ts | 2 + .../modules/board/controller/mapper/index.ts | 4 +- .../mapper/link-element-response.mapper.ts | 35 ++ .../board/repo/board-do.builder-impl.spec.ts | 24 +- .../board/repo/board-do.builder-impl.ts | 22 +- .../repo/recursive-delete.visitor.spec.ts | 21 + .../board/repo/recursive-delete.vistor.ts | 7 + .../board/repo/recursive-save.visitor.spec.ts | 18 + .../board/repo/recursive-save.visitor.ts | 18 + .../board-do-copy.service.spec.ts | 52 ++ .../recursive-copy.visitor.ts | 21 + .../board/service/card.service.spec.ts | 3 +- .../src/modules/board/service/card.service.ts | 10 +- .../content-element-update.visitor.spec.ts | 81 ++- .../service/content-element-update.visitor.ts | 75 ++- .../service/content-element.service.spec.ts | 70 ++- .../board/service/content-element.service.ts | 12 +- .../server/src/modules/board/service/index.ts | 1 + .../service/open-graph-proxy.service.spec.ts | 91 +++ .../board/service/open-graph-proxy.service.ts | 41 ++ .../server/src/modules/board/uc/element.uc.ts | 6 +- .../modules/copy-helper/types/copy.types.ts | 1 + .../domain/domainobject/board/card.do.ts | 2 + .../board/content-element.factory.ts | 16 + .../shared/domain/domainobject/board/index.ts | 3 +- .../board/link-element.do.spec.ts | 37 ++ .../domainobject/board/link-element.do.ts | 59 ++ .../board/types/any-content-element-do.ts | 13 +- .../board/types/board-composite-visitor.ts | 11 +- .../board/types/content-elements.enum.ts | 1 + .../src/shared/domain/entity/all-entities.ts | 2 + .../shared/domain/entity/boardnode/index.ts | 3 +- .../link-element-node.entity.spec.ts | 54 ++ .../boardnode/link-element-node.entity.ts | 36 ++ .../boardnode/types/board-do.builder.ts | 5 +- .../entity/boardnode/types/board-node-type.ts | 1 + .../shared/testing/factory/boardnode/index.ts | 3 +- .../boardnode/link-element-node.factory.ts | 14 + .../factory/domainobject/board/index.ts | 3 +- .../board/link-element.do.factory.ts | 15 + config/default.schema.json | 5 + config/development.json | 1 + package-lock.json | 585 +++++++++++++++++- package.json | 1 + src/services/config/publicAppConfigService.js | 1 + 56 files changed, 1550 insertions(+), 138 deletions(-) create mode 100644 apps/server/src/modules/board/controller/dto/element/link-element.response.ts create mode 100644 apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts create mode 100644 apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts create mode 100644 apps/server/src/modules/board/service/open-graph-proxy.service.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts create mode 100644 apps/server/src/shared/domain/domainobject/board/link-element.do.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts create mode 100644 apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts create mode 100644 apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index a002766b56d..fb04364b6c3 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -14,6 +14,7 @@ import { ColumnBoardService, ColumnService, ContentElementService, + OpenGraphProxyService, SubmissionItemService, } from './service'; import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './service/board-do-copy-service'; @@ -37,6 +38,7 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, + OpenGraphProxyService, ], exports: [ BoardDoAuthorizableService, diff --git a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts index beb31d4dd5c..a108d352759 100644 --- a/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/card-create.api.spec.ts @@ -87,7 +87,7 @@ describe(`card create (api)`, () => { em.clear(); const createCardBodyParams = { - requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE], + requiredEmptyElements: [ContentElementType.RICH_TEXT, ContentElementType.FILE, ContentElementType.LINK], }; return { user, columnBoardNode, columnNode, createCardBodyParams }; @@ -111,7 +111,7 @@ describe(`card create (api)`, () => { expect(result.id).toBeDefined(); }); - it('created card should contain empty text and file elements', async () => { + it('created card should contain empty text, file and link elements', async () => { const { user, columnNode, createCardBodyParams } = await setup(); currentUser = mapUserToCurrentUser(user); @@ -129,6 +129,12 @@ describe(`card create (api)`, () => { alternativeText: '', }, }, + { + type: 'link', + content: { + url: '', + }, + }, ]; const { result } = await api.post(columnNode.id, createCardBodyParams); @@ -136,6 +142,7 @@ describe(`card create (api)`, () => { expect(elements[0]).toMatchObject(expectedEmptyElements[0]); expect(elements[1]).toMatchObject(expectedEmptyElements[1]); + expect(elements[2]).toMatchObject(expectedEmptyElements[2]); }); it('should return status 400 as the content element is unknown', async () => { const { user, columnNode } = await setup(); diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index 16ae21dee78..6a292ffa93d 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -98,7 +98,7 @@ describe(`content element update content (api)`, () => { }; }; - it('should return status 204', async () => { + it('should return status 201', async () => { const { loggedInClient, richTextElement } = await setup(); const response = await loggedInClient.patch(`${richTextElement.id}/content`, { @@ -108,7 +108,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should actually change content of the element', async () => { @@ -167,7 +167,7 @@ describe(`content element update content (api)`, () => { expect(result.alternativeText).toEqual('rich text 1 some more text'); }); - it('should return status 204 (nothing changed) without dueDate parameter for submission container element', async () => { + it('should return status 201', async () => { const { loggedInClient, submissionContainerElement } = await setup(); const response = await loggedInClient.patch(`${submissionContainerElement.id}/content`, { data: { @@ -176,7 +176,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should not change dueDate when not proviced in submission container element without dueDate', async () => { diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index e76bdbe088c..38a979dbf1e 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -25,6 +25,7 @@ import { CreateContentElementBodyParams, ExternalToolElementResponse, FileElementResponse, + LinkElementResponse, MoveCardBodyParams, RenameBodyParams, RichTextElementResponse, @@ -116,19 +117,21 @@ export class CardController { @ApiOperation({ summary: 'Create a new element on a card.' }) @ApiExtraModels( - RichTextElementResponse, + ExternalToolElementResponse, FileElementResponse, - SubmissionContainerElementResponse, - ExternalToolElementResponse + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse ) @ApiResponse({ status: 201, schema: { oneOf: [ - { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, - { $ref: getSchemaPath(ExternalToolElementResponse) }, ], }, }) @@ -137,7 +140,7 @@ export class CardController { @ApiResponse({ status: 404, type: NotFoundException }) @Post(':cardId/elements') async createElement( - @Param() urlParams: CardUrlParams, // TODO add type-property ? + @Param() urlParams: CardUrlParams, @Body() bodyParams: CreateContentElementBodyParams, @CurrentUser() currentUser: ICurrentUser ): Promise { diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index 44ee426fb6b..3577fcbc2a1 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -1,11 +1,23 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; -import { AnyContentElementResponse, FileElementResponse, SubmissionContainerElementResponse } from '../element'; -import { RichTextElementResponse } from '../element/rich-text-element.response'; +import { + AnyContentElementResponse, + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse, +} from '../element'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; -@ApiExtraModels(RichTextElementResponse) +@ApiExtraModels( + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse +) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { this.id = id; @@ -32,8 +44,10 @@ export class CardResponse { type: 'array', items: { oneOf: [ - { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, ], }, diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index 1382b75b3f5..18415d172fa 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -1,10 +1,12 @@ import { ExternalToolElementResponse } from './external-tool-element.response'; import { FileElementResponse } from './file-element.response'; +import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; import { SubmissionContainerElementResponse } from './submission-container-element.response'; export type AnyContentElementResponse = | FileElementResponse + | LinkElementResponse | RichTextElementResponse | SubmissionContainerElementResponse | ExternalToolElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index b1ef77f8ec0..6787c007c1b 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -1,7 +1,8 @@ export * from './any-content-element.response'; export * from './create-content-element.body.params'; -export * from './update-element-content.body.params'; +export * from './external-tool-element.response'; export * from './file-element.response'; +export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; -export * from './external-tool-element.response'; +export * from './update-element-content.body.params'; diff --git a/apps/server/src/modules/board/controller/dto/element/link-element.response.ts b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts new file mode 100644 index 00000000000..d6c4a7e7080 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class LinkElementContent { + constructor({ url, title, description, imageUrl }: LinkElementContent) { + this.url = url; + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + } + + @ApiProperty() + url: string; + + @ApiProperty() + title: string; + + @ApiPropertyOptional() + description?: string; + + @ApiPropertyOptional() + imageUrl?: string; +} + +export class LinkElementResponse { + constructor({ id, content, timestamps, type }: LinkElementResponse) { + this.id = id; + this.content = content; + this.timestamps = timestamps; + this.type = type; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.LINK; + + @ApiProperty() + content: LinkElementContent; + + @ApiProperty() + timestamps: TimestampsResponse; +} diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 208ec7d1d2d..5eb0f239c1f 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -31,6 +31,20 @@ export class FileElementContentBody extends ElementContentBody { @ApiProperty() content!: FileContentBody; } +export class LinkContentBody { + @IsString() + @ApiProperty({}) + url!: string; +} + +export class LinkElementContentBody extends ElementContentBody { + @ApiProperty({ type: ContentElementType.LINK }) + type!: ContentElementType.LINK; + + @ValidateNested() + @ApiProperty({}) + content!: LinkContentBody; +} export class RichTextContentBody { @IsString() @@ -89,6 +103,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody | ExternalToolContentBody; @@ -100,6 +115,7 @@ export class UpdateElementContentBodyParams { property: 'type', subTypes: [ { value: FileElementContentBody, name: ContentElementType.FILE }, + { value: LinkElementContentBody, name: ContentElementType.LINK }, { value: RichTextElementContentBody, name: ContentElementType.RICH_TEXT }, { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, @@ -110,6 +126,7 @@ export class UpdateElementContentBodyParams { @ApiProperty({ oneOf: [ { $ref: getSchemaPath(FileElementContentBody) }, + { $ref: getSchemaPath(LinkElementContentBody) }, { $ref: getSchemaPath(RichTextElementContentBody) }, { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, { $ref: getSchemaPath(ExternalToolElementContentBody) }, @@ -117,6 +134,7 @@ export class UpdateElementContentBodyParams { }) data!: | FileElementContentBody + | LinkElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody; diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 361e59bf6b6..2dacd2cf539 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -10,24 +10,31 @@ import { Post, Put, } from '@nestjs/common'; -import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; import { ApiValidationError } from '@shared/common'; import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { CardUc } from '../uc'; import { ElementUc } from '../uc/element.uc'; import { + AnyContentElementResponse, ContentElementUrlParams, CreateSubmissionItemBodyParams, ExternalToolElementContentBody, + ExternalToolElementResponse, FileElementContentBody, + FileElementResponse, + LinkElementContentBody, + LinkElementResponse, MoveContentElementBody, RichTextElementContentBody, + RichTextElementResponse, SubmissionContainerElementContentBody, + SubmissionContainerElementResponse, SubmissionItemResponse, UpdateElementContentBodyParams, } from './dto'; -import { SubmissionItemResponseMapper } from './mapper'; +import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @ApiTags('Board Element') @Authenticate('jwt') @@ -60,20 +67,38 @@ export class ElementController { FileElementContentBody, RichTextElementContentBody, SubmissionContainerElementContentBody, - ExternalToolElementContentBody + ExternalToolElementContentBody, + LinkElementContentBody ) - @ApiResponse({ status: 204 }) + @ApiResponse({ + status: 201, + schema: { + oneOf: [ + { $ref: getSchemaPath(ExternalToolElementResponse) }, + { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, + { $ref: getSchemaPath(RichTextElementResponse) }, + { $ref: getSchemaPath(SubmissionContainerElementResponse) }, + ], + }, + }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) - @HttpCode(204) + @HttpCode(201) @Patch(':contentElementId/content') async updateElement( @Param() urlParams: ContentElementUrlParams, @Body() bodyParams: UpdateElementContentBodyParams, @CurrentUser() currentUser: ICurrentUser - ): Promise { - await this.elementUc.updateElementContent(currentUser.userId, urlParams.contentElementId, bodyParams.data.content); + ): Promise { + const element = await this.elementUc.updateElementContent( + currentUser.userId, + urlParams.contentElementId, + bodyParams.data.content + ); + const response = ContentElementResponseFactory.mapToResponse(element); + return response; } @ApiOperation({ summary: 'Delete a single content element.' }) diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index f813e44ae7a..2b61e273185 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -1,27 +1,36 @@ import { NotImplementedException } from '@nestjs/common'; -import { fileElementFactory, richTextElementFactory, submissionContainerElementFactory } from '@shared/testing'; -import { FileElementResponse, RichTextElementResponse, SubmissionContainerElementResponse } from '../dto'; +import { + fileElementFactory, + linkElementFactory, + richTextElementFactory, + submissionContainerElementFactory, +} from '@shared/testing'; +import { + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse, +} from '../dto'; import { ContentElementResponseFactory } from './content-element-response.factory'; describe(ContentElementResponseFactory.name, () => { - const setup = () => { - const fileElement = fileElementFactory.build(); - const richTextElement = richTextElementFactory.build(); - const submissionContainerElement = submissionContainerElementFactory.build(); - - return { fileElement, richTextElement, submissionContainerElement }; - }; - it('should return instance of FileElementResponse', () => { - const { fileElement } = setup(); + const fileElement = fileElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(fileElement); expect(result).toBeInstanceOf(FileElementResponse); }); + it('should return instance of LinkElementResponse', () => { + const linkElement = linkElementFactory.build(); + const result = ContentElementResponseFactory.mapToResponse(linkElement); + + expect(result).toBeInstanceOf(LinkElementResponse); + }); + it('should return instance of RichTextElementResponse', () => { - const { richTextElement } = setup(); + const richTextElement = richTextElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(richTextElement); @@ -29,7 +38,7 @@ describe(ContentElementResponseFactory.name, () => { }); it('should return instance of SubmissionContainerElementResponse', () => { - const { submissionContainerElement } = setup(); + const submissionContainerElement = submissionContainerElementFactory.build(); const result = ContentElementResponseFactory.mapToResponse(submissionContainerElement); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index 3ccf11b1bf2..bda46e4b73f 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -4,12 +4,14 @@ import { AnyContentElementResponse } from '../dto'; import { BaseResponseMapper } from './base-mapper.interface'; import { ExternalToolElementResponseMapper } from './external-tool-element-response.mapper'; import { FileElementResponseMapper } from './file-element-response.mapper'; +import { LinkElementResponseMapper } from './link-element-response.mapper'; import { RichTextElementResponseMapper } from './rich-text-element-response.mapper'; import { SubmissionContainerElementResponseMapper } from './submission-container-element-response.mapper'; export class ContentElementResponseFactory { private static mappers: BaseResponseMapper[] = [ FileElementResponseMapper.getInstance(), + LinkElementResponseMapper.getInstance(), RichTextElementResponseMapper.getInstance(), SubmissionContainerElementResponseMapper.getInstance(), ExternalToolElementResponseMapper.getInstance(), diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index 116692df5a4..a24a905ae3f 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -2,7 +2,9 @@ export * from './board-response.mapper'; export * from './card-response.mapper'; export * from './column-response.mapper'; export * from './content-element-response.factory'; +export * from './external-tool-element-response.mapper'; +export * from './file-element-response.mapper'; +export * from './link-element-response.mapper'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; -export * from './external-tool-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts new file mode 100644 index 00000000000..e6a31b2c07a --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -0,0 +1,35 @@ +import { ContentElementType, LinkElement } from '@shared/domain'; +import { LinkElementContent, LinkElementResponse, TimestampsResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class LinkElementResponseMapper implements BaseResponseMapper { + private static instance: LinkElementResponseMapper; + + public static getInstance(): LinkElementResponseMapper { + if (!LinkElementResponseMapper.instance) { + LinkElementResponseMapper.instance = new LinkElementResponseMapper(); + } + + return LinkElementResponseMapper.instance; + } + + mapToResponse(element: LinkElement): LinkElementResponse { + const result = new LinkElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.LINK, + content: new LinkElementContent({ + url: element.url, + title: element.title, + description: element.description, + imageUrl: element.imageUrl, + }), + }); + + return result; + } + + canMap(element: LinkElement): boolean { + return element instanceof LinkElement; + } +} diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index d640d3f6330..8bbc859fa17 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -1,10 +1,11 @@ -import { BoardNodeType, ExternalToolElement } from '@shared/domain'; +import { BoardNodeType, ExternalToolElement, LinkElement } from '@shared/domain'; import { cardNodeFactory, columnBoardNodeFactory, columnNodeFactory, externalToolElementNodeFactory, fileElementNodeFactory, + linkElementNodeFactory, richTextElementNodeFactory, setupEntities, submissionContainerElementNodeFactory, @@ -195,7 +196,7 @@ describe(BoardDoBuilderImpl.name, () => { expect(domainObject.constructor.name).toBe(ExternalToolElement.name); }); - it('should throw error if submissionContainerElement is not a leaf', () => { + it('should throw error if externalToolElement is not a leaf', () => { const externalToolElementNode = externalToolElementNodeFactory.buildWithId(); const columnNode = columnNodeFactory.buildWithId({ parent: externalToolElementNode }); @@ -205,6 +206,25 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a link element', () => { + it('should work without descendants', () => { + const linkElementNode = linkElementNodeFactory.buildWithId(); + + const domainObject = new BoardDoBuilderImpl().buildLinkElement(linkElementNode); + + expect(domainObject.constructor.name).toBe(LinkElement.name); + }); + + it('should throw error if linkElement is not a leaf', () => { + const linkElementNode = linkElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: linkElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildLinkElement(linkElementNode); + }).toThrowError(); + }); + }); + describe('ensure board node types', () => { it('should do nothing if type is correct', () => { const card = cardNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 0b1b2b59cb0..18b0583daa1 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -7,6 +7,7 @@ import type { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -19,6 +20,7 @@ import { ColumnBoard, ExternalToolElement, FileElement, + LinkElement, RichTextElement, SubmissionContainerElement, SubmissionItem, @@ -73,12 +75,15 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { public buildCard(boardNode: CardNode): Card { this.ensureBoardNodeType(this.getChildren(boardNode), [ BoardNodeType.FILE_ELEMENT, + BoardNodeType.LINK_ELEMENT, BoardNodeType.RICH_TEXT_ELEMENT, BoardNodeType.SUBMISSION_CONTAINER_ELEMENT, BoardNodeType.EXTERNAL_TOOL, ]); - const elements = this.buildChildren(boardNode); + const elements = this.buildChildren< + ExternalToolElement | FileElement | LinkElement | RichTextElement | SubmissionContainerElement + >(boardNode); const card = new Card({ id: boardNode.id, @@ -105,6 +110,21 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { return element; } + public buildLinkElement(boardNode: LinkElementNode): LinkElement { + this.ensureLeafNode(boardNode); + + const element = new LinkElement({ + id: boardNode.id, + url: boardNode.url, + title: boardNode.title, + imageUrl: boardNode.imageUrl, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + }); + return element; + } + public buildRichTextElement(boardNode: RichTextElementNode): RichTextElement { this.ensureLeafNode(boardNode); diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 75f0e9e2e99..d94e7ae5557 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -7,6 +7,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, setupEntities, submissionContainerElementFactory, submissionItemFactory, @@ -145,6 +146,26 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('visitLinkElementAsync', () => { + const setup = () => { + const childLinkElement = linkElementFactory.build(); + const linkElement = linkElementFactory.build({ + children: [childLinkElement], + }); + + return { linkElement, childLinkElement }; + }; + + it('should call entity remove', async () => { + const { linkElement, childLinkElement } = setup(); + + await service.visitLinkElementAsync(linkElement); + + expect(em.remove).toHaveBeenCalledWith(em.getReference(linkElement.constructor, linkElement.id)); + expect(em.remove).toHaveBeenCalledWith(em.getReference(childLinkElement.constructor, childLinkElement.id)); + }); + }); + describe('visitSubmissionContainerElementAsync', () => { const setup = () => { const childSubmissionContainerElement = submissionContainerElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index aaed699c26c..c2177e5dd1c 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -13,6 +13,7 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; @Injectable() @@ -44,6 +45,12 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(fileElement); } + async visitLinkElementAsync(linkElement: LinkElement): Promise { + this.deleteNode(linkElement); + + await this.visitChildrenAsync(linkElement); + } + async visitRichTextElementAsync(richTextElement: RichTextElement): Promise { this.deleteNode(richTextElement); await this.visitChildrenAsync(richTextElement); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index 088b6f7f54c..3fd95c18525 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -7,6 +7,7 @@ import { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -19,6 +20,7 @@ import { contextExternalToolEntityFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -137,6 +139,22 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a link element composite', () => { + it('should create or update the node', () => { + const linkElement = linkElementFactory.build(); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + visitor.visitLinkElement(linkElement); + + const expectedNode: Partial = { + id: linkElement.id, + type: BoardNodeType.LINK_ELEMENT, + url: linkElement.url, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('when visiting a rich text element composite', () => { it('should create or update the node', () => { const richTextElement = richTextElementFactory.build(); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index d35b80a93aa..7b2c7901605 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -22,6 +22,8 @@ import { SubmissionItem, SubmissionItemNode, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; +import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; import { BoardNodeRepo } from './board-node.repo'; @@ -108,6 +110,22 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.visitChildren(fileElement, boardNode); } + visitLinkElement(linkElement: LinkElement): void { + const parentData = this.parentsMap.get(linkElement.id); + + const boardNode = new LinkElementNode({ + id: linkElement.id, + url: linkElement.url, + title: linkElement.title, + imageUrl: linkElement.imageUrl, + parent: parentData?.boardNode, + position: parentData?.position, + }); + + this.createOrUpdateBoardNode(boardNode); + this.visitChildren(linkElement, boardNode); + } + visitRichTextElement(richTextElement: RichTextElement): void { const parentData = this.parentsMap.get(richTextElement.id); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index ba3643e6051..4b5393854d2 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -11,8 +11,10 @@ import { isColumnBoard, isExternalToolElement, isFileElement, + isLinkElement, isRichTextElement, isSubmissionContainerElement, + LinkElement, RichTextElement, SubmissionContainerElement, } from '@shared/domain'; @@ -23,6 +25,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, setupEntities, submissionContainerElementFactory, @@ -706,4 +709,53 @@ describe('recursive board copy visitor', () => { expect(result.type).toEqual(CopyElementType.EXTERNAL_TOOL_ELEMENT); }); }); + + describe('when copying a link element', () => { + const setup = () => { + const original = linkElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getLinkElementFromStatus = (status: CopyStatus): LinkElement => { + const copy = status.copyEntity; + + expect(isLinkElement(copy)).toEqual(true); + + return copy as LinkElement; + }; + + it('should return a link element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isLinkElement(result.copyEntity)).toEqual(true); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getLinkElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should be of type LinkElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.LINK_ELEMENT); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 4d1bf55f5ae..7b17194c15f 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -11,6 +11,7 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { FileRecordParentType } from '@shared/infra/rabbitmq'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { ObjectId } from 'bson'; @@ -122,6 +123,26 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { this.copyMap.set(original.id, copy); } + async visitLinkElementAsync(original: LinkElement): Promise { + const copy = new LinkElement({ + id: new ObjectId().toHexString(), + url: original.url, + title: original.title, + imageUrl: original.imageUrl, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.LINK_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + async visitRichTextElementAsync(original: RichTextElement): Promise { const copy = new RichTextElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/board/service/card.service.spec.ts b/apps/server/src/modules/board/service/card.service.spec.ts index 6a9a8f1d944..eb155793412 100644 --- a/apps/server/src/modules/board/service/card.service.spec.ts +++ b/apps/server/src/modules/board/service/card.service.spec.ts @@ -88,7 +88,8 @@ describe(CardService.name, () => { }; it('should call the card repository', async () => { - const { cardIds } = setup(); + const { cards, cardIds } = setup(); + boardDoRepo.findByIds.mockResolvedValueOnce(cards); await service.findByIds(cardIds); diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index 3ef34806397..b7fee25c94f 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -14,16 +14,16 @@ export class CardService { ) {} async findById(cardId: EntityId): Promise { - const card = await this.boardDoRepo.findByClassAndId(Card, cardId); - return card; + return this.boardDoRepo.findByClassAndId(Card, cardId); } async findByIds(cardIds: EntityId[]): Promise { const cards = await this.boardDoRepo.findByIds(cardIds); - if (cards.every((card) => card instanceof Card)) { - return cards as Card[]; + if (cards.some((card) => !(card instanceof Card))) { + throw new NotFoundException('some ids do not belong to a card'); } - throw new NotFoundException('some ids do not belong to a card'); + + return cards as Card[]; } async create(parent: Column, requiredEmptyElements?: ContentElementType[]): Promise { diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index b96a6ca9a41..8a8368fce2b 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -6,12 +6,14 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; import { ExternalToolContentBody, FileContentBody, RichTextContentBody } from '../controller/dto'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; describe(ContentElementUpdateVisitor.name, () => { describe('when visiting an unsupported component', () => { @@ -23,36 +25,37 @@ describe(ContentElementUpdateVisitor.name, () => { content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; const submissionItem = submissionItemFactory.build(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { board, column, card, submissionItem, updater }; }; describe('when component is a column board', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { board, updater } = setup(); - expect(() => updater.visitColumnBoard(board)).toThrow(); + await expect(updater.visitColumnBoardAsync(board)).rejects.toThrow(); }); }); describe('when component is a column', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { column, updater } = setup(); - expect(() => updater.visitColumn(column)).toThrow(); + await expect(() => updater.visitColumnAsync(column)).rejects.toThrow(); }); }); describe('when component is a card', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { card, updater } = setup(); - expect(() => updater.visitCard(card)).toThrow(); + await expect(() => updater.visitCardAsync(card)).rejects.toThrow(); }); }); describe('when component is a submission-item', () => { - it('should throw an error', () => { + it('should throw an error', async () => { const { submissionItem, updater } = setup(); - expect(() => updater.visitSubmissionItem(submissionItem)).toThrow(); + await expect(() => updater.visitSubmissionItemAsync(submissionItem)).rejects.toThrow(); }); }); }); @@ -63,15 +66,34 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { fileElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { fileElement, updater } = setup(); - expect(() => updater.visitFileElement(fileElement)).toThrow(); + await expect(() => updater.visitFileElementAsync(fileElement)).rejects.toThrow(); + }); + }); + + describe('when visiting a link element using the wrong content', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + const content = new FileContentBody(); + content.caption = 'a caption'; + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); + + return { linkElement, updater }; + }; + + it('should throw an error', async () => { + const { linkElement, updater } = setup(); + + await expect(() => updater.visitLinkElementAsync(linkElement)).rejects.toThrow(); }); }); @@ -80,15 +102,16 @@ describe(ContentElementUpdateVisitor.name, () => { const richTextElement = richTextElementFactory.build(); const content = new FileContentBody(); content.caption = 'a caption'; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { richTextElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { richTextElement, updater } = setup(); - expect(() => updater.visitRichTextElement(richTextElement)).toThrow(); + await expect(() => updater.visitRichTextElementAsync(richTextElement)).rejects.toThrow(); }); }); @@ -98,15 +121,16 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { submissionContainerElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { submissionContainerElement, updater } = setup(); - expect(() => updater.visitSubmissionContainerElement(submissionContainerElement)).toThrow(); + await expect(() => updater.visitSubmissionContainerElementAsync(submissionContainerElement)).rejects.toThrow(); }); }); @@ -116,15 +140,16 @@ describe(ContentElementUpdateVisitor.name, () => { const externalToolElement = externalToolElementFactory.build({ contextExternalToolId: undefined }); const content = new ExternalToolContentBody(); content.contextExternalToolId = new ObjectId().toHexString(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater, content }; }; - it('should update the content', () => { + it('should update the content', async () => { const { externalToolElement, updater, content } = setup(); - updater.visitExternalToolElement(externalToolElement); + await updater.visitExternalToolElementAsync(externalToolElement); expect(externalToolElement.contextExternalToolId).toEqual(content.contextExternalToolId); }); @@ -136,15 +161,16 @@ describe(ContentElementUpdateVisitor.name, () => { const content = new RichTextContentBody(); content.text = 'a text'; content.inputFormat = InputFormat.RICH_TEXT_CK5; - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { externalToolElement, updater } = setup(); - expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + await expect(() => updater.visitExternalToolElementAsync(externalToolElement)).rejects.toThrow(); }); }); @@ -152,15 +178,16 @@ describe(ContentElementUpdateVisitor.name, () => { const setup = () => { const externalToolElement = externalToolElementFactory.build(); const content = new ExternalToolContentBody(); - const updater = new ContentElementUpdateVisitor(content); + const openGraphProxyService = new OpenGraphProxyService(); + const updater = new ContentElementUpdateVisitor(content, openGraphProxyService); return { externalToolElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { externalToolElement, updater } = setup(); - expect(() => updater.visitExternalToolElement(externalToolElement)).toThrow(); + await expect(() => updater.visitExternalToolElementAsync(externalToolElement)).rejects.toThrow(); }); }); }); diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index d5d950890d6..0f75bcaee2d 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -1,7 +1,8 @@ +import { Injectable } from '@nestjs/common'; import { sanitizeRichText } from '@shared/controller'; import { AnyBoardDo, - BoardCompositeVisitor, + BoardCompositeVisitorAsync, Card, Column, ColumnBoard, @@ -12,74 +13,94 @@ import { SubmissionContainerElement, SubmissionItem, } from '@shared/domain'; +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { AnyElementContentBody, ExternalToolContentBody, FileContentBody, + LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, } from '../controller/dto'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; -export class ContentElementUpdateVisitor implements BoardCompositeVisitor { +@Injectable() +export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { private readonly content: AnyElementContentBody; - constructor(content: AnyElementContentBody) { + constructor(content: AnyElementContentBody, private readonly openGraphProxyService: OpenGraphProxyService) { this.content = content; } - visitColumnBoard(columnBoard: ColumnBoard): void { - this.throwNotHandled(columnBoard); + async visitColumnBoardAsync(columnBoard: ColumnBoard): Promise { + return this.rejectNotHandled(columnBoard); } - visitColumn(column: Column): void { - this.throwNotHandled(column); + async visitColumnAsync(column: Column): Promise { + return this.rejectNotHandled(column); } - visitCard(card: Card): void { - this.throwNotHandled(card); + async visitCardAsync(card: Card): Promise { + return this.rejectNotHandled(card); } - visitFileElement(fileElement: FileElement): void { + async visitFileElementAsync(fileElement: FileElement): Promise { if (this.content instanceof FileContentBody) { fileElement.caption = sanitizeRichText(this.content.caption, InputFormat.PLAIN_TEXT); fileElement.alternativeText = sanitizeRichText(this.content.alternativeText, InputFormat.PLAIN_TEXT); - } else { - this.throwNotHandled(fileElement); + return Promise.resolve(); } + return this.rejectNotHandled(fileElement); } - visitRichTextElement(richTextElement: RichTextElement): void { + async visitLinkElementAsync(linkElement: LinkElement): Promise { + if (this.content instanceof LinkContentBody) { + const urlWithProtocol = /:\/\//.test(this.content.url) ? this.content.url : `https://${this.content.url}`; + linkElement.url = new URL(urlWithProtocol).toString(); + const openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); + linkElement.title = openGraphData.title; + linkElement.description = openGraphData.description; + if (openGraphData.image) { + linkElement.imageUrl = openGraphData.image.url; + } + return Promise.resolve(); + } + return this.rejectNotHandled(linkElement); + } + + async visitRichTextElementAsync(richTextElement: RichTextElement): Promise { if (this.content instanceof RichTextContentBody) { richTextElement.text = sanitizeRichText(this.content.text, this.content.inputFormat); richTextElement.inputFormat = this.content.inputFormat; - } else { - this.throwNotHandled(richTextElement); + return Promise.resolve(); } + return this.rejectNotHandled(richTextElement); } - visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { + async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { - if (this.content.dueDate === undefined) return; - submissionContainerElement.dueDate = this.content.dueDate; - } else { - this.throwNotHandled(submissionContainerElement); + if (this.content.dueDate !== undefined) { + submissionContainerElement.dueDate = this.content.dueDate; + } + return Promise.resolve(); } + return this.rejectNotHandled(submissionContainerElement); } - visitSubmissionItem(submission: SubmissionItem): void { - this.throwNotHandled(submission); + async visitSubmissionItemAsync(submission: SubmissionItem): Promise { + return this.rejectNotHandled(submission); } - visitExternalToolElement(externalToolElement: ExternalToolElement): void { + async visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise { if (this.content instanceof ExternalToolContentBody && this.content.contextExternalToolId !== undefined) { // Updates should not remove an existing reference to a tool, to prevent orphan tool instances externalToolElement.contextExternalToolId = this.content.contextExternalToolId; - } else { - this.throwNotHandled(externalToolElement); + return Promise.resolve(); } + return this.rejectNotHandled(externalToolElement); } - private throwNotHandled(component: AnyBoardDo) { - throw new Error(`Cannot update element of type: '${component.constructor.name}'`); + private rejectNotHandled(component: AnyBoardDo): Promise { + return Promise.reject(new Error(`Cannot update element of type: '${component.constructor.name}'`)); } } diff --git a/apps/server/src/modules/board/service/content-element.service.spec.ts b/apps/server/src/modules/board/service/content-element.service.spec.ts index 1d41925dfab..b1326450089 100644 --- a/apps/server/src/modules/board/service/content-element.service.spec.ts +++ b/apps/server/src/modules/board/service/content-element.service.spec.ts @@ -1,18 +1,32 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ContentElementFactory, ContentElementType, FileElement, InputFormat, RichTextElement } from '@shared/domain'; +import { + ContentElementFactory, + ContentElementType, + FileElement, + InputFormat, + RichTextElement, + SubmissionContainerElement, +} from '@shared/domain'; import { setupEntities } from '@shared/testing'; import { cardFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, } from '@shared/testing/factory/domainobject'; -import { FileContentBody, RichTextContentBody, SubmissionContainerContentBody } from '../controller/dto'; +import { + FileContentBody, + LinkContentBody, + RichTextContentBody, + SubmissionContainerContentBody, +} from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementService } from './content-element.service'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; describe(ContentElementService.name, () => { let module: TestingModule; @@ -20,6 +34,7 @@ describe(ContentElementService.name, () => { let boardDoRepo: DeepMocked; let boardDoService: DeepMocked; let contentElementFactory: DeepMocked; + let openGraphProxyService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -37,6 +52,10 @@ describe(ContentElementService.name, () => { provide: ContentElementFactory, useValue: createMock(), }, + { + provide: OpenGraphProxyService, + useValue: createMock(), + }, ], }).compile(); @@ -44,6 +63,7 @@ describe(ContentElementService.name, () => { boardDoRepo = module.get(BoardDoRepo); boardDoService = module.get(BoardDoService); contentElementFactory = module.get(ContentElementFactory); + openGraphProxyService = module.get(OpenGraphProxyService); await setupEntities(); }); @@ -229,6 +249,44 @@ describe(ContentElementService.name, () => { }); }); + describe('when element is a link element', () => { + const setup = () => { + const linkElement = linkElementFactory.build(); + + const content = new LinkContentBody(); + content.url = 'https://www.medium.com/great-article'; + const card = cardFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValue(card); + + const imageResponse = { + title: 'Webpage-title', + description: '', + url: linkElement.url, + image: { url: 'https://my-open-graph-proxy.scvs.de/image/adefcb12ed3a' }, + }; + + openGraphProxyService.fetchOpenGraphData.mockResolvedValueOnce(imageResponse); + + return { linkElement, content, card, imageResponse }; + }; + + it('should persist the element', async () => { + const { linkElement, content, card } = setup(); + + await service.update(linkElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(linkElement, card); + }); + + it('should call open graph service', async () => { + const { linkElement, content, card } = setup(); + + await service.update(linkElement, content); + + expect(boardDoRepo.save).toHaveBeenCalledWith(linkElement, card); + }); + }); + describe('when element is a submission container element', () => { const setup = () => { const submissionContainerElement = submissionContainerElementFactory.build(); @@ -245,17 +303,17 @@ describe(ContentElementService.name, () => { it('should update the element', async () => { const { submissionContainerElement, content } = setup(); - await service.update(submissionContainerElement, content); + const element = (await service.update(submissionContainerElement, content)) as SubmissionContainerElement; - expect(submissionContainerElement.dueDate).toEqual(content.dueDate); + expect(element.dueDate).toEqual(content.dueDate); }); it('should persist the element', async () => { const { submissionContainerElement, content, card } = setup(); - await service.update(submissionContainerElement, content); + const element = await service.update(submissionContainerElement, content); - expect(boardDoRepo.save).toHaveBeenCalledWith(submissionContainerElement, card); + expect(boardDoRepo.save).toHaveBeenCalledWith(element, card); }); }); }); diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index 2a55ff17a08..a7c957173f3 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -11,13 +11,15 @@ import { AnyElementContentBody } from '../controller/dto'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementUpdateVisitor } from './content-element-update.visitor'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; @Injectable() export class ContentElementService { constructor( private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService, - private readonly contentElementFactory: ContentElementFactory + private readonly contentElementFactory: ContentElementFactory, + private readonly openGraphProxyService: OpenGraphProxyService ) {} async findById(elementId: EntityId): Promise { @@ -45,12 +47,14 @@ export class ContentElementService { await this.boardDoService.move(element, targetCard, targetPosition); } - async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { - const updater = new ContentElementUpdateVisitor(content); - element.accept(updater); + async update(element: AnyContentElementDo, content: AnyElementContentBody): Promise { + const updater = new ContentElementUpdateVisitor(content, this.openGraphProxyService); + await element.acceptAsync(updater); const parent = await this.boardDoRepo.findParentOfId(element.id); await this.boardDoRepo.save(element, parent); + + return element; } } diff --git a/apps/server/src/modules/board/service/index.ts b/apps/server/src/modules/board/service/index.ts index 8ff2787f35d..ac9c686d4b4 100644 --- a/apps/server/src/modules/board/service/index.ts +++ b/apps/server/src/modules/board/service/index.ts @@ -4,4 +4,5 @@ export * from './card.service'; export * from './column-board.service'; export * from './column.service'; export * from './content-element.service'; +export * from './open-graph-proxy.service'; export * from './submission-item.service'; diff --git a/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts new file mode 100644 index 00000000000..debe76cdeba --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts @@ -0,0 +1,91 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; + +let ogsResponseMock = {}; +jest.mock( + 'open-graph-scraper', + () => () => + Promise.resolve({ + error: false, + html: '', + response: {}, + result: ogsResponseMock, + }) +); + +describe(OpenGraphProxyService.name, () => { + let module: TestingModule; + let service: OpenGraphProxyService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [OpenGraphProxyService], + }).compile(); + + service = module.get(OpenGraphProxyService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('create', () => { + it('should return also the original url', async () => { + const url = 'https://de.wikipedia.org'; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ url })); + }); + + it('should thrown an error if url is an empty string', async () => { + const url = ''; + + await expect(service.fetchOpenGraphData(url)).rejects.toThrow(); + }); + + it('should return ogTitle as title', async () => { + const ogTitle = 'My Title'; + const url = 'https://de.wikipedia.org'; + ogsResponseMock = { ogTitle }; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ title: ogTitle })); + }); + + it('should return ogImage as title', async () => { + const ogImage: ImageObject[] = [ + { + width: 800, + type: 'jpeg', + url: 'big-image.jpg', + }, + { + width: 500, + type: 'jpeg', + url: 'medium-image.jpg', + }, + { + width: 300, + type: 'jpeg', + url: 'small-image.jpg', + }, + ]; + const url = 'https://de.wikipedia.org'; + ogsResponseMock = { ogImage }; + + const result = await service.fetchOpenGraphData(url); + + expect(result).toEqual(expect.objectContaining({ image: ogImage[1] })); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/open-graph-proxy.service.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.ts new file mode 100644 index 00000000000..2b54d75ee82 --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import ogs from 'open-graph-scraper'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; + +type OpenGraphData = { + title: string; + description: string; + url: string; + image?: ImageObject; +}; + +@Injectable() +export class OpenGraphProxyService { + async fetchOpenGraphData(url: string): Promise { + if (url.length === 0) { + throw new Error(`OpenGraphProxyService requires a valid URL. Given URL: ${url}`); + } + + const data = await ogs({ url }); + // WIP: add nice debug logging for available openGraphData?!? + + const title = data.result.ogTitle ?? ''; + const description = data.result.ogDescription ?? ''; + const image = data.result.ogImage ? this.pickImage(data.result.ogImage) : undefined; + + return { + title, + description, + image, + url, + }; + } + + private pickImage(images: ImageObject[], minWidth = 400): ImageObject | undefined { + const sortedImages = [...images]; + sortedImages.sort((a, b) => (a.width && b.width ? Number(a.width) - Number(b.width) : 0)); + const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); + const fallbackImage = images[0] && images[0].width === undefined ? images[0] : undefined; + return smallestBigEnoughImage ?? fallbackImage; + } +} diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index b66ce1bd247..e5dc039168c 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -28,10 +28,12 @@ export class ElementUc { } async updateElementContent(userId: EntityId, elementId: EntityId, content: AnyElementContentBody) { - const element = await this.elementService.findById(elementId); + let element = await this.elementService.findById(elementId); await this.checkPermission(userId, element, Action.write); - await this.elementService.update(element, content); + + element = await this.elementService.update(element, content); + return element; } async createSubmissionItem( diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index 77f0f80e4cc..fff1f0da795 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -34,6 +34,7 @@ export enum CopyElementType { 'LESSON_CONTENT_TEXT' = 'LESSON_CONTENT_TEXT', 'LERNSTORE_MATERIAL' = 'LERNSTORE_MATERIAL', 'LERNSTORE_MATERIAL_GROUP' = 'LERNSTORE_MATERIAL_GROUP', + 'LINK_ELEMENT' = 'LINK_ELEMENT', 'LTITOOL_GROUP' = 'LTITOOL_GROUP', 'METADATA' = 'METADATA', 'RICHTEXT_ELEMENT' = 'RICHTEXT_ELEMENT', diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index 652d30ff027..62931e418dd 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -1,6 +1,7 @@ import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; +import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; @@ -25,6 +26,7 @@ export class Card extends BoardComposite { isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof FileElement || + domainObject instanceof LinkElement || domainObject instanceof RichTextElement || domainObject instanceof SubmissionContainerElement || domainObject instanceof ExternalToolElement; diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index ff8966aa77f..8c34ca54b56 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -3,6 +3,7 @@ import { InputFormat } from '@shared/domain/types'; import { ObjectId } from 'bson'; import { ExternalToolElement } from './external-tool-element.do'; import { FileElement } from './file-element.do'; +import { LinkElement } from './link-element.do'; import { RichTextElement } from './rich-text-element.do'; import { SubmissionContainerElement } from './submission-container-element.do'; import { AnyContentElementDo, ContentElementType } from './types'; @@ -16,6 +17,9 @@ export class ContentElementFactory { case ContentElementType.FILE: element = this.buildFile(); break; + case ContentElementType.LINK: + element = this.buildLink(); + break; case ContentElementType.RICH_TEXT: element = this.buildRichText(); break; @@ -49,6 +53,18 @@ export class ContentElementFactory { return element; } + private buildLink() { + const element = new LinkElement({ + id: new ObjectId().toHexString(), + url: '', + title: '', + createdAt: new Date(), + updatedAt: new Date(), + }); + + return element; + } + private buildRichText() { const element = new RichTextElement({ id: new ObjectId().toHexString(), diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index 86f4d2639c3..9701ba40099 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -3,10 +3,11 @@ export * from './card.do'; export * from './column-board.do'; export * from './column.do'; export * from './content-element.factory'; +export * from './external-tool-element.do'; export * from './file-element.do'; +export * from './link-element.do'; export * from './rich-text-element.do'; export * from './submission-container-element.do'; export * from './submission-item.do'; export * from './submission-item.factory'; -export * from './external-tool-element.do'; export * from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts new file mode 100644 index 00000000000..4a044e9be58 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts @@ -0,0 +1,37 @@ +import { createMock } from '@golevelup/ts-jest'; +import { linkElementFactory } from '@shared/testing'; +import { LinkElement } from './link-element.do'; +import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +describe(LinkElement.name, () => { + describe('when trying to add a child to a link element', () => { + it('should throw an error ', () => { + const linkElement = linkElementFactory.build(); + const linkElementFactoryChild = linkElementFactory.build(); + + expect(() => linkElement.addChild(linkElementFactoryChild)).toThrow(); + }); + }); + + describe('accept', () => { + it('should call the right visitor method', () => { + const visitor = createMock(); + const linkElement = linkElementFactory.build(); + + linkElement.accept(visitor); + + expect(visitor.visitLinkElement).toHaveBeenCalledWith(linkElement); + }); + }); + + describe('acceptAsync', () => { + it('should call the right async visitor method', async () => { + const visitor = createMock(); + const linkElement = linkElementFactory.build(); + + await linkElement.acceptAsync(visitor); + + expect(visitor.visitLinkElementAsync).toHaveBeenCalledWith(linkElement); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/link-element.do.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts new file mode 100644 index 00000000000..7b38cbd938e --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -0,0 +1,59 @@ +import { BoardComposite, BoardCompositeProps } from './board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; + +export class LinkElement extends BoardComposite { + get url(): string { + return this.props.url ?? ''; + } + + set url(value: string) { + this.props.url = value; + } + + get title(): string { + return this.props.title ?? ''; + } + + set title(value: string) { + this.props.title = value; + } + + get description(): string { + return this.props.description ?? ''; + } + + set description(value: string) { + this.props.description = value ?? ''; + } + + get imageUrl(): string { + return this.props.imageUrl ?? ''; + } + + set imageUrl(value: string) { + this.props.imageUrl = value; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitLinkElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitLinkElementAsync(this); + } +} + +export interface LinkElementProps extends BoardCompositeProps { + url: string; + title: string; + description?: string; + imageUrl?: string; +} + +export function isLinkElement(reference: unknown): reference is LinkElement { + return reference instanceof LinkElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts index d6ccfbd56ca..614071e658c 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-content-element-do.ts @@ -1,17 +1,24 @@ import { ExternalToolElement } from '../external-tool-element.do'; import { FileElement } from '../file-element.do'; +import { LinkElement } from '../link-element.do'; import { RichTextElement } from '../rich-text-element.do'; import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; -export type AnyContentElementDo = FileElement | RichTextElement | SubmissionContainerElement | ExternalToolElement; +export type AnyContentElementDo = + | ExternalToolElement + | FileElement + | LinkElement + | RichTextElement + | SubmissionContainerElement; export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const result = + element instanceof ExternalToolElement || element instanceof FileElement || + element instanceof LinkElement || element instanceof RichTextElement || - element instanceof SubmissionContainerElement || - element instanceof ExternalToolElement; + element instanceof SubmissionContainerElement; return result; }; diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index 38e16fc8e5f..3fbd4abdd96 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,17 +1,19 @@ import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; -import { ExternalToolElement } from '../external-tool-element.do'; +import type { ExternalToolElement } from '../external-tool-element.do'; import type { FileElement } from '../file-element.do'; -import { RichTextElement } from '../rich-text-element.do'; -import { SubmissionContainerElement } from '../submission-container-element.do'; -import { SubmissionItem } from '../submission-item.do'; +import type { LinkElement } from '../link-element.do'; +import type { RichTextElement } from '../rich-text-element.do'; +import type { SubmissionContainerElement } from '../submission-container-element.do'; +import type { SubmissionItem } from '../submission-item.do'; export interface BoardCompositeVisitor { visitColumnBoard(columnBoard: ColumnBoard): void; visitColumn(column: Column): void; visitCard(card: Card): void; visitFileElement(fileElement: FileElement): void; + visitLinkElement(linkElement: LinkElement): void; visitRichTextElement(richTextElement: RichTextElement): void; visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void; visitSubmissionItem(submissionItem: SubmissionItem): void; @@ -23,6 +25,7 @@ export interface BoardCompositeVisitorAsync { visitColumnAsync(column: Column): Promise; visitCardAsync(card: Card): Promise; visitFileElementAsync(fileElement: FileElement): Promise; + visitLinkElementAsync(linkElement: LinkElement): Promise; visitRichTextElementAsync(richTextElement: RichTextElement): Promise; visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise; visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; diff --git a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts index 4c6ce7269bd..b8d4e166e25 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/content-elements.enum.ts @@ -1,5 +1,6 @@ export enum ContentElementType { FILE = 'file', + LINK = 'link', RICH_TEXT = 'richText', SUBMISSION_CONTAINER = 'submissionContainer', EXTERNAL_TOOL = 'externalTool', diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 406fda13bf4..6cbb3bf9810 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -13,6 +13,7 @@ import { ColumnNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -58,6 +59,7 @@ export const ALL_ENTITIES = [ ColumnNode, ClassEntity, FileElementNode, + LinkElementNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index cd7cc9d65be..a3a56e6dfe0 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -2,9 +2,10 @@ export * from './boardnode.entity'; export * from './card-node.entity'; export * from './column-board-node.entity'; export * from './column-node.entity'; +export * from './external-tool-element-node.entity'; export * from './file-element-node.entity'; +export * from './link-element-node.entity'; export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; -export * from './external-tool-element-node.entity'; export * from './types'; diff --git a/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts new file mode 100644 index 00000000000..1093e57922e --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts @@ -0,0 +1,54 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { linkElementFactory } from '@shared/testing'; +import { LinkElementNode } from './link-element-node.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +describe(LinkElementNode.name, () => { + describe('when trying to create a link element', () => { + const setup = () => { + const elementProps = { url: 'https://www.any-fake.url/that-is-linked.html', title: 'A Great WebPage' }; + const builder: DeepMocked = createMock(); + + return { elementProps, builder }; + }; + + it('should create a LinkElementNode', () => { + const { elementProps } = setup(); + + const element = new LinkElementNode(elementProps); + + expect(element.type).toEqual(BoardNodeType.LINK_ELEMENT); + }); + }); + + describe('useDoBuilder()', () => { + const setup = () => { + const element = new LinkElementNode({ + url: 'https://www.any-fake.url/that-is-linked.html', + title: 'A Great WebPage', + }); + const builder: DeepMocked = createMock(); + const elementDo = linkElementFactory.build(); + + builder.buildLinkElement.mockReturnValue(elementDo); + + return { element, builder, elementDo }; + }; + + it('should call the specific builder method', () => { + const { element, builder } = setup(); + + element.useDoBuilder(builder); + + expect(builder.buildLinkElement).toHaveBeenCalledWith(element); + }); + + it('should return RichTextElementDo', () => { + const { element, builder, elementDo } = setup(); + + const result = element.useDoBuilder(builder); + + expect(result).toEqual(elementDo); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts new file mode 100644 index 00000000000..0102821d97b --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts @@ -0,0 +1,36 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { AnyBoardDo } from '../../domainobject'; +import { BoardNode, BoardNodeProps } from './boardnode.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; + +@Entity({ discriminatorValue: BoardNodeType.LINK_ELEMENT }) +export class LinkElementNode extends BoardNode { + @Property() + url: string; + + @Property() + title: string; + + @Property() + imageUrl?: string; + + constructor(props: LinkElementNodeProps) { + super(props); + this.type = BoardNodeType.LINK_ELEMENT; + this.url = props.url; + this.title = props.title; + this.imageUrl = props.imageUrl; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildLinkElement(this); + + return domainObject; + } +} + +export interface LinkElementNodeProps extends BoardNodeProps { + url: string; + title: string; + imageUrl?: string; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index a5c2a8b2e16..1b759a41180 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -1,18 +1,20 @@ -import { SubmissionItem } from '@shared/domain/domainobject/board/submission-item.do'; import type { Card, Column, ColumnBoard, ExternalToolElement, FileElement, + LinkElement, RichTextElement, SubmissionContainerElement, + SubmissionItem, } from '../../../domainobject'; import type { CardNode } from '../card-node.entity'; import type { ColumnBoardNode } from '../column-board-node.entity'; import type { ColumnNode } from '../column-node.entity'; import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; +import type { LinkElementNode } from '../link-element-node.entity'; import type { RichTextElementNode } from '../rich-text-element-node.entity'; import type { SubmissionContainerElementNode } from '../submission-container-element-node.entity'; import type { SubmissionItemNode } from '../submission-item-node.entity'; @@ -22,6 +24,7 @@ export interface BoardDoBuilder { buildColumn(boardNode: ColumnNode): Column; buildCard(boardNode: CardNode): Card; buildFileElement(boardNode: FileElementNode): FileElement; + buildLinkElement(boardNode: LinkElementNode): LinkElement; buildRichTextElement(boardNode: RichTextElementNode): RichTextElement; buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement; buildSubmissionItem(boardNode: SubmissionItemNode): SubmissionItem; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index a1b44207907..0b25a81b053 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -3,6 +3,7 @@ export enum BoardNodeType { COLUMN = 'column', CARD = 'card', FILE_ELEMENT = 'file-element', + LINK_ELEMENT = 'link-element', RICH_TEXT_ELEMENT = 'rich-text-element', SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', diff --git a/apps/server/src/shared/testing/factory/boardnode/index.ts b/apps/server/src/shared/testing/factory/boardnode/index.ts index 410a399ccff..14ae5c29312 100644 --- a/apps/server/src/shared/testing/factory/boardnode/index.ts +++ b/apps/server/src/shared/testing/factory/boardnode/index.ts @@ -1,8 +1,9 @@ export * from './card-node.factory'; export * from './column-board-node.factory'; export * from './column-node.factory'; +export * from './external-tool-element-node.factory'; export * from './file-element-node.factory'; +export * from './link-element-node.factory'; export * from './rich-text-element-node.factory'; export * from './submission-container-element-node.factory'; export * from './submission-item-node.factory'; -export * from './external-tool-element-node.factory'; diff --git a/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts new file mode 100644 index 00000000000..1725634705f --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ +import { LinkElementNode, LinkElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const linkElementNodeFactory = BaseFactory.define( + LinkElementNode, + ({ sequence }) => { + const url = `https://www.example.com/link/${sequence}`; + return { + url, + title: `The example page ${sequence}`, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index e7b3bae56ed..9a6cdf84839 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -1,8 +1,9 @@ export * from './card.do.factory'; export * from './column-board.do.factory'; export * from './column.do.factory'; +export * from './external-tool.do.factory'; export * from './file-element.do.factory'; +export * from './link-element.do.factory'; export * from './rich-text-element.do.factory'; export * from './submission-container-element.do.factory'; export * from './submission-item.do.factory'; -export * from './external-tool.do.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts new file mode 100644 index 00000000000..af0e55a1912 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +import { LinkElement, LinkElementProps } from '@shared/domain'; +import { ObjectId } from 'bson'; +import { BaseFactory } from '../../base.factory'; + +export const linkElementFactory = BaseFactory.define(LinkElement, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + url: `https://www.example.com/link/${sequence}`, + title: 'Website opengraph title', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/config/default.schema.json b/config/default.schema.json index 9103cfd7d4f..e6084e5c331 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1050,6 +1050,11 @@ "default": false, "description": "Enable submissions in column board." }, + "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enable link elements in column board." + }, "COLUMN_BOARD_HELP_LINK": { "type": "string", "default": "https://docs.dbildungscloud.de/pages/viewpage.action?pageId=270827606", diff --git a/config/development.json b/config/development.json index a2b8ba524a9..43d1b18640f 100644 --- a/config/development.json +++ b/config/development.json @@ -68,5 +68,6 @@ "FEATURE_COURSE_SHARE": true, "FEATURE_COLUMN_BOARD_ENABLED": true, "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, + "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true } diff --git a/package-lock.json b/package-lock.json index 92a97849e7e..dde18164a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "nest-winston": "^1.9.4", "nestjs-console": "^9.0.0", "oauth-1.0a": "^2.2.6", + "open-graph-scraper": "^6.2.2", "p-limit": "^3.1.0", "papaparse": "^5.1.1", "passport": "^0.6.0", @@ -7427,6 +7428,11 @@ "node": ">= 0.8" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -7902,6 +7908,162 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -8882,6 +9044,83 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/css-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/daemon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", @@ -9154,9 +9393,9 @@ } }, "node_modules/domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -18029,6 +18268,17 @@ "gauge": "~1.2.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/number-allocator": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", @@ -18452,6 +18702,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-graph-scraper": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", + "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "dependencies": { + "chardet": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "undici": "^5.22.1", + "validator": "^13.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/open-graph-scraper/node_modules/chardet": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", + "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -18769,6 +19038,54 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -23512,6 +23829,17 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "node_modules/undici": { + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", + "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/universal-analytics": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", @@ -23726,9 +24054,9 @@ } }, "node_modules/validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", "engines": { "node": ">= 0.10" } @@ -30064,6 +30392,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -30431,6 +30764,114 @@ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + } + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -31229,6 +31670,58 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "daemon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", @@ -31427,9 +31920,9 @@ } }, "domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" }, "domhandler": { "version": "4.3.0", @@ -38138,6 +38631,14 @@ "gauge": "~1.2.0" } }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "number-allocator": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", @@ -38464,6 +38965,24 @@ "is-wsl": "^2.2.0" } }, + "open-graph-scraper": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", + "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "requires": { + "chardet": "^1.6.0", + "cheerio": "^1.0.0-rc.12", + "undici": "^5.22.1", + "validator": "^13.9.0" + }, + "dependencies": { + "chardet": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", + "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -38696,6 +39215,40 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + }, + "dependencies": { + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "dependencies": { + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + } + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -42272,6 +42825,14 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, + "undici": { + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", + "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "requires": { + "busboy": "^1.6.0" + } + }, "universal-analytics": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", @@ -42462,9 +43023,9 @@ } }, "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index 9a8c78c9377..97cb84c069e 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "nest-winston": "^1.9.4", "nestjs-console": "^9.0.0", "oauth-1.0a": "^2.2.6", + "open-graph-scraper": "^6.2.2", "p-limit": "^3.1.0", "papaparse": "^5.1.1", "passport": "^0.6.0", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 31a3ae22224..06a54c6cf96 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -39,6 +39,7 @@ const exposedVars = [ 'SC_TITLE', 'FEATURE_COLUMN_BOARD_ENABLED', 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', + 'FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED', 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED', 'FEATURE_COURSE_SHARE', 'FEATURE_COURSE_SHARE_NEW', From 43d02f81a6e832e2df394f1f3f54e932fa6f1a78 Mon Sep 17 00:00:00 2001 From: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:22:26 +0200 Subject: [PATCH 25/34] BC-5048 - better integration of virusscan (#4421) - add stream to antivirus for previewable types only - add feature flag - fix bug by zero byte file upload --- .../files-storage-copy-files.api.spec.ts | 2 +- .../files-storage-delete-files.api.spec.ts | 2 +- .../files-storage-download-upload.api.spec.ts | 2 +- .../files-storage-preview.api.spec.ts | 2 +- .../files-storage-restore-files.api.spec.ts | 2 +- .../controller/dto/file-storage.params.ts | 3 +- .../files-storage/entity/filerecord.entity.ts | 8 +- .../files-storage/files-storage.config.ts | 4 +- .../files-storage/files-storage.module.ts | 2 + .../files-storage-copy.service.spec.ts | 2 +- .../files-storage-delete.service.spec.ts | 2 +- .../files-storage-download.service.spec.ts | 2 +- .../service/files-storage-get.service.spec.ts | 2 +- .../files-storage-restore.service.spec.ts | 2 +- .../files-storage-update.service.spec.ts | 2 +- .../files-storage-upload.service.spec.ts | 49 ++++++-- .../service/files-storage.service.ts | 25 ++++- .../uc/files-storage-copy.uc.spec.ts | 2 +- .../uc/files-storage-delete.uc.spec.ts | 2 +- .../files-storage-download-preview.uc.spec.ts | 2 +- .../uc/files-storage-download.uc.spec.ts | 2 +- .../uc/files-storage-get.uc.spec.ts | 2 +- .../uc/files-storage-restore.uc.spec.ts | 2 +- .../uc/files-storage-update.uc.spec.ts | 2 +- .../uc/files-storage-upload.uc.spec.ts | 2 +- .../infra/antivirus/antivirus.module.spec.ts | 2 + .../infra/antivirus/antivirus.module.ts | 28 +++-- .../infra/antivirus/antivirus.service.spec.ts | 105 ++++++++++++++++++ .../infra/antivirus/antivirus.service.ts | 40 +++++-- .../src/shared/infra/antivirus/index.ts | 3 + .../infra/antivirus/interfaces/antivirus.ts | 21 ++++ .../infra/antivirus/interfaces/index.ts | 1 + config/default.schema.json | 24 ++++ package-lock.json | 55 +++++++++ package.json | 2 + 35 files changed, 356 insertions(+), 54 deletions(-) create mode 100644 apps/server/src/shared/infra/antivirus/index.ts create mode 100644 apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts create mode 100644 apps/server/src/shared/infra/antivirus/interfaces/index.ts diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts index a0b0c63a8a6..659197c73d9 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts index de360aa60f9..e63bc036510 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts index 9ec672dfd36..de8cda56198 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts index d1aedc97f9e..f905cae5399 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication, NotFoundException, StreamableFile } import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts index 603221b6651..7835c0217d4 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts @@ -4,7 +4,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { cleanupCollections, diff --git a/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts b/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts index d474685caf3..6555b7bd0f9 100644 --- a/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts +++ b/apps/server/src/modules/files-storage/controller/dto/file-storage.params.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { StringToBoolean } from '@shared/controller'; import { EntityId } from '@shared/domain'; +import { ScanResult } from '@shared/infra/antivirus'; import { Allow, IsBoolean, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; import { FileRecordParentType } from '../../entity'; import { PreviewOutputMimeTypes, PreviewWidth } from '../../interface'; @@ -51,7 +52,7 @@ export class DownloadFileParams { fileName!: string; } -export class ScanResultParams { +export class ScanResultParams implements ScanResult { @ApiProperty() @Allow() virus_detected?: boolean; diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts index 81fe5b34ba6..a87789d30fc 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -254,6 +254,12 @@ export class FileRecord extends BaseEntityWithTimestamps { return isVerified; } + public isPreviewPossible(): boolean { + const isPreviewPossible = Object.values(PreviewInputMimeTypes).includes(this.mimeType); + + return isPreviewPossible; + } + public getParentInfo(): IParentInfo { const { parentId, parentType, schoolId } = this; @@ -269,7 +275,7 @@ export class FileRecord extends BaseEntityWithTimestamps { return PreviewStatus.PREVIEW_NOT_POSSIBLE_SCAN_STATUS_BLOCKED; } - if (!Object.values(PreviewInputMimeTypes).includes(this.mimeType)) { + if (!this.isPreviewPossible()) { return PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE; } diff --git a/apps/server/src/modules/files-storage/files-storage.config.ts b/apps/server/src/modules/files-storage/files-storage.config.ts index f3532b157e8..7dc01e9f4e1 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -6,14 +6,16 @@ export const FILES_STORAGE_S3_CONNECTION = 'FILES_STORAGE_S3_CONNECTION'; export interface IFileStorageConfig extends ICoreModuleConfig { MAX_FILE_SIZE: number; MAX_SECURITY_CHECK_FILE_SIZE: number; + USE_STREAM_TO_ANTIVIRUS: boolean; } const fileStorageConfig: IFileStorageConfig = { INCOMING_REQUEST_TIMEOUT: Configuration.get('FILES_STORAGE__INCOMING_REQUEST_TIMEOUT') as number, INCOMING_REQUEST_TIMEOUT_COPY_API: Configuration.get('INCOMING_REQUEST_TIMEOUT_COPY_API') as number, MAX_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, - MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILE_SECURITY_CHECK_MAX_FILE_SIZE') as number, + MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, + USE_STREAM_TO_ANTIVIRUS: Configuration.get('FILES_STORAGE__USE_STREAM_TO_ANTIVIRUS') as boolean, }; // The configurations lookup diff --git a/apps/server/src/modules/files-storage/files-storage.module.ts b/apps/server/src/modules/files-storage/files-storage.module.ts index 29caa14e8c8..248654218ef 100644 --- a/apps/server/src/modules/files-storage/files-storage.module.ts +++ b/apps/server/src/modules/files-storage/files-storage.module.ts @@ -23,6 +23,8 @@ const imports = [ filesServiceBaseUrl: Configuration.get('FILES_STORAGE__SERVICE_BASE_URL') as string, exchange: Configuration.get('ANTIVIRUS_EXCHANGE') as string, routingKey: Configuration.get('ANTIVIRUS_ROUTING_KEY') as string, + hostname: Configuration.get('CLAMAV__SERVICE_HOSTNAME') as string, + port: Configuration.get('CLAMAV__SERVICE_PORT') as number, }), S3ClientModule.register([s3Config]), ]; diff --git a/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts index 3605ae74080..4ba05e540a8 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-copy.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts index 627f35d91da..cd76b564b31 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-delete.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts index a578bca51a1..bcee168c2b9 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-download.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { NotAcceptableException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { GetFile, S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts index 3565e5a328a..95f7c2d204c 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-get.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts index 1c3460c3926..c82f96074f1 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-restore.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts index 9da800baf90..8523c7388fd 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-update.service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts index 0ba3f729dae..022e6a4bf0d 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts @@ -3,14 +3,14 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { readableStreamWithFileTypeFactory } from '@shared/testing/factory/readable-stream-with-file-type.factory'; import { LegacyLogger } from '@src/core/logger'; import { MimeType } from 'file-type'; import FileType from 'file-type-cjs/file-type-cjs-index'; -import { Readable } from 'stream'; +import { PassThrough, Readable } from 'stream'; import { FileRecordParams } from '../controller/dto'; import { FileDto } from '../dto'; import { FileRecord, FileRecordParentType } from '../entity'; @@ -122,6 +122,12 @@ describe('FilesStorageService upload methods', () => { const readableStreamWithFileType = readableStreamWithFileTypeFactory.build(); + antivirusService.checkStream.mockResolvedValueOnce({ + virus_detected: undefined, + virus_signature: undefined, + error: undefined, + }); + return { params, file, @@ -170,7 +176,7 @@ describe('FilesStorageService upload methods', () => { await service.uploadFile(userId, params, file); - expect(getMimeTypeSpy).toHaveBeenCalledWith(file.data); + expect(getMimeTypeSpy).toHaveBeenCalledWith(expect.any(PassThrough)); }); it('should call getFileRecordsOfParent with correct params', async () => { @@ -199,14 +205,6 @@ describe('FilesStorageService upload methods', () => { ); }); - it('should call antivirusService.send with fileRecord', async () => { - const { params, file, userId } = setup(); - - const fileRecord = await service.uploadFile(userId, params, file); - - expect(antivirusService.send).toHaveBeenCalledWith(fileRecord.securityCheck.requestToken); - }); - it('should call storageClient.create with correct params', async () => { const { params, file, userId } = setup(); @@ -223,6 +221,29 @@ describe('FilesStorageService upload methods', () => { expect(result).toBeInstanceOf(FileRecord); }); + + describe('Antivirus handling by upload ', () => { + describe('when useStreamToAntivirus is true', () => { + it('should call antivirusService.send with fileRecord', async () => { + const { params, file, userId } = setup(); + configService.get.mockReturnValueOnce(true); + await service.uploadFile(userId, params, file); + + expect(antivirusService.checkStream).toHaveBeenCalledWith(file); + }); + }); + + describe('when useStreamToAntivirus is false', () => { + it('should call antivirusService.send with fileRecord', async () => { + const { params, file, userId } = setup(); + configService.get.mockReturnValueOnce(false); + + const fileRecord = await service.uploadFile(userId, params, file); + + expect(antivirusService.send).toHaveBeenCalledWith(fileRecord.securityCheck.requestToken); + }); + }); + }); }); describe('WHEN file record repo throws error', () => { @@ -294,6 +315,7 @@ describe('FilesStorageService upload methods', () => { } }); + configService.get.mockReturnValueOnce(true); configService.get.mockReturnValueOnce(2); const error = new BadRequestException(ErrorType.FILE_TOO_BIG); @@ -315,6 +337,9 @@ describe('FilesStorageService upload methods', () => { jest.spyOn(service, 'getFileRecordsOfParent').mockResolvedValue([[fileRecord], 1]); + // Mock for useStreamToAntivirus + configService.get.mockReturnValueOnce(false); + // Mock for max file size configService.get.mockReturnValueOnce(10); @@ -364,6 +389,8 @@ describe('FilesStorageService upload methods', () => { jest.spyOn(FileType, 'fileTypeStream').mockResolvedValueOnce(readableStreamWithFileType); + configService.get.mockReturnValueOnce(false); + // The fileRecord._id must be set by fileRecordRepo.save. Otherwise createPath fails. // eslint-disable-next-line @typescript-eslint/require-await fileRecordRepo.save.mockImplementation(async (fr) => { diff --git a/apps/server/src/modules/files-storage/service/files-storage.service.ts b/apps/server/src/modules/files-storage/service/files-storage.service.ts index 2b6bc4b179a..8c0c85630de 100644 --- a/apps/server/src/modules/files-storage/service/files-storage.service.ts +++ b/apps/server/src/modules/files-storage/service/files-storage.service.ts @@ -8,11 +8,11 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Counted, EntityId } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { LegacyLogger } from '@src/core/logger'; import FileType from 'file-type-cjs/file-type-cjs-index'; -import { Readable } from 'stream'; +import { PassThrough, Readable } from 'stream'; import { CopyFileResponse, CopyFilesOfParentParams, @@ -104,7 +104,8 @@ export class FilesStorageService { private async detectMimeType(file: FileDto): Promise<{ mimeType: string; stream: Readable }> { if (this.isStreamMimeTypeDetectionPossible(file.mimeType)) { - const { stream, mime: detectedMimeType } = await this.detectMimeTypeByStream(file.data); + const source = file.data.pipe(new PassThrough()); + const { stream, mime: detectedMimeType } = await this.detectMimeTypeByStream(source); const mimeType = detectedMimeType ?? file.mimeType; @@ -151,18 +152,32 @@ export class FilesStorageService { file: FileDto ): Promise { const filePath = createPath(params.schoolId, fileRecord.id); + const useStreamToAntivirus = this.configService.get('USE_STREAM_TO_ANTIVIRUS'); try { const fileSizePromise = this.countFileSize(file); - await this.storageClient.create(filePath, file); + if (useStreamToAntivirus && fileRecord.isPreviewPossible()) { + const streamToAntivirus = file.data.pipe(new PassThrough()); + + const [, antivirusClientResponse] = await Promise.all([ + this.storageClient.create(filePath, file), + this.antivirusService.checkStream(streamToAntivirus), + ]); + const { status, reason } = FileRecordMapper.mapScanResultParamsToDto(antivirusClientResponse); + fileRecord.updateSecurityCheckStatus(status, reason); + } else { + await this.storageClient.create(filePath, file); + } // The actual file size is set here because it is known only after the whole file is streamed. fileRecord.size = await fileSizePromise; this.throwErrorIfFileIsTooBig(fileRecord.size); await this.fileRecordRepo.save(fileRecord); - await this.sendToAntivirus(fileRecord); + if (!useStreamToAntivirus || !fileRecord.isPreviewPossible()) { + await this.sendToAntivirus(fileRecord); + } } catch (error) { await this.storageClient.delete([filePath]); await this.fileRecordRepo.delete(fileRecord); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts index 21592585ce4..5d4ab900549 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts index 9a870a7f4e1..eb13f830be6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Counted, EntityId } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts index 2d62feaa08e..3a2f6f1ac21 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts index ee3eb1ecef3..b1aa6d4b437 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts index 610f54d8d3b..02cdb82ded6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts index 05961b33339..be8a6d32561 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts index 75621cd82da..57ec96cff61 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts index 78348078565..903a2f2a6a6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; -import { AntivirusService } from '@shared/infra/antivirus/antivirus.service'; +import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { AxiosHeadersKeyValue, axiosResponseFactory, fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts b/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts index 594e283861d..ea7e85b109f 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.module.spec.ts @@ -11,6 +11,8 @@ describe('AntivirusModule', () => { filesServiceBaseUrl: 'http://localhost', exchange: 'exchange', routingKey: 'routingKey', + hostname: 'localhost', + port: 3311, }; beforeAll(async () => { diff --git a/apps/server/src/shared/infra/antivirus/antivirus.module.ts b/apps/server/src/shared/infra/antivirus/antivirus.module.ts index c9078925532..197e263b97d 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.module.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.module.ts @@ -1,12 +1,7 @@ -import { Module, DynamicModule } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; +import NodeClam from 'clamscan'; import { AntivirusService } from './antivirus.service'; - -interface AntivirusModuleOptions { - enabled: boolean; - filesServiceBaseUrl: string; - exchange: string; - routingKey: string; -} +import { AntivirusModuleOptions } from './interfaces'; @Module({}) export class AntivirusModule { @@ -24,7 +19,24 @@ export class AntivirusModule { routingKey: options.routingKey, }, }, + { + provide: NodeClam, + useFactory: () => { + const isLocalhost = options.hostname === 'localhost'; + + return new NodeClam().init({ + debugMode: isLocalhost, + clamdscan: { + host: options.hostname, + port: options.port, + bypassTest: isLocalhost, + localFallback: false, + }, + }); + }, + }, ], + exports: [AntivirusService], }; } diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts b/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts index 6a02415a82b..247b04c48a0 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.service.spec.ts @@ -2,6 +2,8 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import NodeClam from 'clamscan'; +import { Readable } from 'stream'; import { v4 as uuid } from 'uuid'; import { AntivirusService } from './antivirus.service'; @@ -9,6 +11,7 @@ describe('AntivirusService', () => { let module: TestingModule; let service: AntivirusService; let amqpConnection: DeepMocked; + let clamavConnection: DeepMocked; const antivirusServiceOptions = { enabled: true, @@ -22,12 +25,14 @@ describe('AntivirusService', () => { providers: [ AntivirusService, { provide: AmqpConnection, useValue: createMock() }, + { provide: NodeClam, useValue: createMock() }, { provide: 'ANTIVIRUS_SERVICE_OPTIONS', useValue: antivirusServiceOptions }, ], }).compile(); service = module.get(AntivirusService); amqpConnection = module.get(AmqpConnection); + clamavConnection = module.get(NodeClam); }); afterAll(async () => { @@ -82,4 +87,104 @@ describe('AntivirusService', () => { }); }); }); + + describe('checkStream', () => { + describe('when service can handle passing parameters', () => { + const setup = () => { + const readable = Readable.from('abc'); + + return { readable }; + }; + + it('should call scanStream', async () => { + const { readable } = setup(); + + await service.checkStream(readable); + + expect(clamavConnection.scanStream).toHaveBeenCalledWith(readable); + }); + }); + + describe('when file infected', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockResolvedValueOnce({ isInfected: true, viruses: ['test'] }); + + const expectedResult = { + virus_detected: true, + virus_signature: 'test', + }; + return { readable, expectedResult }; + }; + + it('should return scan result', async () => { + const { readable, expectedResult } = setup(); + + const result = await service.checkStream(readable); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when file not scanned', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockResolvedValueOnce({ isInfected: null }); + + const expectedResult = { + virus_detected: undefined, + virus_signature: undefined, + error: '', + }; + return { readable, expectedResult }; + }; + + it('should return scan result', async () => { + const { readable, expectedResult } = setup(); + + const result = await service.checkStream(readable); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when file is good', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockResolvedValueOnce({ isInfected: false }); + + const expectedResult = { + virus_detected: false, + }; + return { readable, expectedResult }; + }; + + it('should return scan result', async () => { + const { readable, expectedResult } = setup(); + + const result = await service.checkStream(readable); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when clamavConnection.scanStream throw an error', () => { + const setup = () => { + const readable = Readable.from('abc'); + // @ts-expect-error unknown types + clamavConnection.scanStream.mockRejectedValueOnce(new Error('fail')); + + return { readable }; + }; + + it('should throw with InternalServerErrorException by error', async () => { + const { readable } = setup(); + + await expect(() => service.checkStream(readable)).rejects.toThrowError(InternalServerErrorException); + }); + }); + }); }); diff --git a/apps/server/src/shared/infra/antivirus/antivirus.service.ts b/apps/server/src/shared/infra/antivirus/antivirus.service.ts index c3c38494853..1f49b9907fb 100644 --- a/apps/server/src/shared/infra/antivirus/antivirus.service.ts +++ b/apps/server/src/shared/infra/antivirus/antivirus.service.ts @@ -2,21 +2,45 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ErrorUtils } from '@src/core/error/utils'; import { API_VERSION_PATH, FilesStorageInternalActions } from '@src/modules/files-storage/files-storage.const'; - -interface AntivirusServiceOptions { - enabled: boolean; - filesServiceBaseUrl: string; - exchange: string; - routingKey: string; -} +import NodeClam from 'clamscan'; +import { Readable } from 'stream'; +import { AntivirusServiceOptions, ScanResult } from './interfaces'; @Injectable() export class AntivirusService { constructor( private readonly amqpConnection: AmqpConnection, - @Inject('ANTIVIRUS_SERVICE_OPTIONS') private readonly options: AntivirusServiceOptions + @Inject('ANTIVIRUS_SERVICE_OPTIONS') private readonly options: AntivirusServiceOptions, + private readonly clamConnection: NodeClam ) {} + public async checkStream(stream: Readable) { + const scanResult: ScanResult = { + virus_detected: undefined, + virus_signature: undefined, + error: undefined, + }; + try { + const { isInfected, viruses } = await this.clamConnection.scanStream(stream); + if (isInfected === true) { + scanResult.virus_detected = true; + scanResult.virus_signature = viruses.join(','); + } else if (isInfected === null) { + scanResult.virus_detected = undefined; + scanResult.error = ''; + } else { + scanResult.virus_detected = false; + } + } catch (err) { + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(err, 'AntivirusService:checkStream') + ); + } + + return scanResult; + } + public async send(requestToken: string | undefined): Promise { try { if (this.options.enabled && requestToken) { diff --git a/apps/server/src/shared/infra/antivirus/index.ts b/apps/server/src/shared/infra/antivirus/index.ts new file mode 100644 index 00000000000..833c46d81a7 --- /dev/null +++ b/apps/server/src/shared/infra/antivirus/index.ts @@ -0,0 +1,3 @@ +export * from './interfaces'; +export * from './antivirus.module'; +export * from './antivirus.service'; diff --git a/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts b/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts new file mode 100644 index 00000000000..0d648afacce --- /dev/null +++ b/apps/server/src/shared/infra/antivirus/interfaces/antivirus.ts @@ -0,0 +1,21 @@ +export interface AntivirusModuleOptions { + enabled: boolean; + filesServiceBaseUrl: string; + exchange: string; + routingKey: string; + hostname: string; + port: number; +} + +export interface AntivirusServiceOptions { + enabled: boolean; + filesServiceBaseUrl: string; + exchange: string; + routingKey: string; +} + +export interface ScanResult { + virus_detected?: boolean; + virus_signature?: string; + error?: string; +} diff --git a/apps/server/src/shared/infra/antivirus/interfaces/index.ts b/apps/server/src/shared/infra/antivirus/interfaces/index.ts new file mode 100644 index 00000000000..6c4771f9cd5 --- /dev/null +++ b/apps/server/src/shared/infra/antivirus/interfaces/index.ts @@ -0,0 +1 @@ +export * from './antivirus'; diff --git a/config/default.schema.json b/config/default.schema.json index e6084e5c331..5aba0e9aad8 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -259,6 +259,11 @@ "type": "string", "default": "files-storage", "description": "rabbitmq exchange name for antivirus" + }, + "USE_STREAM_TO_ANTIVIRUS": { + "type": "boolean", + "default": false, + "description": "send file to antivirus by uploading" } } }, @@ -454,6 +459,25 @@ } } }, + "CLAMAV": { + "type": "object", + "description": "Properties of the ClamAV server", + "required": [], + "properties": { + "SERVICE_HOSTNAME": { + "type": "string", + "description": "IP of host to connect to TCP interface" + }, + "SERVICE_PORT": { + "type": "number", + "description": "Port of host to use when connecting via TCP interface" + } + }, + "default": { + "SERVICE_HOSTNAME": "localhost", + "SERVICE_PORT": 3310 + } + }, "RABBITMQ_URI": { "type": "string", "format": "uri", diff --git a/package-lock.json b/package-lock.json index dde18164a6f..e36ecdd9873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "cache-manager": "^2.9.0", "cache-manager-redis-store": "^2.0.0", "chalk": "^5.0.0", + "clamscan": "^2.1.2", "class-transformer": "^0.4.0", "class-validator": "^0.14.0", "client-oauth2": "^4.2.5", @@ -143,6 +144,7 @@ "@types/amqplib": "^0.8.2", "@types/bcryptjs": "^2.4.2", "@types/busboy": "^1.5.0", + "@types/clamscan": "^2.0.5", "@types/cookie": "^0.4.1", "@types/crypto-js": "^4.0.2", "@types/express": "^4.17.11", @@ -5460,6 +5462,25 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", "dev": true }, + "node_modules/@types/clamscan": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.5.tgz", + "integrity": "sha512-bFqdscswqBia3yKEJZVVWELOVvWKHUR1dCmH4xshYwu0T9YSfZd35Q8Z9jYW0ygxqGlHjLXMb2/7C6CJITbDgg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "axios": "^0.24.0" + } + }, + "node_modules/@types/clamscan/node_modules/axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -8122,6 +8143,14 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "node_modules/clamscan": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.1.2.tgz", + "integrity": "sha512-pcovgLHcrg3l/mI51Kuk0kN++07pSZdBTskISw0UFvsm8UXda8oNCm0eLeODxFg85Mz+k+TtSS9+XPlriJ8/Fg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/class-transformer": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", @@ -28782,6 +28811,27 @@ "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", "dev": true }, + "@types/clamscan": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.5.tgz", + "integrity": "sha512-bFqdscswqBia3yKEJZVVWELOVvWKHUR1dCmH4xshYwu0T9YSfZd35Q8Z9jYW0ygxqGlHjLXMb2/7C6CJITbDgg==", + "dev": true, + "requires": { + "@types/node": "*", + "axios": "^0.24.0" + }, + "dependencies": { + "axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "dev": true, + "requires": { + "follow-redirects": "^1.14.4" + } + } + } + }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -30913,6 +30963,11 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "clamscan": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.1.2.tgz", + "integrity": "sha512-pcovgLHcrg3l/mI51Kuk0kN++07pSZdBTskISw0UFvsm8UXda8oNCm0eLeODxFg85Mz+k+TtSS9+XPlriJ8/Fg==" + }, "class-transformer": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", diff --git a/package.json b/package.json index 97cb84c069e..3c5a73df7d1 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "cache-manager": "^2.9.0", "cache-manager-redis-store": "^2.0.0", "chalk": "^5.0.0", + "clamscan": "^2.1.2", "class-transformer": "^0.4.0", "class-validator": "^0.14.0", "client-oauth2": "^4.2.5", @@ -225,6 +226,7 @@ "@types/amqplib": "^0.8.2", "@types/bcryptjs": "^2.4.2", "@types/busboy": "^1.5.0", + "@types/clamscan": "^2.0.5", "@types/cookie": "^0.4.1", "@types/crypto-js": "^4.0.2", "@types/express": "^4.17.11", From e3afbd415d806f8d3ed5c2f46cd338735ad54a33 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Fri, 13 Oct 2023 14:34:50 +0200 Subject: [PATCH 26/34] N21-1273 calling oauth2 logoutEndpoint (#4467) * adds externalIdToken to ICurrentUser to be able to trigger rp initiated logout, after successful login * adds migration script to remove logout endpoint for sanis * removes logoutUrl changes init script of moin.schule --- .../templates/configmap_file_init.yml.j2 | 1 - .../controllers/api-test/login.api.spec.ts | 35 +++++- .../authentication/controllers/dto/index.ts | 1 + .../controllers/dto/oauth-login.response.ts | 15 +++ .../controllers/login.controller.ts | 13 ++- .../mapper/login-response.mapper.ts | 17 ++- .../modules/authentication/interface/user.ts | 5 + .../mapper/current-user.mapper.spec.ts | 108 +++++++++++++++--- .../mapper/current-user.mapper.ts | 23 +++- .../strategy/oauth2.strategy.spec.ts | 12 +- .../strategy/oauth2.strategy.ts | 9 +- .../authentication/uc/login.uc.spec.ts | 20 +++- .../src/modules/authentication/uc/login.uc.ts | 10 +- .../oauth/controller/oauth-sso.controller.ts | 2 +- .../oauth/{error => loggable}/index.ts | 0 .../oauth-sso.error.spec.ts | 0 .../{error => loggable}/oauth-sso.error.ts | 0 .../sso-error-code.enum.ts | 0 ...er-provisioning.loggable-exception.spec.ts | 0 ...d-after-provisioning.loggable-exception.ts | 0 .../service/oauth-adapter.service.spec.ts | 7 +- .../oauth/service/oauth-adapter.service.ts | 7 +- .../oauth/service/oauth.service.spec.ts | 2 +- .../modules/oauth/service/oauth.service.ts | 4 +- .../modules/oauth/uc/hydra-oauth.uc.spec.ts | 2 +- .../src/modules/oauth/uc/hydra-oauth.uc.ts | 2 +- .../src/modules/oauth/uc/oauth.uc.spec.ts | 2 +- .../strategy/iserv/iserv.strategy.spec.ts | 2 +- .../strategy/iserv/iserv.strategy.ts | 2 +- .../oidc-mock/oidc-mock.strategy.spec.ts | 2 +- .../strategy/oidc-mock/oidc-mock.strategy.ts | 2 +- .../controller/dto/oauth-config.response.ts | 6 +- .../system/service/dto/oauth-config.dto.ts | 5 +- .../error/oauth-migration.error.ts | 2 +- .../src/shared/domain/entity/system.entity.ts | 4 +- backup/setup/migrations.json | 11 ++ ...8782-remove-moin-schule-logout-endpoint.js | 87 ++++++++++++++ 37 files changed, 345 insertions(+), 75 deletions(-) create mode 100644 apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts rename apps/server/src/modules/oauth/{error => loggable}/index.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/oauth-sso.error.spec.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/oauth-sso.error.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/sso-error-code.enum.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/user-not-found-after-provisioning.loggable-exception.spec.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/user-not-found-after-provisioning.loggable-exception.ts (100%) create mode 100644 migrations/1697020818782-remove-moin-schule-logout-endpoint.js diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 6b17e522753..82ff81afb8b 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -177,7 +177,6 @@ data: "redirectUri": "https://{{ NAMESPACE }}.cd.dbildungscloud.dev/api/v3/sso/oauth", "authEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/auth", "provider": "sanis", - "logoutEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/logout", "jwksEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/certs", "issuer": "https://auth.stage.niedersachsen-login.schule/realms/SANIS" } diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 80fc43734bc..8d8ea8b305d 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -3,7 +3,7 @@ import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, RoleName, SchoolEntity, SystemEntity, User } from '@shared/domain'; import { accountFactory, roleFactory, schoolFactory, systemFactory, userFactory } from '@shared/testing'; -import { SSOErrorCode } from '@src/modules/oauth/error/sso-error-code.enum'; +import { SSOErrorCode } from '@src/modules/oauth/loggable'; import { OauthTokenResponse } from '@src/modules/oauth/service/dto'; import { ServerTestModule } from '@src/modules/server/server.module'; import axios from 'axios'; @@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; import jwt from 'jsonwebtoken'; import request, { Response } from 'supertest'; -import { LdapAuthorizationBodyParams, LocalAuthorizationBodyParams } from '../dto'; +import { LdapAuthorizationBodyParams, LocalAuthorizationBodyParams, OauthLoginResponse } from '../dto'; const ldapAccountUserName = 'ldapAccountUserName'; const mockUserLdapDN = 'mockUserLdapDN'; @@ -129,6 +129,7 @@ describe('Login Controller (api)', () => { expect(decodedToken).toHaveProperty('accountId'); expect(decodedToken).toHaveProperty('schoolId'); expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); @@ -193,6 +194,7 @@ describe('Login Controller (api)', () => { expect(decodedToken).toHaveProperty('accountId'); expect(decodedToken).toHaveProperty('schoolId'); expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); @@ -253,10 +255,30 @@ describe('Login Controller (api)', () => { return { system, + idToken, }; }; - it('should return jwt', async () => { + it('should return oauth login response', async () => { + const { system, idToken } = await setup(); + + const response: Response = await request(app.getHttpServer()) + .post(`${basePath}/oauth2`) + .send({ + redirectUri: 'redirectUri', + code: 'code', + systemId: system.id, + }) + .expect(HttpStatus.OK); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(response.body).toEqual({ + accessToken: expect.any(String), + externalIdToken: idToken, + }); + }); + + it('should return a valid jwt as access token', async () => { const { system } = await setup(); const response: Response = await request(app.getHttpServer()) @@ -268,8 +290,15 @@ describe('Login Controller (api)', () => { }) .expect(HttpStatus.OK); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument + const decodedToken = jwt.decode(response.body.accessToken); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(response.body.accessToken).toBeDefined(); + expect(decodedToken).toHaveProperty('userId'); + expect(decodedToken).toHaveProperty('accountId'); + expect(decodedToken).toHaveProperty('schoolId'); + expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); diff --git a/apps/server/src/modules/authentication/controllers/dto/index.ts b/apps/server/src/modules/authentication/controllers/dto/index.ts index 513041f604a..69c69055f74 100644 --- a/apps/server/src/modules/authentication/controllers/dto/index.ts +++ b/apps/server/src/modules/authentication/controllers/dto/index.ts @@ -2,3 +2,4 @@ export * from './oauth2-authorization.body.params'; export * from './login.response'; export * from './ldap-authorization.body.params'; export * from './local-authorization.body.params'; +export * from './oauth-login.response'; diff --git a/apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts b/apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts new file mode 100644 index 00000000000..53bb3cc6b38 --- /dev/null +++ b/apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { LoginResponse } from './login.response'; + +export class OauthLoginResponse extends LoginResponse { + @ApiPropertyOptional({ + description: + 'The external id token which is from the external oauth system and set when scope openid is available.', + }) + externalIdToken?: string; + + constructor(props: OauthLoginResponse) { + super(props); + this.externalIdToken = props.externalIdToken; + } +} diff --git a/apps/server/src/modules/authentication/controllers/login.controller.ts b/apps/server/src/modules/authentication/controllers/login.controller.ts index d6d1d1f0b40..4db3f825acf 100644 --- a/apps/server/src/modules/authentication/controllers/login.controller.ts +++ b/apps/server/src/modules/authentication/controllers/login.controller.ts @@ -3,7 +3,7 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ForbiddenOperationError, ValidationError } from '@shared/common'; import { CurrentUser } from '../decorator/auth.decorator'; -import type { ICurrentUser } from '../interface'; +import type { ICurrentUser, OauthCurrentUser } from '../interface'; import { LoginDto } from '../uc/dto'; import { LoginUc } from '../uc/login.uc'; import { @@ -11,6 +11,7 @@ import { LocalAuthorizationBodyParams, LoginResponse, Oauth2AuthorizationBodyParams, + OauthLoginResponse, } from './dto'; import { LoginResponseMapper } from './mapper/login-response.mapper'; @@ -30,7 +31,7 @@ export class LoginController { async loginLdap(@CurrentUser() user: ICurrentUser, @Body() _: LdapAuthorizationBodyParams): Promise { const loginDto: LoginDto = await this.loginUc.getLoginData(user); - const mapped: LoginResponse = LoginResponseMapper.mapLoginDtoToResponse(loginDto); + const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); return mapped; } @@ -46,7 +47,7 @@ export class LoginController { async loginLocal(@CurrentUser() user: ICurrentUser, @Body() _: LocalAuthorizationBodyParams): Promise { const loginDto: LoginDto = await this.loginUc.getLoginData(user); - const mapped: LoginResponse = LoginResponseMapper.mapLoginDtoToResponse(loginDto); + const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); return mapped; } @@ -59,13 +60,13 @@ export class LoginController { @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Invalid user credentials.' }) async loginOauth2( - @CurrentUser() user: ICurrentUser, + @CurrentUser() user: OauthCurrentUser, // eslint-disable-next-line @typescript-eslint/no-unused-vars @Body() _: Oauth2AuthorizationBodyParams - ): Promise { + ): Promise { const loginDto: LoginDto = await this.loginUc.getLoginData(user); - const mapped: LoginResponse = LoginResponseMapper.mapLoginDtoToResponse(loginDto); + const mapped: OauthLoginResponse = LoginResponseMapper.mapToOauthLoginResponse(loginDto, user.externalIdToken); return mapped; } diff --git a/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts b/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts index aeffbfe8960..9ff67a39095 100644 --- a/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts +++ b/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts @@ -1,9 +1,20 @@ -import { LoginResponse } from '../dto'; import { LoginDto } from '../../uc/dto'; +import { LoginResponse, OauthLoginResponse } from '../dto'; export class LoginResponseMapper { - static mapLoginDtoToResponse(loginDto: LoginDto): LoginResponse { - const response: LoginResponse = new LoginResponse({ accessToken: loginDto.accessToken }); + static mapToLoginResponse(loginDto: LoginDto): LoginResponse { + const response: LoginResponse = new LoginResponse({ + accessToken: loginDto.accessToken, + }); + + return response; + } + + static mapToOauthLoginResponse(loginDto: LoginDto, externalIdToken?: string): OauthLoginResponse { + const response: OauthLoginResponse = new OauthLoginResponse({ + accessToken: loginDto.accessToken, + externalIdToken, + }); return response; } diff --git a/apps/server/src/modules/authentication/interface/user.ts b/apps/server/src/modules/authentication/interface/user.ts index 1baa64d192b..e2874d1a8e0 100644 --- a/apps/server/src/modules/authentication/interface/user.ts +++ b/apps/server/src/modules/authentication/interface/user.ts @@ -40,3 +40,8 @@ export interface ICurrentUser { /** True if a support member impersonates the user */ impersonated?: boolean; } + +export interface OauthCurrentUser extends ICurrentUser { + /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ + externalIdToken?: string; +} diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index c89d1e3a037..09a76c1ebfb 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -2,8 +2,8 @@ import { ValidationError } from '@shared/common'; import { Permission, RoleName } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { roleFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { ICurrentUser } from '../interface'; -import { JwtPayload } from '../interface/jwt-payload'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; import { CurrentUserMapper } from './current-user.mapper'; describe('CurrentUserMapper', () => { @@ -56,40 +56,89 @@ describe('CurrentUserMapper', () => { }); }); - describe('userDoToICurrentUser', () => { - const userId = 'mockUserId'; + describe('OauthCurrentUser', () => { + const userIdMock = 'mockUserId'; describe('when userDO has no ID', () => { it('should throw error', () => { const user: UserDO = userDoFactory.build({ createdAt: new Date(), updatedAt: new Date() }); - expect(() => CurrentUserMapper.userDoToICurrentUser(accountId, user)).toThrow(ValidationError); + expect(() => CurrentUserMapper.mapToOauthCurrentUser(accountId, user, undefined, 'idToken')).toThrow( + ValidationError + ); }); }); describe('when userDO is valid', () => { - it('should return valid ICurrentUser instance', () => { - const user: UserDO = userDoFactory.buildWithId({ id: userId, createdAt: new Date(), updatedAt: new Date() }); - const currentUser = CurrentUserMapper.userDoToICurrentUser(accountId, user); - expect(currentUser).toMatchObject({ + const setup = () => { + const user: UserDO = userDoFactory.buildWithId({ + id: userIdMock, + createdAt: new Date(), + updatedAt: new Date(), + }); + const idToken = 'idToken'; + + return { + user, + userId: user.id as string, + idToken, + }; + }; + + it('should return valid oauth current user instance', () => { + const { user, userId, idToken } = setup(); + + const currentUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + accountId, + user, + undefined, + idToken + ); + + expect(currentUser).toMatchObject({ accountId, systemId: undefined, roles: [], schoolId: user.schoolId, - userId: user.id, + userId, + externalIdToken: idToken, }); }); }); describe('when userDO is valid and a systemId is provided', () => { - it('should return valid ICurrentUser instance with systemId', () => { - const user: UserDO = userDoFactory.buildWithId({ id: userId, createdAt: new Date(), updatedAt: new Date() }); + const setup = () => { + const user: UserDO = userDoFactory.buildWithId({ + id: userIdMock, + createdAt: new Date(), + updatedAt: new Date(), + }); const systemId = 'mockSystemId'; - const currentUser = CurrentUserMapper.userDoToICurrentUser(accountId, user, systemId); - expect(currentUser).toMatchObject({ + const idToken = 'idToken'; + + return { + user, + userId: user.id as string, + idToken, + systemId, + }; + }; + + it('should return valid ICurrentUser instance with systemId', () => { + const { user, userId, systemId, idToken } = setup(); + + const currentUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + accountId, + user, + systemId, + idToken + ); + + expect(currentUser).toMatchObject({ accountId, systemId, roles: [], schoolId: user.schoolId, - userId: user.id, + userId, + externalIdToken: idToken, }); }); }); @@ -104,7 +153,7 @@ describe('CurrentUserMapper', () => { }, ]) .buildWithId({ - id: userId, + id: userIdMock, createdAt: new Date(), updatedAt: new Date(), }); @@ -117,7 +166,7 @@ describe('CurrentUserMapper', () => { it('should return valid ICurrentUser instance without systemId', () => { const { user } = setup(); - const currentUser = CurrentUserMapper.userDoToICurrentUser(accountId, user); + const currentUser = CurrentUserMapper.mapToOauthCurrentUser(accountId, user, undefined, 'idToken'); expect(currentUser).toMatchObject({ accountId, @@ -158,6 +207,7 @@ describe('CurrentUserMapper', () => { }); }); }); + describe('when JWT is provided without optional claims', () => { it('should return current user', () => { const jwtPayload: JwtPayload = { @@ -182,4 +232,28 @@ describe('CurrentUserMapper', () => { }); }); }); + + describe('mapCurrentUserToCreateJwtPayload', () => { + it('should map current user to create jwt payload', () => { + const currentUser: ICurrentUser = { + accountId: 'dummyAccountId', + systemId: 'dummySystemId', + roles: ['mockRoleId'], + schoolId: 'dummySchoolId', + userId: 'dummyUserId', + impersonated: true, + }; + + const createJwtPayload: CreateJwtPayload = CurrentUserMapper.mapCurrentUserToCreateJwtPayload(currentUser); + + expect(createJwtPayload).toMatchObject({ + accountId: currentUser.accountId, + systemId: currentUser.systemId, + roles: currentUser.roles, + schoolId: currentUser.schoolId, + userId: currentUser.userId, + support: currentUser.impersonated, + }); + }); + }); }); diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts index d4eb31fbede..80ca91b56b0 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts @@ -2,8 +2,8 @@ import { ValidationError } from '@shared/common'; import { Role, User } from '@shared/domain'; import { RoleReference } from '@shared/domain/domainobject'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { ICurrentUser } from '../interface'; -import { JwtPayload } from '../interface/jwt-payload'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; export class CurrentUserMapper { static userToICurrentUser(accountId: string, user: User, systemId?: string): ICurrentUser { @@ -16,7 +16,12 @@ export class CurrentUserMapper { }; } - static userDoToICurrentUser(accountId: string, user: UserDO, systemId?: string): ICurrentUser { + static mapToOauthCurrentUser( + accountId: string, + user: UserDO, + systemId?: string, + externalIdToken?: string + ): OauthCurrentUser { if (!user.id) { throw new ValidationError('user has no ID'); } @@ -27,6 +32,18 @@ export class CurrentUserMapper { roles: user.roles.map((roleRef: RoleReference) => roleRef.id), schoolId: user.schoolId, userId: user.id, + externalIdToken, + }; + } + + static mapCurrentUserToCreateJwtPayload(currentUser: ICurrentUser): CreateJwtPayload { + return { + accountId: currentUser.accountId, + userId: currentUser.userId, + schoolId: currentUser.schoolId, + roles: currentUser.roles, + systemId: currentUser.systemId, + support: currentUser.impersonated, }; } diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index 01feb93b044..722ba125574 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -9,7 +9,7 @@ import { AccountDto } from '@src/modules/account/services/dto'; import { OAuthTokenDto } from '@src/modules/oauth'; import { OAuthService } from '@src/modules/oauth/service/oauth.service'; import { SchoolInMigrationError } from '../errors/school-in-migration.error'; -import { ICurrentUser } from '../interface'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; import { Oauth2Strategy } from './oauth2.strategy'; describe('Oauth2Strategy', () => { @@ -60,9 +60,10 @@ describe('Oauth2Strategy', () => { username: 'username', }); + const idToken = 'idToken'; oauthService.authenticateUser.mockResolvedValue( new OAuthTokenDto({ - idToken: 'idToken', + idToken, accessToken: 'accessToken', refreshToken: 'refreshToken', }) @@ -70,22 +71,23 @@ describe('Oauth2Strategy', () => { oauthService.provisionUser.mockResolvedValue({ user, redirect: '' }); accountService.findByUserId.mockResolvedValue(account); - return { systemId, user, account }; + return { systemId, user, account, idToken }; }; it('should return the ICurrentUser', async () => { - const { systemId, user, account } = setup(); + const { systemId, user, account, idToken } = setup(); const result: ICurrentUser = await strategy.validate({ body: { code: 'code', redirectUri: 'redirectUri', systemId }, }); - expect(result).toEqual({ + expect(result).toEqual({ systemId, userId: user.id as EntityId, roles: [user.roles[0].id], schoolId: user.schoolId, accountId: account.id, + externalIdToken: idToken, }); }); }); diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index 2d774594a3d..6d532538e68 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -8,7 +8,7 @@ import { OAuthService } from '@src/modules/oauth/service/oauth.service'; import { Strategy } from 'passport-custom'; import { Oauth2AuthorizationBodyParams } from '../controllers/dto'; import { SchoolInMigrationError } from '../errors/school-in-migration.error'; -import { ICurrentUser } from '../interface'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; import { CurrentUserMapper } from '../mapper'; @Injectable() @@ -37,7 +37,12 @@ export class Oauth2Strategy extends PassportStrategy(Strategy, 'oauth2') { throw new UnauthorizedException('no account found'); } - const currentUser: ICurrentUser = CurrentUserMapper.userDoToICurrentUser(account.id, user, systemId); + const currentUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + account.id, + user, + systemId, + tokenDto.idToken + ); return currentUser; } diff --git a/apps/server/src/modules/authentication/uc/login.uc.spec.ts b/apps/server/src/modules/authentication/uc/login.uc.spec.ts index 2a6b3ab12b9..a14b741ae88 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.spec.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.spec.ts @@ -1,6 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { CreateJwtPayload } from '../interface/jwt-payload'; import { AuthenticationService } from '../services/authentication.service'; import { LoginDto } from './dto'; import { LoginUc } from './login.uc'; @@ -29,7 +28,15 @@ describe('LoginUc', () => { describe('getLoginData', () => { describe('when userInfo is given', () => { const setup = () => { - const userInfo: CreateJwtPayload = { accountId: '', roles: [], schoolId: '', userId: '' }; + const userInfo = { + accountId: '', + roles: [], + schoolId: '', + userId: '', + systemId: '', + impersonated: false, + someProperty: 'shouldNotBeMapped', + }; const loginDto: LoginDto = new LoginDto({ accessToken: 'accessToken' }); authenticationService.generateJwt.mockResolvedValue(loginDto); @@ -44,7 +51,14 @@ describe('LoginUc', () => { await loginUc.getLoginData(userInfo); - expect(authenticationService.generateJwt).toHaveBeenCalledWith(userInfo); + expect(authenticationService.generateJwt).toHaveBeenCalledWith({ + accountId: userInfo.accountId, + userId: userInfo.userId, + schoolId: userInfo.schoolId, + roles: userInfo.roles, + systemId: userInfo.systemId, + support: userInfo.impersonated, + }); }); it('should return a loginDto', async () => { diff --git a/apps/server/src/modules/authentication/uc/login.uc.ts b/apps/server/src/modules/authentication/uc/login.uc.ts index f6ce6fbdd16..2a6404b0a87 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.ts @@ -1,14 +1,18 @@ import { Injectable } from '@nestjs/common'; -import { AuthenticationService } from '../services/authentication.service'; +import { ICurrentUser } from '../interface'; import { CreateJwtPayload } from '../interface/jwt-payload'; +import { CurrentUserMapper } from '../mapper'; +import { AuthenticationService } from '../services/authentication.service'; import { LoginDto } from './dto'; @Injectable() export class LoginUc { constructor(private readonly authService: AuthenticationService) {} - async getLoginData(userInfo: CreateJwtPayload): Promise { - const accessTokenDto: LoginDto = await this.authService.generateJwt(userInfo); + async getLoginData(userInfo: ICurrentUser): Promise { + const createJwtPayload: CreateJwtPayload = CurrentUserMapper.mapCurrentUserToCreateJwtPayload(userInfo); + + const accessTokenDto: LoginDto = await this.authService.generateJwt(createJwtPayload); const loginDto: LoginDto = new LoginDto({ accessToken: accessTokenDto.accessToken, diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts index 934f7c26dc6..3c30984cc7a 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -21,7 +21,7 @@ import { HydraOauthUc } from '@src/modules/oauth/uc/hydra-oauth.uc'; import { OAuthMigrationError } from '@src/modules/user-login-migration/error/oauth-migration.error'; import { MigrationDto } from '@src/modules/user-login-migration/service/dto'; import { CookieOptions, Request, Response } from 'express'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; import { OauthLoginStateMapper } from '../mapper/oauth-login-state.mapper'; import { UserMigrationMapper } from '../mapper/user-migration.mapper'; diff --git a/apps/server/src/modules/oauth/error/index.ts b/apps/server/src/modules/oauth/loggable/index.ts similarity index 100% rename from apps/server/src/modules/oauth/error/index.ts rename to apps/server/src/modules/oauth/loggable/index.ts diff --git a/apps/server/src/modules/oauth/error/oauth-sso.error.spec.ts b/apps/server/src/modules/oauth/loggable/oauth-sso.error.spec.ts similarity index 100% rename from apps/server/src/modules/oauth/error/oauth-sso.error.spec.ts rename to apps/server/src/modules/oauth/loggable/oauth-sso.error.spec.ts diff --git a/apps/server/src/modules/oauth/error/oauth-sso.error.ts b/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts similarity index 100% rename from apps/server/src/modules/oauth/error/oauth-sso.error.ts rename to apps/server/src/modules/oauth/loggable/oauth-sso.error.ts diff --git a/apps/server/src/modules/oauth/error/sso-error-code.enum.ts b/apps/server/src/modules/oauth/loggable/sso-error-code.enum.ts similarity index 100% rename from apps/server/src/modules/oauth/error/sso-error-code.enum.ts rename to apps/server/src/modules/oauth/loggable/sso-error-code.enum.ts diff --git a/apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts similarity index 100% rename from apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.spec.ts rename to apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts diff --git a/apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.ts b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts similarity index 100% rename from apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.ts rename to apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts index 897ca989c04..12c0a381d8b 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts @@ -2,10 +2,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; import { of, throwError } from 'rxjs'; -import { OAuthSSOError } from '../error/oauth-sso.error'; import { OAuthGrantType } from '../interface/oauth-grant-type.enum'; +import { OAuthSSOError } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; @@ -44,10 +43,6 @@ describe('OauthAdapterServive', () => { provide: HttpService, useValue: createMock(), }, - { - provide: LegacyLogger, - useValue: createMock(), - }, ], }).compile(); service = module.get(OauthAdapterService); diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts index f48982e051e..6b008b610cf 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts @@ -1,18 +1,15 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common/decorators'; -import { LegacyLogger } from '@src/core/logger'; import { AxiosResponse } from 'axios'; import JwksRsa from 'jwks-rsa'; import QueryString from 'qs'; import { lastValueFrom, Observable } from 'rxjs'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @Injectable() export class OauthAdapterService { - constructor(private readonly httpService: HttpService, private readonly logger: LegacyLogger) { - this.logger.setContext(OauthAdapterService.name); - } + constructor(private readonly httpService: HttpService) {} async getPublicKey(jwksUri: string): Promise { const client: JwksRsa.JwksClient = JwksRsa({ diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index 5394070d273..81bb7724dbd 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -16,7 +16,7 @@ import { SystemService } from '@src/modules/system/service/system.service'; import { UserService } from '@src/modules/user'; import { MigrationCheckService, UserMigrationService } from '@src/modules/user-login-migration'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../error'; +import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; import { OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index afef7f113e5..4445438b11f 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -12,7 +12,7 @@ import { SystemDto } from '@src/modules/system/service'; import { UserService } from '@src/modules/user'; import { MigrationCheckService, UserMigrationService } from '@src/modules/user-login-migration'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, SSOErrorCode, UserNotFoundAfterProvisioningLoggableException } from '../error'; +import { OAuthSSOError, SSOErrorCode, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; import { TokenRequestMapper } from '../mapper/token-request.mapper'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @@ -172,7 +172,7 @@ export class OAuthService { const system: SystemDto = await this.systemService.findById(systemId); let redirect: string; - if (system.oauthConfig?.provider === 'iserv') { + if (system.oauthConfig?.provider === 'iserv' && system.oauthConfig?.logoutEndpoint) { const iservLogoutUrl: URL = new URL(system.oauthConfig.logoutEndpoint); iservLogoutUrl.searchParams.append('id_token_hint', idToken); iservLogoutUrl.searchParams.append('post_logout_redirect_uri', postLoginRedirect || dashboardUrl.toString()); diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts index ffcc9abc393..4736e1905d1 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts @@ -13,7 +13,7 @@ import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; class HydraOauthUcSpec extends HydraOauthUc { diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts index d042c037df5..7889ad31def 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -5,7 +5,7 @@ import { LegacyLogger } from '@src/core/logger'; import { HydraRedirectDto } from '@src/modules/oauth/service/dto/hydra.redirect.dto'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; import { HydraSsoService } from '../service/hydra.service'; import { OAuthService } from '../service/oauth.service'; diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts index dba6cca003c..15907d5dde2 100644 --- a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts @@ -8,7 +8,6 @@ import { legacySchoolDoFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { ICurrentUser } from '@src/modules/authentication'; import { AuthenticationService } from '@src/modules/authentication/services/authentication.service'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import { OauthUc } from '@src/modules/oauth/uc/oauth.uc'; import { ProvisioningService } from '@src/modules/provisioning'; import { ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@src/modules/provisioning/dto'; @@ -20,6 +19,7 @@ import { UserMigrationService } from '@src/modules/user-login-migration'; import { OAuthMigrationError } from '@src/modules/user-login-migration/error/oauth-migration.error'; import { SchoolMigrationService } from '@src/modules/user-login-migration/service'; import { MigrationDto } from '@src/modules/user-login-migration/service/dto'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { AuthorizationParams } from '../controller/dto'; import { OAuthTokenDto } from '../interface'; import { OAuthProcessDto } from '../service/dto'; diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts index 0926ae7e3a7..fad80012e06 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts @@ -4,10 +4,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, RoleName, User, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import { LegacySchoolService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import jwt from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { RoleDto } from '../../../role/service/dto/role.dto'; import { ExternalSchoolDto, diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts index 71ea2972b54..4a449b9287f 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { LegacySchoolDo, RoleName, RoleReference, User, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import { LegacySchoolService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { ExternalSchoolDto, ExternalUserDto, diff --git a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts index 37ca542a6d7..c6cff205686 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import jwt from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { ExternalUserDto, OauthDataDto, diff --git a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts index 1d944bb0c8f..5daa69ed7d2 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { ExternalUserDto, OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto } from '../../dto'; import { ProvisioningStrategy } from '../base.strategy'; diff --git a/apps/server/src/modules/system/controller/dto/oauth-config.response.ts b/apps/server/src/modules/system/controller/dto/oauth-config.response.ts index 1d5f98a7c6d..f2649b75f9a 100644 --- a/apps/server/src/modules/system/controller/dto/oauth-config.response.ts +++ b/apps/server/src/modules/system/controller/dto/oauth-config.response.ts @@ -66,10 +66,10 @@ export class OauthConfigResponse { @ApiProperty({ description: 'Logout endpoint', - required: true, + required: false, nullable: false, }) - logoutEndpoint: string; + logoutEndpoint?: string; @ApiProperty({ description: 'Issuer', @@ -95,7 +95,7 @@ export class OauthConfigResponse { jwksEndpoint: string; authEndpoint: string; scope: string; - logoutEndpoint: string; + logoutEndpoint?: string; grantType: string; issuer: string; }) { diff --git a/apps/server/src/modules/system/service/dto/oauth-config.dto.ts b/apps/server/src/modules/system/service/dto/oauth-config.dto.ts index 8ee1605bee2..7af97200971 100644 --- a/apps/server/src/modules/system/service/dto/oauth-config.dto.ts +++ b/apps/server/src/modules/system/service/dto/oauth-config.dto.ts @@ -19,7 +19,10 @@ export class OauthConfigDto { provider: string; - logoutEndpoint: string; + /** + * If this is set it will be used to redirect the user after login to the logout endpoint of the identity provider. + */ + logoutEndpoint?: string; issuer: string; diff --git a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts b/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts index 23d6b58c0fe..06f0c0c235e 100644 --- a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts +++ b/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts @@ -1,4 +1,4 @@ -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; export class OAuthMigrationError extends OAuthSSOError { readonly message: string; diff --git a/apps/server/src/shared/domain/entity/system.entity.ts b/apps/server/src/shared/domain/entity/system.entity.ts index 8f1b5821fbd..0633e515589 100644 --- a/apps/server/src/shared/domain/entity/system.entity.ts +++ b/apps/server/src/shared/domain/entity/system.entity.ts @@ -62,8 +62,8 @@ export class OauthConfig { @Property() provider: string; - @Property() - logoutEndpoint: string; + @Property({ nullable: true }) + logoutEndpoint?: string; @Property() issuer: string; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 96c0f276522..4cec0040475 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -306,5 +306,16 @@ "$date": "2023-09-01T13:14:13.453Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "652686eb35521c3d90686845" + }, + "state": "up", + "name": "remove-moin-schule-logout-endpoint", + "createdAt": { + "$date": "2023-10-11T10:40:18.782Z" + }, + "__v": 0 } ] diff --git a/migrations/1697020818782-remove-moin-schule-logout-endpoint.js b/migrations/1697020818782-remove-moin-schule-logout-endpoint.js new file mode 100644 index 00000000000..793db440b46 --- /dev/null +++ b/migrations/1697020818782-remove-moin-schule-logout-endpoint.js @@ -0,0 +1,87 @@ +const mongoose = require('mongoose'); +// eslint-disable-next-line no-unused-vars +const { alert, error } = require('../src/logger'); + +const { connect, close } = require('../src/utils/database'); + +const Systems = mongoose.model( + 'system2023101111140', + new mongoose.Schema( + { + alias: { type: String }, + oauthConfig: { + type: { + clientId: { type: String, required: true }, + clientSecret: { type: String, required: true }, + grantType: { type: String, required: true }, + redirectUri: { type: String, required: true }, + scope: { type: String, required: true }, + responseType: { type: String, required: true }, + authEndpoint: { type: String, required: true }, + provider: { type: String, required: true }, + logoutEndpoint: { type: String, required: false }, + issuer: { type: String, required: true }, + jwksEndpoint: { type: String, required: true }, + }, + required: false, + }, + }, + { + timestamps: true, + } + ), + 'systems' +); + +module.exports = { + up: async function up() { + await connect(); + + const result = await Systems.findOneAndUpdate( + { alias: 'SANIS' }, + { + $unset: { + 'oauthConfig.logoutEndpoint': 1, + }, + } + ) + .lean() + .exec(); + + if (result) { + alert(`Removed logoutEndpoint from oauthConfig of sanis/moin.schule system`); + } else { + alert('No matching document found with alias "SANIS" and logoutEndpoint'); + } + + await close(); + }, + + down: async function down() { + await connect(); + + const system = await Systems.findOne({ alias: 'SANIS' }).lean().exec(); + + if (system) { + const { authEndpoint } = system.oauthConfig; + const logoutEndpoint = authEndpoint.replace(/\/auth$/, '/logout'); + + const result = await Systems.findOneAndUpdate( + { alias: 'SANIS' }, + { + $set: { + 'oauthConfig.logoutEndpoint': logoutEndpoint, + }, + } + ) + .lean() + .exec(); + + if (result) { + alert(`Added logoutEndpoint to oauthConfig of sanis/moin.schule system`); + } + } + + await close(); + }, +}; From fdc040a2ebc9f4f3dc5a84093bf585526c1eabf0 Mon Sep 17 00:00:00 2001 From: Martin Schuhmacher <55735359+MartinSchuhmacher@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:14:29 +0200 Subject: [PATCH 27/34] BC-5450 - BE fix issue with api generator for SubmissionsResponse (#4472) * revert changes from api generator fix * remove unnecessary properties --- .../controller/api-test/submission-item-lookup.api.spec.ts | 2 +- .../dto/element/update-element-content.body.params.ts | 2 -- .../src/modules/board/controller/dto/submission-item/index.ts | 2 +- .../board/controller/mapper/submission-item-response.mapper.ts | 3 +-- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts index 6f31d5a4e5a..4686f953d29 100644 --- a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts @@ -15,7 +15,7 @@ import { userFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server'; -import { SubmissionsResponse } from '../dto/submission-item/submissions.response'; +import { SubmissionsResponse } from '../dto'; const baseRouteName = '/board-submissions'; describe('submission item lookup (api)', () => { diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 5eb0f239c1f..d9b709d8d67 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -69,8 +69,6 @@ export class SubmissionContainerContentBody { @IsDate() @IsOptional() @ApiPropertyOptional({ - required: false, - nullable: true, description: 'The point in time until when a submission can be handed in.', }) dueDate?: Date; diff --git a/apps/server/src/modules/board/controller/dto/submission-item/index.ts b/apps/server/src/modules/board/controller/dto/submission-item/index.ts index b009f3e0560..affa6cedc24 100644 --- a/apps/server/src/modules/board/controller/dto/submission-item/index.ts +++ b/apps/server/src/modules/board/controller/dto/submission-item/index.ts @@ -4,5 +4,5 @@ export * from './submission-item.response'; export * from './submission-item.url.params'; // TODO for some reason, api generator messes up the types // import it directly, not via this index seems to fix it -// export * from './submissions.response'; +export * from './submissions.response'; export * from './update-submission-item.body.params'; diff --git a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts index 82d2292ba11..53efb37a482 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts @@ -1,6 +1,5 @@ import { SubmissionItem, UserBoardRoles } from '@shared/domain'; -import { SubmissionsResponse } from '../dto/submission-item/submissions.response'; -import { SubmissionItemResponse, TimestampsResponse, UserDataResponse } from '../dto'; +import { SubmissionItemResponse, SubmissionsResponse, TimestampsResponse, UserDataResponse } from '../dto'; export class SubmissionItemResponseMapper { private static instance: SubmissionItemResponseMapper; From 264d8ffacf7bf6275fccd8c9e0a15bf96e5d1b51 Mon Sep 17 00:00:00 2001 From: sszafGCA <116172610+sszafGCA@users.noreply.github.com> Date: Mon, 16 Oct 2023 11:12:52 +0200 Subject: [PATCH 28/34] BC-4579- Rewriting the user module user data deletion method (#4441) Added user deletion method for KNL purposes --- .../modules/user/service/user.service.spec.ts | 48 ++++++++++++++++++- .../src/modules/user/service/user.service.ts | 8 +++- .../repo/user/user.repo.integration.spec.ts | 41 ++++++++++++++++ apps/server/src/shared/repo/user/user.repo.ts | 7 +++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index ea29891cee7..a24704014f5 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/core'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { IFindOptions, LanguageType, Permission, Role, RoleName, SortOrder, User } from '@shared/domain'; +import { EntityId, IFindOptions, LanguageType, Permission, Role, RoleName, SortOrder, User } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; @@ -329,4 +329,50 @@ describe('UserService', () => { expect(userDORepo.saveAll).toHaveBeenCalledWith(users); }); }); + + describe('deleteUser', () => { + describe('when user is missing', () => { + const setup = () => { + const user: UserDO = userDoFactory.build({ id: undefined }); + const userId: EntityId = user.id as EntityId; + + userRepo.deleteUser.mockResolvedValue(0); + + return { + userId, + }; + }; + + it('should return 0', async () => { + const { userId } = setup(); + + const result = await service.deleteUser(userId); + + expect(result).toEqual(0); + }); + }); + + describe('when deleting by userId', () => { + const setup = () => { + const user1: User = userFactory.asStudent().buildWithId(); + userFactory.asStudent().buildWithId(); + + userRepo.findById.mockResolvedValue(user1); + userRepo.deleteUser.mockResolvedValue(1); + + return { + user1, + }; + }; + + it('should delete user by userId', async () => { + const { user1 } = setup(); + + const result = await service.deleteUser(user1.id); + + expect(userRepo.deleteUser).toHaveBeenCalledWith(user1.id); + expect(result).toEqual(1); + }); + }); + }); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 8f7bf0beeba..60f62e5ee28 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -1,4 +1,3 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EntityId, IFindOptions, LanguageType, User } from '@shared/domain'; import { RoleReference } from '@shared/domain/domainobject'; @@ -12,6 +11,7 @@ import { ICurrentUser } from '@src/modules/authentication'; import { CurrentUserMapper } from '@src/modules/authentication/mapper'; import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { RoleService } from '@src/modules/role/service/role.service'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { IUserConfig } from '../interfaces'; import { UserMapper } from '../mapper/user.mapper'; import { UserDto } from '../uc/dto/user.dto'; @@ -107,4 +107,10 @@ export class UserService { throw new BadRequestException('Language is not activated.'); } } + + async deleteUser(userId: EntityId): Promise { + const deletedUserNumber: Promise = this.userRepo.deleteUser(userId); + + return deletedUserNumber; + } } diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index 04e19284040..d469b59c14b 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -399,4 +399,45 @@ describe('user repo', () => { expect(user.id).not.toBeNull(); }); }); + + describe('delete', () => { + const setup = async () => { + const user1: User = userFactory.buildWithId(); + const user2: User = userFactory.buildWithId(); + const user3: User = userFactory.buildWithId(); + await em.persistAndFlush([user1, user2, user3]); + + return { + user1, + user2, + user3, + }; + }; + it('should delete user', async () => { + const { user1, user2, user3 } = await setup(); + const deleteResult = await repo.deleteUser(user1.id); + expect(deleteResult).toEqual(1); + + const result1 = await em.find(User, { id: user1.id }); + expect(result1).toHaveLength(0); + + const result2 = await repo.findById(user2.id); + expect(result2).toMatchObject({ + firstName: user2.firstName, + lastName: user2.lastName, + email: user2.email, + roles: user2.roles, + school: user2.school, + }); + + const result3 = await repo.findById(user3.id); + expect(result3).toMatchObject({ + firstName: user3.firstName, + lastName: user3.lastName, + email: user3.email, + roles: user3.roles, + school: user3.school, + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 32aa38578a0..a067953faba 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -163,6 +163,13 @@ export class UserRepo extends BaseRepo { return promise; } + async deleteUser(userId: EntityId): Promise { + const deletedUserNumber: Promise = this._em.nativeDelete(User, { + id: userId, + }); + return deletedUserNumber; + } + private async populateRoles(roles: Role[]): Promise { for (let i = 0; i < roles.length; i += 1) { const role = roles[i]; From 2fc165e9b7b2589185a057803d5c84c19b5efa3e Mon Sep 17 00:00:00 2001 From: mrikallab <93978883+mrikallab@users.noreply.github.com> Date: Mon, 16 Oct 2023 15:09:24 +0200 Subject: [PATCH 29/34] N21-939-assign-groups-to-course (#4464) --- apps/server/src/apps/server.app.ts | 3 ++ .../controller/api-test/group.api.spec.ts | 26 ++++++++++-- .../dto/response/class-info.response.ts | 13 ++++++ .../mapper/group-response.mapper.ts | 3 ++ .../modules/group/uc/dto/class-info.dto.ts | 11 +++++ .../modules/group/uc/dto/class-root-type.ts | 4 ++ .../src/modules/group/uc/group.uc.spec.ts | 42 ++++++++++++++++--- apps/server/src/modules/group/uc/group.uc.ts | 14 +++++-- .../group/uc/mapper/group-uc.mapper.ts | 10 ++++- .../service/school-year.service.spec.ts | 32 ++++++++++++-- .../service/school-year.service.ts | 8 +++- .../service/feathers-roster.service.spec.ts | 2 + .../src/shared/domain/entity/course.entity.ts | 12 ++++++ .../course/course.repo.integration.spec.ts | 2 + config/default.schema.json | 5 +++ src/services/user-group/hooks/courses.js | 28 +++++++++++-- src/services/user-group/model.js | 1 + .../services/user-group/hooks/classes.test.js | 1 + 18 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/modules/group/uc/dto/class-root-type.ts diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 6452ca1cd47..c486adc1915 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -12,6 +12,7 @@ import { TeamService } from '@src/modules/teams/service/team.service'; import { AccountValidationService } from '@src/modules/account/services/account.validation.service'; import { AccountUc } from '@src/modules/account/uc/account.uc'; import { CollaborativeStorageUc } from '@src/modules/collaborative-storage/uc/collaborative-storage.uc'; +import { GroupService } from '@src/modules/group'; import { RocketChatService } from '@src/modules/rocketchat'; import { ServerModule } from '@src/modules/server'; import express from 'express'; @@ -82,6 +83,8 @@ async function bootstrap() { feathersExpress.services['nest-team-service'] = nestApp.get(TeamService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-feathers-roster-service'] = nestApp.get(FeathersRosterService); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + feathersExpress.services['nest-group-service'] = nestApp.get(GroupService); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; 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 f0561518c0c..39bb86a4caa 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 @@ -1,11 +1,12 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Role, RoleName, SchoolEntity, SortOrder, SystemEntity, User } from '@shared/domain'; +import { Role, RoleName, SchoolEntity, SchoolYearEntity, SortOrder, SystemEntity, User } from '@shared/domain'; import { groupEntityFactory, roleFactory, schoolFactory, + schoolYearFactory, systemFactory, TestApiClient, UserAndAccountTestFactory, @@ -15,6 +16,7 @@ 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 { GroupEntity, GroupEntityTypes } from '../../entity'; +import { ClassRootType } from '../../uc/dto/class-root-type'; import { ClassInfoSearchListResponse, ClassSortBy } from '../dto'; const baseRouteName = '/groups'; @@ -48,11 +50,13 @@ describe('Group (API)', () => { const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); const system: SystemEntity = systemFactory.buildWithId(); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); const clazz: ClassEntity = classEntityFactory.buildWithId({ name: 'Group A', schoolId: school._id, teacherIds: [teacherUser._id], source: undefined, + year: schoolYear.id, }); const group: GroupEntity = groupEntityFactory.buildWithId({ name: 'Group B', @@ -70,7 +74,17 @@ describe('Group (API)', () => { ], }); - await em.persistAndFlush([school, adminAccount, adminUser, teacherRole, teacherUser, system, clazz, group]); + await em.persistAndFlush([ + school, + adminAccount, + adminUser, + teacherRole, + teacherUser, + system, + clazz, + group, + schoolYear, + ]); em.clear(); const adminClient = await testApiClient.login(adminAccount); @@ -82,11 +96,12 @@ describe('Group (API)', () => { system, adminUser, teacherUser, + schoolYear, }; }; it('should return the classes of his school', async () => { - const { adminClient, group, clazz, system, adminUser, teacherUser } = await setup(); + const { adminClient, group, clazz, system, adminUser, teacherUser, schoolYear } = await setup(); const response = await adminClient.get(`/class`).query({ skip: 0, @@ -99,13 +114,18 @@ describe('Group (API)', () => { total: 2, data: [ { + id: group.id, + type: ClassRootType.GROUP, name: group.name, externalSourceName: system.displayName, teachers: [adminUser.lastName], }, { + id: clazz.id, + type: ClassRootType.CLASS, name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, teachers: [teacherUser.lastName], + schoolYear: schoolYear.name, }, ], skip: 0, diff --git a/apps/server/src/modules/group/controller/dto/response/class-info.response.ts b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts index a2d71333c04..62c52501b95 100644 --- a/apps/server/src/modules/group/controller/dto/response/class-info.response.ts +++ b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts @@ -1,6 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ClassRootType } from '../../../uc/dto/class-root-type'; export class ClassInfoResponse { + @ApiProperty() + id: string; + + @ApiProperty({ enum: ClassRootType }) + type: ClassRootType; + @ApiProperty() name: string; @@ -10,9 +17,15 @@ export class ClassInfoResponse { @ApiProperty({ type: [String] }) teachers: string[]; + @ApiPropertyOptional() + schoolYear?: string; + constructor(props: ClassInfoResponse) { + this.id = props.id; + this.type = props.type; this.name = props.name; this.externalSourceName = props.externalSourceName; this.teachers = props.teachers; + this.schoolYear = props.schoolYear; } } 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 6fbb0c6dc65..958aeee2c6b 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 @@ -24,9 +24,12 @@ export class GroupResponseMapper { private static mapToClassInfoToResponse(classInfo: ClassInfoDto): ClassInfoResponse { const mapped = new ClassInfoResponse({ + id: classInfo.id, + type: classInfo.type, name: classInfo.name, externalSourceName: classInfo.externalSourceName, teachers: classInfo.teachers, + schoolYear: classInfo.schoolYear, }); return mapped; diff --git a/apps/server/src/modules/group/uc/dto/class-info.dto.ts b/apps/server/src/modules/group/uc/dto/class-info.dto.ts index 0d2b5adaf68..d17c0169c93 100644 --- a/apps/server/src/modules/group/uc/dto/class-info.dto.ts +++ b/apps/server/src/modules/group/uc/dto/class-info.dto.ts @@ -1,13 +1,24 @@ +import { ClassRootType } from './class-root-type'; + export class ClassInfoDto { + id: string; + + type: ClassRootType; + name: string; externalSourceName?: string; teachers: string[]; + schoolYear?: string; + constructor(props: ClassInfoDto) { + this.id = props.id; + this.type = props.type; this.name = props.name; this.externalSourceName = props.externalSourceName; this.teachers = props.teachers; + this.schoolYear = props.schoolYear; } } diff --git a/apps/server/src/modules/group/uc/dto/class-root-type.ts b/apps/server/src/modules/group/uc/dto/class-root-type.ts new file mode 100644 index 00000000000..b1a725a7ddc --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/class-root-type.ts @@ -0,0 +1,4 @@ +export enum ClassRootType { + CLASS = 'class', + GROUP = 'group', +} 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 b4115d3739b..ed089007a72 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -2,11 +2,12 @@ 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 { LegacySchoolDo, Page, Permission, SortOrder, User, UserDO } from '@shared/domain'; +import { LegacySchoolDo, Page, Permission, SchoolYearEntity, SortOrder, User, UserDO } from '@shared/domain'; import { groupFactory, legacySchoolDoFactory, roleDtoFactory, + schoolYearFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, @@ -16,7 +17,7 @@ import { Action, AuthorizationContext, AuthorizationService } from '@src/modules import { ClassService } from '@src/modules/class'; import { Class } from '@src/modules/class/domain'; import { classFactory } from '@src/modules/class/domain/testing/factory/class.factory'; -import { LegacySchoolService } from '@src/modules/legacy-school'; +import { LegacySchoolService, SchoolYearService } from '@src/modules/legacy-school'; import { RoleService } from '@src/modules/role'; import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { SystemDto, SystemService } from '@src/modules/system'; @@ -24,6 +25,7 @@ import { UserService } from '@src/modules/user'; import { Group } from '../domain'; import { GroupService } from '../service'; import { ClassInfoDto } from './dto'; +import { ClassRootType } from './dto/class-root-type'; import { GroupUc } from './group.uc'; describe('GroupUc', () => { @@ -37,6 +39,7 @@ describe('GroupUc', () => { let roleService: DeepMocked; let schoolService: DeepMocked; let authorizationService: DeepMocked; + let schoolYearService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -70,6 +73,10 @@ describe('GroupUc', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: SchoolYearService, + useValue: createMock(), + }, ], }).compile(); @@ -81,6 +88,7 @@ describe('GroupUc', () => { roleService = module.get(RoleService); schoolService = module.get(LegacySchoolService); authorizationService = module.get(AuthorizationService); + schoolYearService = module.get(SchoolYearService); await setupEntities(); }); @@ -144,7 +152,13 @@ describe('GroupUc', () => { lastName: studentUser.lastName, roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], }); - const clazz: Class = classFactory.build({ name: 'A', teacherIds: [teacherUser.id], source: 'LDAP' }); + const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); + const clazz: Class = classFactory.build({ + name: 'A', + teacherIds: [teacherUser.id], + source: 'LDAP', + year: schoolYear.id, + }); const system: SystemDto = new SystemDto({ id: new ObjectId().toHexString(), displayName: 'External System', @@ -191,6 +205,7 @@ describe('GroupUc', () => { throw new Error(); }); + schoolYearService.findById.mockResolvedValue(schoolYear); return { teacherUser, @@ -199,6 +214,7 @@ describe('GroupUc', () => { group, groupWithSystem, system, + schoolYear, }; }; @@ -219,23 +235,30 @@ describe('GroupUc', () => { describe('when no pagination is given', () => { it('should return all classes sorted by name', async () => { - const { teacherUser, clazz, group, groupWithSystem, system } = setup(); + const { teacherUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); const result: Page = await uc.findAllClassesForSchool(teacherUser.id, teacherUser.school.id); expect(result).toEqual>({ data: [ { + id: clazz.id, name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, externalSourceName: clazz.source, teachers: [teacherUser.lastName], + schoolYear: schoolYear.name, }, { + id: group.id, name: group.name, + type: ClassRootType.GROUP, teachers: [teacherUser.lastName], }, { + id: groupWithSystem.id, name: groupWithSystem.name, + type: ClassRootType.GROUP, externalSourceName: system.displayName, teachers: [teacherUser.lastName], }, @@ -247,7 +270,7 @@ describe('GroupUc', () => { describe('when sorting by external source name in descending order', () => { it('should return all classes sorted by external source name in descending order', async () => { - const { teacherUser, clazz, group, groupWithSystem, system } = setup(); + const { teacherUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); const result: Page = await uc.findAllClassesForSchool( teacherUser.id, @@ -261,17 +284,24 @@ describe('GroupUc', () => { expect(result).toEqual>({ data: [ { + id: clazz.id, name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + type: ClassRootType.CLASS, externalSourceName: clazz.source, teachers: [teacherUser.lastName], + schoolYear: schoolYear.name, }, { + id: groupWithSystem.id, name: groupWithSystem.name, + type: ClassRootType.GROUP, externalSourceName: system.displayName, teachers: [teacherUser.lastName], }, { + id: group.id, name: group.name, + type: ClassRootType.GROUP, teachers: [teacherUser.lastName], }, ], @@ -296,7 +326,9 @@ describe('GroupUc', () => { expect(result).toEqual>({ data: [ { + id: group.id, name: group.name, + type: ClassRootType.GROUP, teachers: [teacherUser.lastName], }, ], diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index a179b8cb352..1d884c5a325 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { EntityId, LegacySchoolDo, Page, Permission, SortOrder, User, UserDO } from '@shared/domain'; +import { EntityId, LegacySchoolDo, Page, Permission, SchoolYearEntity, SortOrder, User, UserDO } from '@shared/domain'; import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { ClassService } from '@src/modules/class'; import { Class } from '@src/modules/class/domain'; -import { LegacySchoolService } from '@src/modules/legacy-school'; +import { LegacySchoolService, SchoolYearService } from '@src/modules/legacy-school'; import { RoleService } from '@src/modules/role'; import { RoleDto } from '@src/modules/role/service/dto/role.dto'; import { SystemDto, SystemService } from '@src/modules/system'; @@ -23,7 +23,8 @@ export class GroupUc { private readonly userService: UserService, private readonly roleService: RoleService, private readonly schoolService: LegacySchoolService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationService, + private readonly schoolYearService: SchoolYearService ) {} public async findAllClassesForSchool( @@ -72,7 +73,12 @@ export class GroupUc { clazz.teacherIds.map((teacherId: EntityId) => this.userService.findById(teacherId)) ); - const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto(clazz, teachers); + let schoolYear: SchoolYearEntity | undefined; + if (clazz.year) { + schoolYear = await this.schoolYearService.findById(clazz.year); + } + + const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto(clazz, teachers, schoolYear); return mapped; }) 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 1e1f11057ce..596302c4a5c 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 @@ -1,8 +1,9 @@ -import { RoleName, UserDO } from '@shared/domain'; +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 { ClassRootType } from '../dto/class-root-type'; export class GroupUcMapper { public static mapGroupToClassInfoDto( @@ -11,6 +12,8 @@ export class GroupUcMapper { system?: SystemDto ): ClassInfoDto { const mapped: ClassInfoDto = new ClassInfoDto({ + id: group.id, + type: ClassRootType.GROUP, name: group.name, externalSourceName: system?.displayName, teachers: resolvedUsers @@ -21,13 +24,16 @@ export class GroupUcMapper { return mapped; } - public static mapClassToClassInfoDto(clazz: Class, teachers: UserDO[]): ClassInfoDto { + public static mapClassToClassInfoDto(clazz: Class, teachers: UserDO[], schoolYear?: SchoolYearEntity): ClassInfoDto { const name = clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name; const mapped: ClassInfoDto = new ClassInfoDto({ + id: clazz.id, + type: ClassRootType.CLASS, name, externalSourceName: clazz.source, teachers: teachers.map((user: UserDO) => user.lastName), + schoolYear: schoolYear?.name, }); return mapped; diff --git a/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts b/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts index 00e47a6360f..041b80d41d1 100644 --- a/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts +++ b/apps/server/src/modules/legacy-school/service/school-year.service.spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolYearEntity } from '@shared/domain'; import { setupEntities } from '@shared/testing'; import { schoolYearFactory } from '@shared/testing/factory/schoolyear.factory'; -import { SchoolYearEntity } from '@shared/domain'; -import { SchoolYearService } from './school-year.service'; import { SchoolYearRepo } from '../repo'; +import { SchoolYearService } from './school-year.service'; describe('SchoolYearService', () => { let module: TestingModule; @@ -57,4 +57,30 @@ describe('SchoolYearService', () => { }); }); }); + + describe('findById', () => { + const setup = () => { + jest.setSystemTime(new Date('2022-06-01').getTime()); + const schoolYear: SchoolYearEntity = schoolYearFactory.build({ + startDate: new Date('2021-09-01'), + endDate: new Date('2022-12-31'), + }); + + schoolYearRepo.findById.mockResolvedValue(schoolYear); + + return { + schoolYear, + }; + }; + + describe('when called', () => { + it('should return the current school year', async () => { + const { schoolYear } = setup(); + + const currentSchoolYear: SchoolYearEntity = await service.findById(schoolYear.id); + + expect(currentSchoolYear).toEqual(schoolYear); + }); + }); + }); }); diff --git a/apps/server/src/modules/legacy-school/service/school-year.service.ts b/apps/server/src/modules/legacy-school/service/school-year.service.ts index 16cae1c1cff..c153122e5d1 100644 --- a/apps/server/src/modules/legacy-school/service/school-year.service.ts +++ b/apps/server/src/modules/legacy-school/service/school-year.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { SchoolYearEntity } from '@shared/domain'; +import { EntityId, SchoolYearEntity } from '@shared/domain'; import { SchoolYearRepo } from '../repo'; @Injectable() @@ -12,4 +12,10 @@ export class SchoolYearService { return current; } + + async findById(id: EntityId): Promise { + const year: SchoolYearEntity = await this.schoolYearRepo.findById(id); + + return year; + } } diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts index 6c55067552d..ce4d5144a38 100644 --- a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts @@ -424,6 +424,8 @@ describe('FeathersRosterService', () => { students: [studentUser, studentUser2], teachers: [teacherUser], substitutionTeachers: [substitutionTeacherUser], + classes: [], + groups: [], }); courseService.findById.mockResolvedValue(courseA); diff --git a/apps/server/src/shared/domain/entity/course.entity.ts b/apps/server/src/shared/domain/entity/course.entity.ts index 1aea75aa3c0..e873ed05300 100644 --- a/apps/server/src/shared/domain/entity/course.entity.ts +++ b/apps/server/src/shared/domain/entity/course.entity.ts @@ -1,6 +1,8 @@ import { Collection, Entity, Enum, Index, ManyToMany, ManyToOne, OneToMany, Property, Unique } from '@mikro-orm/core'; import { InternalServerErrorException } from '@nestjs/common/exceptions/internal-server-error.exception'; import { IEntityWithSchool, ILearnroom } from '@shared/domain/interface'; +import { ClassEntity } from '@src/modules/class/entity/class.entity'; +import { GroupEntity } from '@src/modules/group/entity/group.entity'; import { EntityId, LearnroomMetadata, LearnroomTypes } from '../types'; import { BaseEntityWithTimestamps } from './base.entity'; import { CourseGroup } from './coursegroup.entity'; @@ -22,6 +24,8 @@ export interface ICourseProperties { untilDate?: Date; copyingSince?: Date; features?: CourseFeatures[]; + classes?: ClassEntity[]; + groups?: GroupEntity[]; } // that is really really shit default handling :D constructor, getter, js default, em default...what the hell @@ -95,6 +99,12 @@ export class Course @Enum({ nullable: true, array: true }) features?: CourseFeatures[]; + @ManyToMany(() => ClassEntity, undefined, { fieldName: 'classIds' }) + classes = new Collection(this); + + @ManyToMany(() => GroupEntity, undefined, { fieldName: 'groupIds' }) + groups = new Collection(this); + constructor(props: ICourseProperties) { super(); if (props.name) this.name = props.name; @@ -108,6 +118,8 @@ export class Course if (props.startDate) this.startDate = props.startDate; if (props.copyingSince) this.copyingSince = props.copyingSince; if (props.features) this.features = props.features; + this.classes.set(props.classes || []); + this.groups.set(props.groups || []); } public getStudentIds(): EntityId[] { diff --git a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts index 42df9c6ba24..5474c4ec19d 100644 --- a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts @@ -72,6 +72,8 @@ describe('course repo', () => { 'updatedAt', 'students', 'features', + 'classes', + 'groups', ].sort(); expect(keysOfFirstElements).toEqual(expectedResult); }); diff --git a/config/default.schema.json b/config/default.schema.json index 5aba0e9aad8..ef92d3f1db5 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1322,6 +1322,11 @@ "default": false, "description": "Enables the new class list view" }, + "FEATURE_GROUPS_IN_COURSE_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables groups of type class in courses" + }, "TSP_SCHOOL_SYNCER": { "type": "object", "description": "TSP School Syncer properties", diff --git a/src/services/user-group/hooks/courses.js b/src/services/user-group/hooks/courses.js index e5cdb9a7de4..41e2014feb0 100644 --- a/src/services/user-group/hooks/courses.js +++ b/src/services/user-group/hooks/courses.js @@ -1,4 +1,6 @@ const _ = require('lodash'); +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); +const { service } = require('feathers-mongoose'); const { BadRequest } = require('../../../errors'); const globalHooks = require('../../../hooks'); @@ -10,17 +12,34 @@ const restrictToCurrentSchool = globalHooks.ifNotLocal(globalHooks.restrictToCur const restrictToUsersOwnCourses = globalHooks.ifNotLocal(globalHooks.restrictToUsersOwnCourses); const { checkScopePermissions } = require('../../helpers/scopePermissions/hooks'); - /** * adds all students to a course when a class is added to the course * @param hook - contains created/patched object and request body */ -const addWholeClassToCourse = (hook) => { +const addWholeClassToCourse = async (hook) => { + const { app } = hook; const requestBody = hook.data; const course = hook.result; - if (requestBody.classIds === undefined) { - return hook; + + if (Configuration.get('FEATURE_GROUPS_IN_COURSE_ENABLED') && (requestBody.groupIds || []).length > 0) { + await Promise.all( + requestBody.groupIds.map((groupId) => + app + .service('nest-group-service') + .findById(groupId) + .then((group) => group.users) + ) + ).then(async (groupUsers) => { + // flatten deep arrays and remove duplicates + const userIds = _.flattenDeep(groupUsers).map((groupUser) => groupUser.userId); + const uniqueUserIds = _.uniqWith(userIds, (a, b) => a === b); + + await CourseModel.update({ _id: course._id }, { $addToSet: { userIds: { $each: uniqueUserIds } } }).exec(); + + return undefined; + }); } + if ((requestBody.classIds || []).length > 0) { // just courses do have a property "classIds" return Promise.all( @@ -34,6 +53,7 @@ const addWholeClassToCourse = (hook) => { studentIds = _.uniqWith(_.flattenDeep(studentIds), (e1, e2) => JSON.stringify(e1) === JSON.stringify(e2)); await CourseModel.update({ _id: course._id }, { $addToSet: { userIds: { $each: studentIds } } }).exec(); + return hook; }); } diff --git a/src/services/user-group/model.js b/src/services/user-group/model.js index 70a7bb7bab3..2bdf688b2c3 100644 --- a/src/services/user-group/model.js +++ b/src/services/user-group/model.js @@ -45,6 +45,7 @@ const timeSchema = new Schema({ const courseSchema = getUserGroupSchema({ description: { type: String }, classIds: [{ type: Schema.Types.ObjectId, required: true, ref: 'class' }], + groupIds: [{ type: Schema.Types.ObjectId }], teacherIds: [{ type: Schema.Types.ObjectId, required: true, ref: 'user' }], substitutionIds: [{ type: Schema.Types.ObjectId, required: true, ref: 'user' }], ltiToolIds: [{ type: Schema.Types.ObjectId, required: true, ref: 'ltiTool' }], diff --git a/test/services/user-group/hooks/classes.test.js b/test/services/user-group/hooks/classes.test.js index 33f547e0e38..7353f1520f0 100644 --- a/test/services/user-group/hooks/classes.test.js +++ b/test/services/user-group/hooks/classes.test.js @@ -68,6 +68,7 @@ describe('class hooks', () => { configBefore = Configuration.toObject({}); app = await appPromise(); Configuration.set('TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT', 'false'); + Configuration.set('FEATURE_GROUPS_IN_COURSE_ENABLED', 'false'); server = await app.listen(0); nestServices = await setupNestServices(app); }); From 1f90ed182448ab4a96d195eb3a025908136c9c66 Mon Sep 17 00:00:00 2001 From: mkreuzkam-cap <144103168+mkreuzkam-cap@users.noreply.github.com> Date: Wed, 18 Oct 2023 09:54:05 +0200 Subject: [PATCH 30/34] EW-553: Export description of etherpad content as title of weblink. (#4477) --- .../service/common-cartridge-export.service.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts index 25077d84338..b41b6314fa8 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.ts @@ -115,8 +115,18 @@ export class CommonCartridgeExportService { if (content.component === ComponentType.ETHERPAD) { return version === CommonCartridgeVersion.V_1_3_0 - ? { ...commonProps, type: CommonCartridgeResourceType.WEB_LINK_V3, url: content.content.url } - : { ...commonProps, type: CommonCartridgeResourceType.WEB_LINK_V1, url: content.content.url }; + ? { + ...commonProps, + type: CommonCartridgeResourceType.WEB_LINK_V3, + url: content.content.url, + title: content.content.description, + } + : { + ...commonProps, + type: CommonCartridgeResourceType.WEB_LINK_V1, + url: content.content.url, + title: content.content.description, + }; } return undefined; From f2f0b368e636b997aaea4aa63157bd745c56fc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:13:10 +0200 Subject: [PATCH 31/34] N21-1248 Configure External Tools in Boards (#4471) - move tool reference to context external tool module - new endpoint for tool reference from context external tool id - add new tool reference controller - add board-element as context for context external tools --- .../element/external-tool-element.response.ts | 6 +- .../external-tool-element-response.mapper.ts | 2 +- .../service/feathers-roster.service.spec.ts | 16 +- .../service/feathers-roster.service.ts | 6 +- .../common/enum/tool-context-type.enum.ts | 1 + .../tool/common/mapper/context-type.mapper.ts | 1 + .../mapper/tool-status-response.mapper.ts | 14 + .../context-external-tool.module.ts | 27 +- .../api-test/tool-reference.api.spec.ts | 287 ++++++++++++++++++ .../context-external-tool-context.params.ts | 8 +- .../controller/dto/index.ts | 3 + .../tool-configuration-status.response.ts | 0 .../dto}/tool-reference-list.response.ts | 0 .../dto}/tool-reference.response.ts | 1 + .../controller/tool-reference.controller.ts | 68 +++++ .../context-external-tool/domain/index.ts | 1 + .../domain/tool-reference.ts | 0 .../entity/context-external-tool-type.enum.ts | 1 + .../context-external-tool-response.mapper.ts | 25 +- .../context-external-tool/mapper/index.ts | 1 + .../mapper/tool-reference.mapper.ts | 4 +- ...t-external-tool-validation.service.spec.ts | 8 +- ...ontext-external-tool-validation.service.ts | 6 +- .../context-external-tool.service.spec.ts | 6 +- .../service/context-external-tool.service.ts | 4 +- .../context-external-tool/service/index.ts | 1 + .../service/tool-reference.service.spec.ts | 132 ++++++++ .../service/tool-reference.service.ts | 50 +++ .../uc/context-external-tool.uc.spec.ts | 10 +- .../uc/context-external-tool.uc.ts | 14 +- .../tool/context-external-tool/uc/index.ts | 1 + .../uc/tool-reference.uc.spec.ts | 215 +++++++++++++ .../uc/tool-reference.uc.ts | 82 +++++ .../controller/api-test/tool.api.spec.ts | 142 +-------- .../controller/dto/response/index.ts | 3 - .../controller/tool.controller.ts | 38 +-- .../tool/external-tool/domain/index.ts | 1 - .../mapper/external-tool-response.mapper.ts | 24 +- .../tool/external-tool/mapper/index.ts | 1 - .../external-tool-logo-service.spec.ts | 10 +- .../service/external-tool-logo.service.ts | 14 +- .../external-tool-validation.service.spec.ts | 12 +- .../external-tool-validation.service.ts | 4 +- .../service/external-tool.service.spec.ts | 8 +- .../service/external-tool.service.ts | 2 +- .../uc/external-tool-configuration.uc.spec.ts | 28 +- .../uc/external-tool-configuration.uc.ts | 16 +- .../external-tool/uc/external-tool.uc.spec.ts | 4 +- .../tool/external-tool/uc/external-tool.uc.ts | 4 +- .../modules/tool/external-tool/uc/index.ts | 1 - .../uc/tool-reference.uc.spec.ts | 234 -------------- .../external-tool/uc/tool-reference.uc.ts | 102 ------- .../api-test/tool-school.api.spec.ts | 6 +- .../dto/school-external-tool.response.ts | 2 +- ...hool-external-tool-response.mapper.spec.ts | 2 +- .../school-external-tool-response.mapper.ts | 12 +- ...l-external-tool-validation.service.spec.ts | 4 +- ...school-external-tool-validation.service.ts | 4 +- .../school-external-tool.service.spec.ts | 20 +- .../service/school-external-tool.service.ts | 12 +- .../uc/school-external-tool.uc.spec.ts | 12 +- .../uc/school-external-tool.uc.ts | 14 +- .../src/modules/tool/tool-api.module.ts | 10 +- .../strategy/abstract-launch.strategy.spec.ts | 151 +++++++-- .../strategy/abstract-launch.strategy.ts | 19 +- .../lti11-tool-launch.strategy.spec.ts | 4 +- .../strategy/lti11-tool-launch.strategy.ts | 2 +- .../service/tool-launch.service.spec.ts | 32 +- .../service/tool-launch.service.ts | 20 +- .../tool-launch/uc/tool-launch.uc.spec.ts | 10 +- .../tool/tool-launch/uc/tool-launch.uc.ts | 8 +- ...ext-external-tool.repo.integration.spec.ts | 34 ++- .../context-external-tool.repo.ts | 4 + 73 files changed, 1246 insertions(+), 785 deletions(-) create mode 100644 apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts rename apps/server/src/modules/tool/{external-tool/controller/dto/response => context-external-tool/controller/dto}/tool-configuration-status.response.ts (100%) rename apps/server/src/modules/tool/{external-tool/controller/dto/response => context-external-tool/controller/dto}/tool-reference-list.response.ts (100%) rename apps/server/src/modules/tool/{external-tool/controller/dto/response => context-external-tool/controller/dto}/tool-reference.response.ts (96%) create mode 100644 apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts rename apps/server/src/modules/tool/{external-tool => context-external-tool}/domain/tool-reference.ts (100%) rename apps/server/src/modules/tool/{external-tool => context-external-tool}/mapper/tool-reference.mapper.ts (80%) create mode 100644 apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts delete mode 100644 apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts delete mode 100644 apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts diff --git a/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts b/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts index 5f51a1a26ec..fc67b7631b6 100644 --- a/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/external-tool-element.response.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { ContentElementType } from '@shared/domain'; import { TimestampsResponse } from '../timestamps.response'; @@ -7,8 +7,8 @@ export class ExternalToolElementContent { this.contextExternalToolId = props.contextExternalToolId; } - @ApiPropertyOptional() - contextExternalToolId?: string; + @ApiProperty({ type: String, required: true, nullable: true }) + contextExternalToolId: string | null; } export class ExternalToolElementResponse { diff --git a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts index a907f4eb157..a27cab41d63 100644 --- a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts @@ -18,7 +18,7 @@ export class ExternalToolElementResponseMapper implements BaseResponseMapper { id: element.id, timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), type: ContentElementType.EXTERNAL_TOOL, - content: new ExternalToolElementContent({ contextExternalToolId: element.contextExternalToolId }), + content: new ExternalToolElementContent({ contextExternalToolId: element.contextExternalToolId ?? null }), }); return result; diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts index ce4d5144a38..e2a8484d630 100644 --- a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts @@ -2,13 +2,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { DatabaseObjectNotFoundException } from '@mikro-orm/core'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { Course, Pseudonym, RoleName, LegacySchoolDo, UserDO, SchoolEntity } from '@shared/domain'; +import { Course, LegacySchoolDo, Pseudonym, RoleName, SchoolEntity, UserDO } from '@shared/domain'; import { contextExternalToolFactory, courseFactory, externalToolFactory, - pseudonymFactory, legacySchoolDoFactory, + pseudonymFactory, schoolExternalToolFactory, schoolFactory, setupEntities, @@ -249,10 +249,10 @@ describe('FeathersRosterService', () => { ]); contextExternalToolService.findAllByContext.mockResolvedValueOnce([otherContextExternalTool]); contextExternalToolService.findAllByContext.mockResolvedValueOnce([]); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(otherSchoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(otherExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(otherSchoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + externalToolService.findById.mockResolvedValueOnce(otherExternalTool); return { pseudonym, @@ -299,7 +299,7 @@ describe('FeathersRosterService', () => { await service.getUserGroups(pseudonym.pseudonym, clientId); - expect(schoolExternalToolService.getSchoolExternalToolById.mock.calls).toEqual([ + expect(schoolExternalToolService.findById.mock.calls).toEqual([ [schoolExternalTool.id], [otherSchoolExternalTool.id], ]); @@ -310,7 +310,7 @@ describe('FeathersRosterService', () => { await service.getUserGroups(pseudonym.pseudonym, clientId); - expect(externalToolService.findExternalToolById.mock.calls).toEqual([[externalToolId], [otherExternalTool.id]]); + expect(externalToolService.findById.mock.calls).toEqual([[externalToolId], [otherExternalTool.id]]); }); it('should return a group for each course where the tool of the users pseudonym is used', async () => { diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts index a5fd359b6c1..e808a2fc59f 100644 --- a/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts @@ -179,12 +179,10 @@ export class FeathersRosterService { ); for await (const contextExternalTool of contextExternalTools) { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId ); - const externalTool: ExternalTool = await this.externalToolService.findExternalToolById( - schoolExternalTool.toolId - ); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); const isRequiredTool: boolean = externalTool.id === externalToolId; if (isRequiredTool) { diff --git a/apps/server/src/modules/tool/common/enum/tool-context-type.enum.ts b/apps/server/src/modules/tool/common/enum/tool-context-type.enum.ts index 20e57d7bd60..4c930b57397 100644 --- a/apps/server/src/modules/tool/common/enum/tool-context-type.enum.ts +++ b/apps/server/src/modules/tool/common/enum/tool-context-type.enum.ts @@ -1,3 +1,4 @@ export enum ToolContextType { COURSE = 'course', + BOARD_ELEMENT = 'board-element', } diff --git a/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts b/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts index 00da0a8b36d..bf8fb537924 100644 --- a/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts +++ b/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts @@ -3,6 +3,7 @@ import { ToolContextType } from '../enum'; const typeMapping: Record = { [ToolContextType.COURSE]: AuthorizableReferenceType.Course, + [ToolContextType.BOARD_ELEMENT]: AuthorizableReferenceType.BoardNode, }; export class ContextTypeMapper { diff --git a/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts new file mode 100644 index 00000000000..c199fc6f307 --- /dev/null +++ b/apps/server/src/modules/tool/common/mapper/tool-status-response.mapper.ts @@ -0,0 +1,14 @@ +import { ToolConfigurationStatusResponse } from '../../context-external-tool/controller/dto/tool-configuration-status.response'; +import { ToolConfigurationStatus } from '../enum'; + +export const statusMapping: Record = { + [ToolConfigurationStatus.LATEST]: ToolConfigurationStatusResponse.LATEST, + [ToolConfigurationStatus.OUTDATED]: ToolConfigurationStatusResponse.OUTDATED, + [ToolConfigurationStatus.UNKNOWN]: ToolConfigurationStatusResponse.UNKNOWN, +}; + +export class ToolStatusResponseMapper { + static mapToResponse(status: ToolConfigurationStatus): ToolConfigurationStatusResponse { + return statusMapping[status]; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts index e3319512fc5..1afd639f1e7 100644 --- a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts +++ b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts @@ -1,25 +1,28 @@ -import { forwardRef, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationModule } from '@src/modules/authorization'; +import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; import { ContextExternalToolAuthorizableService, ContextExternalToolService, ContextExternalToolValidationService, + ToolReferenceService, } from './service'; -import { CommonToolModule } from '../common'; @Module({ - // TODO: remove authorization module here N21-1055 - imports: [ - CommonToolModule, - ExternalToolModule, - SchoolExternalToolModule, - LoggerModule, - forwardRef(() => AuthorizationModule), + imports: [CommonToolModule, ExternalToolModule, SchoolExternalToolModule, LoggerModule], + providers: [ + ContextExternalToolService, + ContextExternalToolValidationService, + ContextExternalToolAuthorizableService, + ToolReferenceService, + ], + exports: [ + ContextExternalToolService, + ContextExternalToolValidationService, + ContextExternalToolAuthorizableService, + ToolReferenceService, ], - providers: [ContextExternalToolService, ContextExternalToolValidationService, ContextExternalToolAuthorizableService], - exports: [ContextExternalToolService, ContextExternalToolValidationService, ContextExternalToolAuthorizableService], }) export class ContextExternalToolModule {} diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts new file mode 100644 index 00000000000..f3072a95131 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts @@ -0,0 +1,287 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course, Permission, SchoolEntity } from '@shared/domain'; +import { + cleanupCollections, + contextExternalToolEntityFactory, + courseFactory, + externalToolEntityFactory, + schoolExternalToolEntityFactory, + schoolFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; +import { Response } from 'supertest'; +import { ToolContextType } from '../../../common/enum'; +import { ExternalToolEntity } from '../../../external-tool/entity'; +import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; +import { ContextExternalToolContextParams, ToolReferenceListResponse, ToolReferenceResponse } from '../dto'; +import { ToolConfigurationStatusResponse } from '../dto/tool-configuration-status.response'; + +describe('ToolReferenceController (API)', () => { + let app: INestApplication; + let em: EntityManager; + + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + + await app.init(); + + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'tools/tool-references'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('[GET] tools/tool-references/:contextType/:contextId', () => { + describe('when user is not authenticated', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get(`contextType/${new ObjectId().toHexString()}`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user has no access to a tool', () => { + const setup = async () => { + const schoolWithoutTool: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId(); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school: schoolWithoutTool }); + const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalToolEntity, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + }); + + await em.persistAndFlush([ + school, + adminAccount, + adminUser, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const params: ContextExternalToolContextParams = { + contextId: course.id, + contextType: ToolContextType.COURSE, + }; + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { loggedInClient, params }; + }; + + it('should filter out the tool', async () => { + const { loggedInClient, params } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextType}/${params.contextId}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ data: [] }); + }); + }); + + describe('when user has access for a tool', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + logoBase64: 'logoBase64', + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalToolEntity, + toolVersion: externalToolEntity.version, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + displayName: 'This is a test tool', + toolVersion: schoolExternalToolEntity.toolVersion, + }); + + await em.persistAndFlush([ + school, + adminAccount, + adminUser, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const params: ContextExternalToolContextParams = { + contextId: course.id, + contextType: ToolContextType.COURSE, + }; + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { loggedInClient, params, contextExternalToolEntity, externalToolEntity }; + }; + + it('should return an ToolReferenceListResponse with data', async () => { + const { loggedInClient, params, contextExternalToolEntity, externalToolEntity } = await setup(); + + const response: Response = await loggedInClient.get(`${params.contextType}/${params.contextId}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [ + { + contextToolId: contextExternalToolEntity.id, + displayName: contextExternalToolEntity.displayName as string, + status: ToolConfigurationStatusResponse.LATEST, + logoUrl: `http://localhost:3030/api/v3/tools/external-tools/${externalToolEntity.id}/logo`, + openInNewTab: externalToolEntity.openNewTab, + }, + ], + }); + }); + }); + }); + + describe('[GET] tools/tool-references/context-external-tools/:contextExternalToolId', () => { + describe('when user is not authenticated', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get(`context-external-tools/${new ObjectId().toHexString()}`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user has no access to a tool', () => { + const setup = async () => { + const schoolWithoutTool: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId(); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school: schoolWithoutTool }); + const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalToolEntity, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + }); + + await em.persistAndFlush([ + school, + adminAccount, + adminUser, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + contextExternalToolId: contextExternalToolEntity.id, + }; + }; + + it('should filter out the tool', async () => { + const { loggedInClient, contextExternalToolId } = await setup(); + + const response: Response = await loggedInClient.get(`context-external-tools/${contextExternalToolId}`); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has access for a tool', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + logoBase64: 'logoBase64', + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalToolEntity, + toolVersion: externalToolEntity.version, + }); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + schoolTool: schoolExternalToolEntity, + contextId: course.id, + contextType: ContextExternalToolType.COURSE, + displayName: 'This is a test tool', + toolVersion: schoolExternalToolEntity.toolVersion, + }); + + await em.persistAndFlush([ + school, + adminAccount, + adminUser, + course, + externalToolEntity, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + contextExternalToolId: contextExternalToolEntity.id, + contextExternalToolEntity, + externalToolEntity, + }; + }; + + it('should return an ToolReferenceListResponse with data', async () => { + const { loggedInClient, contextExternalToolId, contextExternalToolEntity, externalToolEntity } = await setup(); + + const response: Response = await loggedInClient.get(`context-external-tools/${contextExternalToolId}`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + contextToolId: contextExternalToolEntity.id, + displayName: contextExternalToolEntity.displayName as string, + status: ToolConfigurationStatusResponse.LATEST, + logoUrl: `http://localhost:3030/api/v3/tools/external-tools/${externalToolEntity.id}/logo`, + openInNewTab: externalToolEntity.openNewTab, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts index 7d20deef026..63850daa22b 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/context-external-tool-context.params.ts @@ -8,6 +8,12 @@ export class ContextExternalToolContextParams { contextId!: string; @IsEnum(ToolContextType) - @ApiProperty({ nullable: false, required: true, example: ToolContextType.COURSE }) + @ApiProperty({ + enum: ToolContextType, + enumName: 'ToolContextType', + nullable: false, + required: true, + example: ToolContextType.COURSE, + }) contextType!: ToolContextType; } diff --git a/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts index dfe16d84244..e6da4bb909f 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/index.ts @@ -3,3 +3,6 @@ export * from './context-external-tool-id.params'; export * from './context-external-tool-search-list.response'; export * from './context-external-tool-context.params'; export * from './context-external-tool.response'; +export * from './tool-reference-list.response'; +export * from './tool-reference.response'; +export * from './tool-configuration-status.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-status.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-configuration-status.response.ts similarity index 100% rename from apps/server/src/modules/tool/external-tool/controller/dto/response/tool-configuration-status.response.ts rename to apps/server/src/modules/tool/context-external-tool/controller/dto/tool-configuration-status.response.ts diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference-list.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference-list.response.ts similarity index 100% rename from apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference-list.response.ts rename to apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference-list.response.ts diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts similarity index 96% rename from apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts rename to apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts index 24844d8bd2a..0ccefffa6ae 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-reference.response.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/dto/tool-reference.response.ts @@ -20,6 +20,7 @@ export class ToolReferenceResponse { @ApiProperty({ enum: ToolConfigurationStatusResponse, + enumName: 'ToolConfigurationStatusResponse', nullable: false, required: true, description: 'The status of the tool', diff --git a/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts new file mode 100644 index 00000000000..c414f4423de --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/controller/tool-reference.controller.ts @@ -0,0 +1,68 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiForbiddenResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; +import { ICurrentUser } from '@src/modules/authentication'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { ToolReference } from '../domain'; +import { ContextExternalToolResponseMapper } from '../mapper'; +import { ToolReferenceUc } from '../uc'; +import { + ContextExternalToolContextParams, + ContextExternalToolIdParams, + ToolReferenceListResponse, + ToolReferenceResponse, +} from './dto'; + +@ApiTags('Tool') +@Authenticate('jwt') +@Controller('tools/tool-references') +export class ToolReferenceController { + constructor(private readonly toolReferenceUc: ToolReferenceUc) {} + + @Get('context-external-tools/:contextExternalToolId') + @ApiOperation({ summary: 'Get ExternalTool Reference for a given context external tool' }) + @ApiOkResponse({ + description: 'The Tool Reference has been successfully fetched.', + type: ToolReferenceResponse, + }) + @ApiForbiddenResponse({ description: 'User is not allowed to access this resource.' }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + async getToolReference( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ContextExternalToolIdParams + ): Promise { + const toolReference: ToolReference = await this.toolReferenceUc.getToolReference( + currentUser.userId, + params.contextExternalToolId + ); + + const toolReferenceResponse: ToolReferenceResponse = + ContextExternalToolResponseMapper.mapToToolReferenceResponse(toolReference); + + return toolReferenceResponse; + } + + @Get('/:contextType/:contextId') + @ApiOperation({ summary: 'Get ExternalTool References for a given context' }) + @ApiOkResponse({ + description: 'The Tool References has been successfully fetched.', + type: ToolReferenceListResponse, + }) + @ApiForbiddenResponse({ description: 'User is not allowed to access this resource.' }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + async getToolReferencesForContext( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ContextExternalToolContextParams + ): Promise { + const toolReferences: ToolReference[] = await this.toolReferenceUc.getToolReferencesForContext( + currentUser.userId, + params.contextType, + params.contextId + ); + + const toolReferenceResponses: ToolReferenceResponse[] = + ContextExternalToolResponseMapper.mapToToolReferenceResponses(toolReferences); + const toolReferenceListResponse = new ToolReferenceListResponse(toolReferenceResponses); + + return toolReferenceListResponse; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/domain/index.ts b/apps/server/src/modules/tool/context-external-tool/domain/index.ts index a012e1d4002..557bc04788c 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/index.ts @@ -1,2 +1,3 @@ export * from './context-external-tool.do'; export * from './context-ref'; +export * from './tool-reference'; diff --git a/apps/server/src/modules/tool/external-tool/domain/tool-reference.ts b/apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts similarity index 100% rename from apps/server/src/modules/tool/external-tool/domain/tool-reference.ts rename to apps/server/src/modules/tool/context-external-tool/domain/tool-reference.ts diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool-type.enum.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool-type.enum.ts index 4d40ca6e84c..56753c354dc 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool-type.enum.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool-type.enum.ts @@ -1,3 +1,4 @@ export enum ContextExternalToolType { COURSE = 'course', + BOARD_ELEMENT = 'boardElement', } diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts index 601c960299d..07610a7a508 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/context-external-tool-response.mapper.ts @@ -1,6 +1,7 @@ +import { ToolStatusResponseMapper } from '../../common/mapper/tool-status-response.mapper'; import { CustomParameterEntryParam, CustomParameterEntryResponse } from '../../school-external-tool/controller/dto'; -import { ContextExternalToolResponse } from '../controller/dto'; -import { ContextExternalTool } from '../domain'; +import { ContextExternalToolResponse, ToolReferenceResponse } from '../controller/dto'; +import { ContextExternalTool, ToolReference } from '../domain'; export class ContextExternalToolResponseMapper { static mapContextExternalToolResponse(contextExternalTool: ContextExternalTool): ContextExternalToolResponse { @@ -33,4 +34,24 @@ export class ContextExternalToolResponseMapper { return mapped; } + + static mapToToolReferenceResponses(toolReferences: ToolReference[]): ToolReferenceResponse[] { + const toolReferenceResponses: ToolReferenceResponse[] = toolReferences.map((toolReference: ToolReference) => + this.mapToToolReferenceResponse(toolReference) + ); + + return toolReferenceResponses; + } + + static mapToToolReferenceResponse(toolReference: ToolReference): ToolReferenceResponse { + const response = new ToolReferenceResponse({ + contextToolId: toolReference.contextToolId, + displayName: toolReference.displayName, + logoUrl: toolReference.logoUrl, + openInNewTab: toolReference.openInNewTab, + status: ToolStatusResponseMapper.mapToResponse(toolReference.status), + }); + + return response; + } } diff --git a/apps/server/src/modules/tool/context-external-tool/mapper/index.ts b/apps/server/src/modules/tool/context-external-tool/mapper/index.ts index 1987491c4e0..427f02a713a 100644 --- a/apps/server/src/modules/tool/context-external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/index.ts @@ -1,2 +1,3 @@ export * from './context-external-tool-request.mapper'; export * from './context-external-tool-response.mapper'; +export * from './tool-reference.mapper'; diff --git a/apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts b/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts similarity index 80% rename from apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts rename to apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts index ec982467578..be6e6b8ab12 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/tool-reference.mapper.ts +++ b/apps/server/src/modules/tool/context-external-tool/mapper/tool-reference.mapper.ts @@ -1,6 +1,6 @@ -import { ExternalTool, ToolReference } from '../domain'; -import { ContextExternalTool } from '../../context-external-tool/domain'; import { ToolConfigurationStatus } from '../../common/enum'; +import { ExternalTool } from '../../external-tool/domain'; +import { ContextExternalTool, ToolReference } from '../domain'; export class ToolReferenceMapper { static mapToToolReference( diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts index c419154c020..41e849b3d79 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.spec.ts @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ValidationError } from '@mikro-orm/core'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { contextExternalToolFactory, externalToolFactory } from '@shared/testing'; -import { ValidationError } from '@mikro-orm/core'; import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; @@ -62,7 +62,7 @@ describe('ContextExternalToolValidationService', () => { describe('when no tool with the name exists in the context', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ displayName: 'Tool 1', @@ -93,9 +93,7 @@ describe('ContextExternalToolValidationService', () => { await service.validate(contextExternalTool); - expect(schoolExternalToolService.getSchoolExternalToolById).toBeCalledWith( - contextExternalTool.schoolToolRef.schoolToolId - ); + expect(schoolExternalToolService.findById).toBeCalledWith(contextExternalTool.schoolToolRef.schoolToolId); }); it('should call commonToolValidationService.checkCustomParameterEntries', async () => { diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts index af6d36840f7..3777273d18e 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool-validation.service.ts @@ -23,13 +23,11 @@ export class ContextExternalToolValidationService { await this.checkDuplicateInContext(contextExternalTool); - const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + const loadedSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId ); - const loadedExternalTool: ExternalTool = await this.externalToolService.findExternalToolById( - loadedSchoolExternalTool.toolId - ); + const loadedExternalTool: ExternalTool = await this.externalToolService.findById(loadedSchoolExternalTool.toolId); this.commonToolValidationService.checkCustomParameterEntries(loadedExternalTool, contextExternalTool); } diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts index 28cb093ae2d..819e2895896 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.spec.ts @@ -130,7 +130,7 @@ describe('ContextExternalToolService', () => { }); }); - describe('getContextExternalToolById', () => { + describe('findById', () => { describe('when contextExternalToolId is given', () => { const setup = () => { const schoolId: string = legacySchoolDoFactory.buildWithId().id as string; @@ -151,7 +151,7 @@ describe('ContextExternalToolService', () => { it('should return a contextExternalTool', async () => { const { contextExternalTool } = setup(); - const result: ContextExternalTool = await service.getContextExternalToolById(contextExternalTool.id as string); + const result: ContextExternalTool = await service.findById(contextExternalTool.id as string); expect(result).toEqual(contextExternalTool); }); @@ -165,7 +165,7 @@ describe('ContextExternalToolService', () => { it('should throw a not found exception', async () => { setup(); - const func = () => service.getContextExternalToolById('unknownContextExternalToolId'); + const func = () => service.findById('unknownContextExternalToolId'); await expect(func()).rejects.toThrow(NotFoundException); }); diff --git a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts index 011e6db2f7a..63618191810 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/context-external-tool.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { ContextExternalToolRepo } from '@shared/repo'; -import { ContextExternalToolQuery } from '../uc/dto/context-external-tool.types'; import { ContextExternalTool, ContextRef } from '../domain'; +import { ContextExternalToolQuery } from '../uc/dto/context-external-tool.types'; @Injectable() export class ContextExternalToolService { @@ -14,7 +14,7 @@ export class ContextExternalToolService { return contextExternalTools; } - async getContextExternalToolById(contextExternalToolId: EntityId): Promise { + async findById(contextExternalToolId: EntityId): Promise { const tool: ContextExternalTool = await this.contextExternalToolRepo.findById(contextExternalToolId); return tool; diff --git a/apps/server/src/modules/tool/context-external-tool/service/index.ts b/apps/server/src/modules/tool/context-external-tool/service/index.ts index 887cfbe7d9d..31fedbe42af 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/index.ts @@ -1,3 +1,4 @@ export * from './context-external-tool.service'; export * from './context-external-tool-validation.service'; export * from './context-external-tool-authorizable.service'; +export * from './tool-reference.service'; diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts new file mode 100644 index 00000000000..e434ab49527 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.spec.ts @@ -0,0 +1,132 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; +import { ToolConfigurationStatus } from '../../common/enum'; +import { CommonToolService } from '../../common/service'; +import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ToolReference } from '../domain'; +import { ContextExternalToolService } from './context-external-tool.service'; +import { ToolReferenceService } from './tool-reference.service'; + +describe('ToolReferenceService', () => { + let module: TestingModule; + let service: ToolReferenceService; + + let externalToolService: DeepMocked; + let schoolExternalToolService: DeepMocked; + let contextExternalToolService: DeepMocked; + let commonToolService: DeepMocked; + let externalToolLogoService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ToolReferenceService, + { + provide: ExternalToolService, + useValue: createMock(), + }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + { + provide: CommonToolService, + useValue: createMock(), + }, + { + provide: ExternalToolLogoService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ToolReferenceService); + externalToolService = module.get(ExternalToolService); + schoolExternalToolService = module.get(SchoolExternalToolService); + contextExternalToolService = module.get(ContextExternalToolService); + commonToolService = module.get(CommonToolService); + externalToolLogoService = module.get(ExternalToolLogoService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getToolReference', () => { + describe('when a context external tool id is provided', () => { + const setup = () => { + const contextExternalToolId = new ObjectId().toHexString(); + const externalTool = externalToolFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id as string, + }); + const contextExternalTool = contextExternalToolFactory + .withSchoolExternalToolRef(schoolExternalTool.id as string) + .buildWithId(undefined, contextExternalToolId); + const logoUrl = 'logoUrl'; + + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + commonToolService.determineToolConfigurationStatus.mockReturnValue(ToolConfigurationStatus.OUTDATED); + externalToolLogoService.buildLogoUrl.mockReturnValue(logoUrl); + + return { + contextExternalToolId, + externalTool, + schoolExternalTool, + contextExternalTool, + logoUrl, + }; + }; + + it('should determine the tool status', async () => { + const { contextExternalToolId, externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.getToolReference(contextExternalToolId); + + expect(commonToolService.determineToolConfigurationStatus).toHaveBeenCalledWith( + externalTool, + schoolExternalTool, + contextExternalTool + ); + }); + + it('should build the logo url', async () => { + const { contextExternalToolId, externalTool } = setup(); + + await service.getToolReference(contextExternalToolId); + + expect(externalToolLogoService.buildLogoUrl).toHaveBeenCalledWith( + '/v3/tools/external-tools/{id}/logo', + externalTool + ); + }); + + it('should return the tool reference', async () => { + const { contextExternalToolId, logoUrl, contextExternalTool, externalTool } = setup(); + + const result: ToolReference = await service.getToolReference(contextExternalToolId); + + expect(result).toEqual({ + logoUrl, + displayName: contextExternalTool.displayName as string, + openInNewTab: externalTool.openNewTab, + status: ToolConfigurationStatus.OUTDATED, + contextToolId: contextExternalToolId, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts new file mode 100644 index 00000000000..02c6a08677e --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-reference.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ToolConfigurationStatus } from '../../common/enum'; +import { CommonToolService } from '../../common/service'; +import { ExternalTool } from '../../external-tool/domain'; +import { ExternalToolLogoService, ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ContextExternalTool, ToolReference } from '../domain'; +import { ToolReferenceMapper } from '../mapper'; +import { ContextExternalToolService } from './context-external-tool.service'; + +@Injectable() +export class ToolReferenceService { + constructor( + private readonly externalToolService: ExternalToolService, + private readonly schoolExternalToolService: SchoolExternalToolService, + private readonly contextExternalToolService: ContextExternalToolService, + private readonly commonToolService: CommonToolService, + private readonly externalToolLogoService: ExternalToolLogoService + ) {} + + async getToolReference(contextExternalToolId: EntityId): Promise { + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findById( + contextExternalToolId + ); + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( + contextExternalTool.schoolToolRef.schoolToolId + ); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); + + const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( + externalTool, + schoolExternalTool, + contextExternalTool + ); + + const toolReference: ToolReference = ToolReferenceMapper.mapToToolReference( + externalTool, + contextExternalTool, + status + ); + toolReference.logoUrl = this.externalToolLogoService.buildLogoUrl( + '/v3/tools/external-tools/{id}/logo', + externalTool + ); + + return toolReference; + } +} diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index 7dcdea9d16b..801765f80e5 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -8,10 +8,10 @@ import { LegacyLogger } from '@src/core/logger'; import { Action, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { ForbiddenLoggableException } from '@src/modules/authorization/errors/forbidden.loggable-exception'; import { ToolContextType } from '../../common/enum'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../domain'; import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; import { ContextExternalToolUc } from './context-external-tool.uc'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ContextExternalToolUc', () => { let module: TestingModule; @@ -339,7 +339,7 @@ describe('ContextExternalToolUc', () => { const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); + contextExternalToolService.findById.mockResolvedValue(contextExternalTool); return { contextExternalTool, @@ -496,7 +496,7 @@ describe('ContextExternalToolUc', () => { }, }); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); + contextExternalToolService.findById.mockResolvedValue(contextExternalTool); toolPermissionHelper.ensureContextPermissions.mockResolvedValue(Promise.resolve()); return { @@ -524,7 +524,7 @@ describe('ContextExternalToolUc', () => { await uc.getContextExternalTool(userId, contextExternalTool.id as string); - expect(contextExternalToolService.getContextExternalToolById).toHaveBeenCalledWith(contextExternalTool.id); + expect(contextExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.id); }); }); @@ -542,7 +542,7 @@ describe('ContextExternalToolUc', () => { }, }); - contextExternalToolService.getContextExternalToolById.mockResolvedValue(contextExternalTool); + contextExternalToolService.findById.mockResolvedValue(contextExternalTool); toolPermissionHelper.ensureContextPermissions.mockRejectedValue( new ForbiddenLoggableException( userId, diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index 903b8197251..04002cf9fc6 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { EntityId, Permission, User } from '@shared/domain'; -import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { LegacyLogger } from '@src/core/logger'; -import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; -import { ContextExternalToolDto } from './dto/context-external-tool.types'; -import { ContextExternalTool, ContextRef } from '../domain'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ContextExternalTool, ContextRef } from '../domain'; +import { ContextExternalToolService, ContextExternalToolValidationService } from '../service'; +import { ContextExternalToolDto } from './dto/context-external-tool.types'; @Injectable() export class ContextExternalToolUc { @@ -62,9 +62,7 @@ export class ContextExternalToolUc { } async deleteContextExternalTool(userId: EntityId, contextExternalToolId: EntityId): Promise { - const tool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById( - contextExternalToolId - ); + const tool: ContextExternalTool = await this.contextExternalToolService.findById(contextExternalToolId); const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); @@ -85,7 +83,7 @@ export class ContextExternalToolUc { } async getContextExternalTool(userId: EntityId, contextToolId: EntityId) { - const tool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById(contextToolId); + const tool: ContextExternalTool = await this.contextExternalToolService.findById(contextToolId); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/index.ts b/apps/server/src/modules/tool/context-external-tool/uc/index.ts index cd34b162bad..12f2a82a9f1 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/index.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/index.ts @@ -1 +1,2 @@ export * from './context-external-tool.uc'; +export * from './tool-reference.uc'; diff --git a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts new file mode 100644 index 00000000000..e0fbdcf6a6a --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts @@ -0,0 +1,215 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain'; +import { contextExternalToolFactory, externalToolFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; +import { ToolConfigurationStatus, ToolContextType } from '../../common/enum'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ExternalTool } from '../../external-tool/domain'; +import { ContextExternalTool, ToolReference } from '../domain'; +import { ContextExternalToolService, ToolReferenceService } from '../service'; +import { ToolReferenceUc } from './tool-reference.uc'; + +describe('ToolReferenceUc', () => { + let module: TestingModule; + let uc: ToolReferenceUc; + + let contextExternalToolService: DeepMocked; + let toolReferenceService: DeepMocked; + let toolPermissionHelper: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ToolReferenceUc, + { + provide: ContextExternalToolService, + useValue: createMock(), + }, + { + provide: ToolReferenceService, + useValue: createMock(), + }, + { + provide: ToolPermissionHelper, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(ToolReferenceUc); + + contextExternalToolService = module.get(ContextExternalToolService); + toolReferenceService = module.get(ToolReferenceService); + toolPermissionHelper = module.get(ToolPermissionHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('getToolReferencesForContext', () => { + describe('when called with a context type and id', () => { + const setup = () => { + const userId = 'userId'; + + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + const toolReference: ToolReference = new ToolReference({ + logoUrl: externalTool.logoUrl, + contextToolId: contextExternalTool.id as string, + displayName: contextExternalTool.displayName as string, + status: ToolConfigurationStatus.LATEST, + openInNewTab: externalTool.openNewTab, + }); + + const contextType: ToolContextType = ToolContextType.COURSE; + const contextId = 'contextId'; + + contextExternalToolService.findAllByContext.mockResolvedValueOnce([contextExternalTool]); + toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); + toolReferenceService.getToolReference.mockResolvedValue(toolReference); + + return { + userId, + contextType, + contextId, + contextExternalTool, + externalTool, + toolReference, + }; + }; + + it('should call toolPermissionHelper.ensureContextPermissions', async () => { + const { userId, contextType, contextId, contextExternalTool } = setup(); + + await uc.getToolReferencesForContext(userId, contextType, contextId); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]) + ); + }); + + it('should return a list of tool references', async () => { + const { userId, contextType, contextId, toolReference } = setup(); + + const result: ToolReference[] = await uc.getToolReferencesForContext(userId, contextType, contextId); + + expect(result).toEqual([toolReference]); + }); + }); + + describe('when user does not have permission to a tool', () => { + const setup = () => { + const userId = 'userId'; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + + const contextType: ToolContextType = ToolContextType.COURSE; + const contextId = 'contextId'; + + contextExternalToolService.findAllByContext.mockResolvedValueOnce([contextExternalTool]); + toolPermissionHelper.ensureContextPermissions.mockRejectedValueOnce(new ForbiddenException()); + + return { + userId, + contextType, + contextId, + }; + }; + + it('should filter out tool references if a ForbiddenException is thrown', async () => { + const { userId, contextType, contextId } = setup(); + + const result: ToolReference[] = await uc.getToolReferencesForContext(userId, contextType, contextId); + + expect(result).toEqual([]); + }); + }); + }); + + describe('getToolReference', () => { + describe('when called with a context type and id', () => { + const setup = () => { + const userId = 'userId'; + const contextExternalToolId = 'contextExternalToolId'; + + const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + undefined, + contextExternalToolId + ); + const toolReference: ToolReference = new ToolReference({ + logoUrl: externalTool.logoUrl, + contextToolId: contextExternalTool.id as string, + displayName: contextExternalTool.displayName as string, + status: ToolConfigurationStatus.LATEST, + openInNewTab: externalTool.openNewTab, + }); + + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); + toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); + toolReferenceService.getToolReference.mockResolvedValue(toolReference); + + return { + userId, + contextExternalTool, + externalTool, + toolReference, + contextExternalToolId, + }; + }; + + it('should call toolPermissionHelper.ensureContextPermissions', async () => { + const { userId, contextExternalToolId, contextExternalTool } = setup(); + + await uc.getToolReference(userId, contextExternalToolId); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + userId, + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]) + ); + }); + + it('should return a list of tool references', async () => { + const { userId, contextExternalToolId, toolReference } = setup(); + + const result: ToolReference = await uc.getToolReference(userId, contextExternalToolId); + + expect(result).toEqual(toolReference); + }); + }); + + describe('when user does not have permission to a tool', () => { + const setup = () => { + const userId = 'userId'; + const contextExternalToolId = 'contextExternalToolId'; + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + undefined, + contextExternalToolId + ); + const error = new ForbiddenException(); + + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); + toolPermissionHelper.ensureContextPermissions.mockRejectedValueOnce(error); + + return { + userId, + contextExternalToolId, + error, + }; + }; + + it('should filter out tool references if a ForbiddenException is thrown', async () => { + const { userId, contextExternalToolId, error } = setup(); + + await expect(uc.getToolReference(userId, contextExternalToolId)).rejects.toThrow(error); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts new file mode 100644 index 00000000000..c044e01dfeb --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId, Permission } from '@shared/domain'; +import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; +import { ToolContextType } from '../../common/enum'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ContextExternalTool, ContextRef, ToolReference } from '../domain'; +import { ContextExternalToolService, ToolReferenceService } from '../service'; + +@Injectable() +export class ToolReferenceUc { + constructor( + private readonly contextExternalToolService: ContextExternalToolService, + private readonly toolReferenceService: ToolReferenceService, + private readonly toolPermissionHelper: ToolPermissionHelper + ) {} + + async getToolReferencesForContext( + userId: EntityId, + contextType: ToolContextType, + contextId: EntityId + ): Promise { + const contextRef = new ContextRef({ type: contextType, id: contextId }); + + const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( + contextRef + ); + + const toolReferencesPromises: Promise[] = contextExternalTools.map( + async (contextExternalTool: ContextExternalTool) => this.tryBuildToolReference(userId, contextExternalTool) + ); + + const toolReferencesWithNull: (ToolReference | null)[] = await Promise.all(toolReferencesPromises); + const filteredToolReferences: ToolReference[] = toolReferencesWithNull.filter( + (toolReference: ToolReference | null): toolReference is ToolReference => toolReference !== null + ); + + return filteredToolReferences; + } + + private async tryBuildToolReference( + userId: EntityId, + contextExternalTool: ContextExternalTool + ): Promise { + try { + await this.ensureToolPermissions(userId, contextExternalTool); + + const toolReference: ToolReference = await this.toolReferenceService.getToolReference( + contextExternalTool.id as string + ); + + return toolReference; + } catch (e: unknown) { + return null; + } + } + + async getToolReference(userId: EntityId, contextExternalToolId: EntityId): Promise { + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findById( + contextExternalToolId + ); + + await this.ensureToolPermissions(userId, contextExternalTool); + + const toolReference: ToolReference = await this.toolReferenceService.getToolReference( + contextExternalTool.id as string + ); + + return toolReference; + } + + private async ensureToolPermissions(userId: EntityId, contextExternalTool: ContextExternalTool): Promise { + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + const promise: Promise = this.toolPermissionHelper.ensureContextPermissions( + userId, + contextExternalTool, + context + ); + + return promise; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts index 6498f66fc1f..0d0c3fb08dc 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts @@ -1,41 +1,27 @@ +import { Loaded } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Course, Permission, SchoolEntity } from '@shared/domain'; +import { Permission } from '@shared/domain'; import { cleanupCollections, - contextExternalToolEntityFactory, - courseFactory, externalToolEntityFactory, externalToolFactory, - schoolExternalToolEntityFactory, - schoolFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { Response } from 'supertest'; -import { Loaded } from '@mikro-orm/core'; -import { ServerTestModule } from '@src/modules/server'; import { CustomParameterLocationParams, CustomParameterScopeTypeParams, CustomParameterTypeParams, ToolConfigType, - ToolContextType, } from '../../../common/enum'; -import { - ExternalToolCreateParams, - ExternalToolResponse, - ExternalToolSearchListResponse, - ToolConfigurationStatusResponse, - ToolReferenceListResponse, -} from '../dto'; -import { ContextExternalToolContextParams } from '../../../context-external-tool/controller/dto'; import { ExternalToolEntity } from '../../entity'; -import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; -import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { ExternalToolCreateParams, ExternalToolResponse, ExternalToolSearchListResponse } from '../dto'; describe('ToolController (API)', () => { let app: INestApplication; @@ -597,126 +583,6 @@ describe('ToolController (API)', () => { }); }); - describe('[GET] tools/external-tools/:contextType/:contextId/references', () => { - describe('when user is not authenticated', () => { - it('should return unauthorized', async () => { - const response: Response = await testApiClient.get(`contextType/${new ObjectId().toHexString()}/references`); - - expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); - }); - }); - - describe('when user has no access to a tool', () => { - const setup = async () => { - const schoolWithoutTool: SchoolEntity = schoolFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId(); - const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school: schoolWithoutTool }); - const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ - school, - tool: externalToolEntity, - }); - const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ - schoolTool: schoolExternalToolEntity, - contextId: course.id, - contextType: ContextExternalToolType.COURSE, - }); - - await em.persistAndFlush([ - school, - adminAccount, - adminUser, - course, - externalToolEntity, - schoolExternalToolEntity, - contextExternalToolEntity, - ]); - em.clear(); - - const params: ContextExternalToolContextParams = { - contextId: course.id, - contextType: ToolContextType.COURSE, - }; - - const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); - - return { loggedInClient, params }; - }; - - it('should filter out the tool', async () => { - const { loggedInClient, params } = await setup(); - - const response: Response = await loggedInClient.get(`${params.contextType}/${params.contextId}/references`); - - expect(response.statusCode).toEqual(HttpStatus.OK); - expect(response.body).toEqual({ data: [] }); - }); - }); - - describe('when user has access for a tool', () => { - const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); - const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ - Permission.CONTEXT_TOOL_USER, - ]); - const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ logoUrl: undefined }); - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ - school, - tool: externalToolEntity, - toolVersion: externalToolEntity.version, - }); - const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ - schoolTool: schoolExternalToolEntity, - contextId: course.id, - contextType: ContextExternalToolType.COURSE, - displayName: 'This is a test tool', - toolVersion: schoolExternalToolEntity.toolVersion, - }); - - await em.persistAndFlush([ - school, - adminAccount, - adminUser, - course, - externalToolEntity, - schoolExternalToolEntity, - contextExternalToolEntity, - ]); - em.clear(); - - const params: ContextExternalToolContextParams = { - contextId: course.id, - contextType: ToolContextType.COURSE, - }; - - const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); - - return { loggedInClient, params, contextExternalToolEntity, externalToolEntity }; - }; - - it('should return an ToolReferenceListResponse with data', async () => { - const { loggedInClient, params, contextExternalToolEntity, externalToolEntity } = await setup(); - - const response: Response = await loggedInClient.get(`${params.contextType}/${params.contextId}/references`); - - expect(response.statusCode).toEqual(HttpStatus.OK); - expect(response.body).toEqual({ - data: [ - { - contextToolId: contextExternalToolEntity.id, - displayName: contextExternalToolEntity.displayName as string, - status: ToolConfigurationStatusResponse.LATEST, - logoUrl: externalToolEntity.logoUrl, - openInNewTab: externalToolEntity.openNewTab, - }, - ], - }); - }); - }); - }); - describe('[GET] tools/external-tools/:externalToolId/logo', () => { const setup = async () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.withBase64Logo().buildWithId(); diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts index fbae39a8b33..e9e5fafa376 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts @@ -1,10 +1,7 @@ export * from './config'; export * from './external-tool.response'; -export * from './tool-reference.response'; export * from './custom-parameter.response'; -export * from './tool-reference-list.response'; export * from './external-tool-search-list.response'; -export * from './tool-configuration-status.response'; export * from './context-external-tool-configuration-template.response'; export * from './context-external-tool-configuration-template-list.response'; export * from './school-external-tool-configuration-template.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts index 3e6ac38fedc..4c3658d8025 100644 --- a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts @@ -18,23 +18,20 @@ import { ICurrentUser } from '@src/modules/authentication'; import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; import { Response } from 'express'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ContextExternalToolContextParams } from '../../context-external-tool/controller/dto'; -import { ExternalTool, ToolReference } from '../domain'; +import { ExternalTool } from '../domain'; import { ExternalToolLogo } from '../domain/external-tool-logo'; import { ExternalToolRequestMapper, ExternalToolResponseMapper } from '../mapper'; -import { ExternalToolCreate, ExternalToolUc, ExternalToolUpdate, ToolReferenceUc } from '../uc'; +import { ExternalToolLogoService } from '../service'; +import { ExternalToolCreate, ExternalToolUc, ExternalToolUpdate } from '../uc'; import { ExternalToolCreateParams, + ExternalToolIdParams, ExternalToolResponse, ExternalToolSearchListResponse, ExternalToolSearchParams, ExternalToolUpdateParams, SortExternalToolParams, - ExternalToolIdParams, - ToolReferenceListResponse, - ToolReferenceResponse, } from './dto'; -import { ExternalToolLogoService } from '../service'; @ApiTags('Tool') @Authenticate('jwt') @@ -43,7 +40,6 @@ export class ToolController { constructor( private readonly externalToolUc: ExternalToolUc, private readonly externalToolDOMapper: ExternalToolRequestMapper, - private readonly toolReferenceUc: ToolReferenceUc, private readonly logger: LegacyLogger, private readonly externalToolLogoService: ExternalToolLogoService ) {} @@ -156,32 +152,6 @@ export class ToolController { return promise; } - @Get('/:contextType/:contextId/references') - @ApiOperation({ summary: 'Get ExternalTool References for a given context' }) - @ApiOkResponse({ - description: 'The Tool References has been successfully fetched.', - type: ToolReferenceListResponse, - }) - @ApiForbiddenResponse({ description: 'User is not allowed to access this resource.' }) - @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) - async getToolReferences( - @CurrentUser() currentUser: ICurrentUser, - @Param() params: ContextExternalToolContextParams - ): Promise { - const toolReferences: ToolReference[] = await this.toolReferenceUc.getToolReferences( - currentUser.userId, - params.contextType, - params.contextId, - '/v3/tools/external-tools/{id}/logo' - ); - - const toolReferenceResponses: ToolReferenceResponse[] = - ExternalToolResponseMapper.mapToToolReferenceResponses(toolReferences); - const toolReferenceListResponse = new ToolReferenceListResponse(toolReferenceResponses); - - return toolReferenceListResponse; - } - @Get('/:externalToolId/logo') @ApiOperation({ summary: 'Gets the logo of an external tool.' }) @ApiOkResponse({ diff --git a/apps/server/src/modules/tool/external-tool/domain/index.ts b/apps/server/src/modules/tool/external-tool/domain/index.ts index 9eaf1f03cbb..e5a1dab735d 100644 --- a/apps/server/src/modules/tool/external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/external-tool/domain/index.ts @@ -1,3 +1,2 @@ export * from './external-tool.do'; export * from './config'; -export * from './tool-reference'; diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts index 2885c2ea0c0..b2035e66477 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts @@ -8,16 +8,14 @@ import { CustomParameterType, CustomParameterTypeParams, } from '../../common/enum'; -import { statusMapping } from '../../school-external-tool/mapper'; import { BasicToolConfigResponse, CustomParameterResponse, ExternalToolResponse, Lti11ToolConfigResponse, Oauth2ToolConfigResponse, - ToolReferenceResponse, } from '../controller/dto'; -import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig, ToolReference } from '../domain'; +import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; const scopeMapping: Record = { [CustomParameterScope.GLOBAL]: CustomParameterScopeTypeParams.GLOBAL, @@ -98,24 +96,4 @@ export class ExternalToolResponseMapper { }; }); } - - static mapToToolReferenceResponses(toolReferences: ToolReference[]): ToolReferenceResponse[] { - const toolReferenceResponses: ToolReferenceResponse[] = toolReferences.map((toolReference: ToolReference) => - this.mapToToolReferenceResponse(toolReference) - ); - - return toolReferenceResponses; - } - - private static mapToToolReferenceResponse(toolReference: ToolReference): ToolReferenceResponse { - const response = new ToolReferenceResponse({ - contextToolId: toolReference.contextToolId, - displayName: toolReference.displayName, - logoUrl: toolReference.logoUrl, - openInNewTab: toolReference.openInNewTab, - status: statusMapping[toolReference.status], - }); - - return response; - } } diff --git a/apps/server/src/modules/tool/external-tool/mapper/index.ts b/apps/server/src/modules/tool/external-tool/mapper/index.ts index 92aff5e73c9..4149a17a519 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/index.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/index.ts @@ -1,3 +1,2 @@ -export * from './tool-reference.mapper'; export * from './external-tool-request.mapper'; export * from './external-tool-response.mapper'; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts index 57acd50122f..c53154098a5 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-logo-service.spec.ts @@ -1,4 +1,4 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { HttpException, HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -9,8 +9,8 @@ import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ExternalToolLogo } from '../domain/external-tool-logo'; import { - ExternalToolLogoFetchFailedLoggableException, ExternalToolLogoFetchedLoggable, + ExternalToolLogoFetchFailedLoggableException, ExternalToolLogoNotFoundLoggableException, ExternalToolLogoSizeExceededLoggableException, ExternalToolLogoWrongFileTypeLoggableException, @@ -329,7 +329,7 @@ describe('ExternalToolLogoService', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); return { externalToolId: externalTool.id as string, @@ -355,7 +355,7 @@ describe('ExternalToolLogoService', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.buildWithId({ logo: 'notAValidBase64File' }); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); return { externalToolId: externalTool.id as string, @@ -375,7 +375,7 @@ describe('ExternalToolLogoService', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); return { externalToolId: externalTool.id as string, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts index f2518e65a3a..b39684fbd1b 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-logo.service.ts @@ -1,19 +1,19 @@ +import { HttpService } from '@nestjs/axios'; import { HttpException, Inject } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { Logger } from '@src/core/logger'; import { AxiosResponse } from 'axios'; import { lastValueFrom } from 'rxjs'; -import { HttpService } from '@nestjs/axios'; -import { Logger } from '@src/core/logger'; -import { EntityId } from '@shared/domain'; +import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; +import { ExternalToolLogo } from '../domain/external-tool-logo'; import { ExternalToolLogoFetchedLoggable, + ExternalToolLogoFetchFailedLoggableException, ExternalToolLogoNotFoundLoggableException, ExternalToolLogoSizeExceededLoggableException, ExternalToolLogoWrongFileTypeLoggableException, - ExternalToolLogoFetchFailedLoggableException, } from '../loggable'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; -import { ExternalToolLogo } from '../domain/external-tool-logo'; import { ExternalToolService } from './external-tool.service'; const contentTypeDetector: Record = { @@ -95,7 +95,7 @@ export class ExternalToolLogoService { } async getExternalToolBinaryLogo(toolId: EntityId): Promise { - const tool: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + const tool: ExternalTool = await this.externalToolService.findById(toolId); if (!tool.logo) { throw new ExternalToolLogoNotFoundLoggableException(toolId); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts index 8f5f1607df6..42efcd6559a 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.spec.ts @@ -4,10 +4,10 @@ import { ValidationError } from '@shared/common'; import { externalToolFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; +import { ExternalToolLogoService } from './external-tool-logo.service'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; import { ExternalToolValidationService } from './external-tool-validation.service'; import { ExternalToolService } from './external-tool.service'; -import { ExternalToolLogoService } from './external-tool-logo.service'; describe('ExternalToolValidationService', () => { let module: TestingModule; @@ -232,7 +232,7 @@ describe('ExternalToolValidationService', () => { .buildWithId(); externalOauthTool.id = 'toolId'; - externalToolService.findExternalToolById.mockResolvedValue(externalOauthTool); + externalToolService.findById.mockResolvedValue(externalOauthTool); return { externalOauthTool, @@ -266,7 +266,7 @@ describe('ExternalToolValidationService', () => { .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(existingExternalOauthTool); + externalToolService.findById.mockResolvedValue(existingExternalOauthTool); const newExternalTool: ExternalTool = externalToolFactory.buildWithId(); @@ -296,7 +296,7 @@ describe('ExternalToolValidationService', () => { .withOauth2Config({ clientId: 'ClientId', clientSecret: 'secret' }) .buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(externalOauthTool); + externalToolService.findById.mockResolvedValue(externalOauthTool); return { externalOauthTool }; }; @@ -318,7 +318,7 @@ describe('ExternalToolValidationService', () => { const existingExternalOauthToolDOWithDifferentClientId: ExternalTool = externalToolFactory .withOauth2Config({ clientId: 'DifferentClientId', clientSecret: 'secret' }) .buildWithId(); - externalToolService.findExternalToolById.mockResolvedValue(existingExternalOauthToolDOWithDifferentClientId); + externalToolService.findById.mockResolvedValue(existingExternalOauthToolDOWithDifferentClientId); return { externalOauthTool, @@ -344,7 +344,7 @@ describe('ExternalToolValidationService', () => { const externalLtiToolDO: ExternalTool = externalToolFactory.withLti11Config().buildWithId(); externalLtiToolDO.id = 'toolId'; - externalToolService.findExternalToolById.mockResolvedValue(externalLtiToolDO); + externalToolService.findById.mockResolvedValue(externalLtiToolDO); return { externalLtiToolDO, diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts index 90b8307dc7e..434e7fac86e 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts @@ -2,9 +2,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; +import { ExternalToolLogoService } from './external-tool-logo.service'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; import { ExternalToolService } from './external-tool.service'; -import { ExternalToolLogoService } from './external-tool-logo.service'; @Injectable() export class ExternalToolValidationService { @@ -32,7 +32,7 @@ export class ExternalToolValidationService { await this.externalToolParameterValidationService.validateCommon(externalTool); - const loadedTool: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + const loadedTool: ExternalTool = await this.externalToolService.findById(toolId); if ( ExternalTool.isOauth2Config(loadedTool.config) && externalTool.config && diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts index d2913e5401a..4db2a5be0b0 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts @@ -308,7 +308,7 @@ describe('ExternalToolService', () => { }); }); - describe('findExternalToolById', () => { + describe('findById', () => { describe('when external tool id is set', () => { const setup = () => { const { externalTool } = createTools(); @@ -320,7 +320,7 @@ describe('ExternalToolService', () => { it('should get domain object', async () => { const { externalTool } = setup(); - const result: ExternalTool = await service.findExternalToolById('toolId'); + const result: ExternalTool = await service.findById('toolId'); expect(result).toEqual(externalTool); }); @@ -340,7 +340,7 @@ describe('ExternalToolService', () => { it('should get domain object and add external oauth2 data', async () => { const { externalTool, oauth2ToolConfig } = setup(); - const result: ExternalTool = await service.findExternalToolById('toolId'); + const result: ExternalTool = await service.findById('toolId'); expect(result).toEqual({ ...externalTool, config: oauth2ToolConfig }); }); @@ -362,7 +362,7 @@ describe('ExternalToolService', () => { it('should throw UnprocessableEntityException ', async () => { const { externalTool } = setup(); - const func = () => service.findExternalToolById('toolId'); + const func = () => service.findById('toolId'); await expect(func()).rejects.toThrow(`Could not resolve oauth2Config of tool ${externalTool.name}.`); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts index 2a53f8aae45..fcc1a7e2d5c 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts @@ -75,7 +75,7 @@ export class ExternalToolService { return tools; } - async findExternalToolById(id: EntityId): Promise { + async findById(id: EntityId): Promise { const tool: ExternalTool = await this.externalToolRepo.findById(id); if (ExternalTool.isOauth2Config(tool.config)) { try { diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts index 5490f0c546b..0ed3a3317f8 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts @@ -12,14 +12,14 @@ import { } from '@shared/testing'; import { AuthorizationContextBuilder } from '@src/modules/authorization'; import { CustomParameterScope, ToolContextType } from '../../common/enum'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ExternalTool } from '../domain'; -import { ExternalToolLogoService, ExternalToolService, ExternalToolConfigurationService } from '../service'; +import { ExternalToolConfigurationService, ExternalToolLogoService, ExternalToolService } from '../service'; import { ExternalToolConfigurationUc } from './external-tool-configuration.uc'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ExternalToolConfigurationUc', () => { let module: TestingModule; @@ -439,8 +439,8 @@ describe('ExternalToolConfigurationUc', () => { schoolExternalToolId ); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); return { externalTool, @@ -478,7 +478,7 @@ describe('ExternalToolConfigurationUc', () => { schoolExternalToolId ); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); toolPermissionHelper.ensureSchoolPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -512,8 +512,8 @@ describe('ExternalToolConfigurationUc', () => { schoolExternalToolId ); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); return { schoolExternalToolId, @@ -553,9 +553,9 @@ describe('ExternalToolConfigurationUc', () => { contextExternalToolId ); - contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); return { externalTool, @@ -593,7 +593,7 @@ describe('ExternalToolConfigurationUc', () => { contextExternalToolId ); - contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); toolPermissionHelper.ensureContextPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -632,9 +632,9 @@ describe('ExternalToolConfigurationUc', () => { contextExternalToolId ); - contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); return { contextExternalToolId, diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts index 9607beb84df..16dd9626d0c 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts @@ -4,14 +4,14 @@ import { EntityId, Permission } from '@shared/domain'; import { Page } from '@shared/domain/domainobject/page'; import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; import { CustomParameterScope, ToolContextType } from '../../common/enum'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ExternalTool } from '../domain'; -import { ExternalToolLogoService, ExternalToolService, ExternalToolConfigurationService } from '../service'; +import { ExternalToolConfigurationService, ExternalToolLogoService, ExternalToolService } from '../service'; import { ContextExternalToolTemplateInfo } from './dto'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class ExternalToolConfigurationUc { @@ -117,14 +117,12 @@ export class ExternalToolConfigurationUc { userId: EntityId, schoolExternalToolId: EntityId ): Promise { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( - schoolExternalToolId - ); + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); - const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(schoolExternalTool.toolId); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); if (externalTool.isHidden) { throw new NotFoundException('Could not find the Tool Template'); @@ -139,18 +137,18 @@ export class ExternalToolConfigurationUc { userId: EntityId, contextExternalToolId: EntityId ): Promise { - const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById( + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findById( contextExternalToolId ); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId ); - const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(schoolExternalTool.toolId); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); if (externalTool.isHidden) { throw new NotFoundException('Could not find the Tool Template'); diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index d0b02f1e4f3..f2baadd885c 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -301,7 +301,7 @@ describe('ExternalToolUc', () => { it('should fetch a tool', async () => { const { currentUser } = setupAuthorization(); const { externalTool, toolId } = setup(); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); const result: ExternalTool = await uc.getExternalTool(currentUser.userId, toolId); @@ -327,7 +327,7 @@ describe('ExternalToolUc', () => { }); externalToolService.updateExternalTool.mockResolvedValue(updatedExternalToolDO); - externalToolService.findExternalToolById.mockResolvedValue(new ExternalTool(externalToolDOtoUpdate)); + externalToolService.findById.mockResolvedValue(new ExternalTool(externalToolDOtoUpdate)); return { externalTool, diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts index 240977b2b38..3fb81e4f74a 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts @@ -35,7 +35,7 @@ export class ExternalToolUc { await this.toolValidationService.validateUpdate(toolId, externalTool); - const loaded: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + const loaded: ExternalTool = await this.externalToolService.findById(toolId); const configToUpdate: ExternalToolConfig = { ...loaded.config, ...externalTool.config }; const toUpdate: ExternalTool = new ExternalTool({ ...loaded, @@ -63,7 +63,7 @@ export class ExternalToolUc { async getExternalTool(userId: EntityId, toolId: EntityId): Promise { await this.ensurePermission(userId, Permission.TOOL_ADMIN); - const tool: ExternalTool = await this.externalToolService.findExternalToolById(toolId); + const tool: ExternalTool = await this.externalToolService.findById(toolId); return tool; } diff --git a/apps/server/src/modules/tool/external-tool/uc/index.ts b/apps/server/src/modules/tool/external-tool/uc/index.ts index 46f3a860080..0a61273b29b 100644 --- a/apps/server/src/modules/tool/external-tool/uc/index.ts +++ b/apps/server/src/modules/tool/external-tool/uc/index.ts @@ -1,4 +1,3 @@ export * from './dto'; export * from './external-tool.uc'; -export * from './tool-reference.uc'; export * from './external-tool-configuration.uc'; diff --git a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts deleted file mode 100644 index e06c34e5e8b..00000000000 --- a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.spec.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { ForbiddenException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Permission } from '@shared/domain'; -import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; -import { AuthorizationContextBuilder } from '@src/modules/authorization'; -import { ToolReferenceUc } from './tool-reference.uc'; -import { ToolConfigurationStatus, ToolContextType } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ExternalTool, ToolReference } from '../domain'; -import { ExternalToolLogoService, ExternalToolService } from '../service'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; - -describe('ToolReferenceUc', () => { - let module: TestingModule; - let uc: ToolReferenceUc; - - let externalToolService: DeepMocked; - let schoolExternalToolService: DeepMocked; - let contextExternalToolService: DeepMocked; - let toolPermissionHelper: DeepMocked; - let commonToolService: DeepMocked; - let logoService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - ToolReferenceUc, - { - provide: ExternalToolService, - useValue: createMock(), - }, - { - provide: SchoolExternalToolService, - useValue: createMock(), - }, - { - provide: ContextExternalToolService, - useValue: createMock(), - }, - { - provide: CommonToolService, - useValue: createMock(), - }, - { - provide: ExternalToolLogoService, - useValue: createMock(), - }, - { - provide: ToolPermissionHelper, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(ToolReferenceUc); - - externalToolService = module.get(ExternalToolService); - schoolExternalToolService = module.get(SchoolExternalToolService); - contextExternalToolService = module.get(ContextExternalToolService); - toolPermissionHelper = module.get(ToolPermissionHelper); - commonToolService = module.get(CommonToolService); - logoService = module.get(ExternalToolLogoService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('getToolReferences', () => { - describe('when called with a context type and id', () => { - const setup = () => { - const userId = 'userId'; - - const externalTool: ExternalTool = externalToolFactory.withBase64Logo().buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - toolId: externalTool.id, - }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef('schoolToolId', 'schoolId') - .buildWithId(); - - const contextType: ToolContextType = ToolContextType.COURSE; - const contextId = 'contextId'; - - contextExternalToolService.findAllByContext.mockResolvedValueOnce([contextExternalTool]); - toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); - commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); - - return { - userId, - contextType, - contextId, - contextExternalTool, - schoolExternalTool, - externalTool, - externalToolId: externalTool.id as string, - }; - }; - - it('should call toolPermissionHelper.ensureContextPermissions', async () => { - const { userId, contextType, contextId, contextExternalTool } = setup(); - - await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - - expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - userId, - contextExternalTool, - AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]) - ); - }); - - it('should call contextExternalToolService.findAllByContext', async () => { - const { userId, contextType, contextId } = setup(); - - await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - - expect(contextExternalToolService.findAllByContext).toHaveBeenCalledWith({ - type: contextType, - id: contextId, - }); - }); - - it('should call schoolExternalToolService.findByExternalToolId', async () => { - const { userId, contextType, contextId, contextExternalTool } = setup(); - - await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - - expect(schoolExternalToolService.getSchoolExternalToolById).toHaveBeenCalledWith( - contextExternalTool.schoolToolRef.schoolToolId - ); - }); - - it('should call externalToolService.findById', async () => { - const { userId, contextType, contextId, externalToolId } = setup(); - - await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(externalToolId); - }); - - it('should call commonToolService.determineToolConfigurationStatus', async () => { - const { userId, contextType, contextId, contextExternalTool, schoolExternalTool, externalTool } = setup(); - - await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - - expect(commonToolService.determineToolConfigurationStatus).toHaveBeenCalledWith( - externalTool, - schoolExternalTool, - contextExternalTool - ); - }); - - it('should call externalToolLogoService.buildLogoUrl', async () => { - const { userId, contextType, contextId, externalTool } = setup(); - - await uc.getToolReferences(userId, contextType, contextId, '/v3/tools/external-tools/{id}/logo'); - - expect(logoService.buildLogoUrl).toHaveBeenCalledWith('/v3/tools/external-tools/{id}/logo', externalTool); - }); - - it('should return a list of tool references', async () => { - const { userId, contextType, contextId, contextExternalTool, externalTool } = setup(); - - const result: ToolReference[] = await uc.getToolReferences( - userId, - contextType, - contextId, - '/v3/tools/external-tools/{id}/logo' - ); - - expect(result).toEqual([ - { - logoUrl: `${Configuration.get('PUBLIC_BACKEND_URL') as string}/v3/tools/external-tools/${ - externalTool.id as string - }/logo`, - openInNewTab: externalTool.openNewTab, - contextToolId: contextExternalTool.id as string, - displayName: contextExternalTool.displayName as string, - status: ToolConfigurationStatus.LATEST, - }, - ]); - }); - }); - - describe('when user does not have permission to a tool', () => { - const setup = () => { - const userId = 'userId'; - - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ - toolId: externalTool.id, - }); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef('schoolToolId', 'schoolId') - .buildWithId(); - - const contextType: ToolContextType = ToolContextType.COURSE; - const contextId = 'contextId'; - - contextExternalToolService.findAllByContext.mockResolvedValueOnce([contextExternalTool]); - toolPermissionHelper.ensureContextPermissions.mockRejectedValueOnce(new ForbiddenException()); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValueOnce(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValueOnce(externalTool); - - return { - userId, - contextType, - contextId, - }; - }; - - it('should filter out tool references if a ForbiddenException is thrown', async () => { - const { userId, contextType, contextId } = setup(); - - const result: ToolReference[] = await uc.getToolReferences( - userId, - contextType, - contextId, - '/v3/tools/external-tools/{id}/logo' - ); - - expect(result).toEqual([]); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts b/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts deleted file mode 100644 index 5ddf0e467c6..00000000000 --- a/apps/server/src/modules/tool/external-tool/uc/tool-reference.uc.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; -import { EntityId, Permission } from '@shared/domain'; -import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; -import { ExternalTool, ToolReference } from '../domain'; -import { ToolConfigurationStatus, ToolContextType } from '../../common/enum'; -import { CommonToolService } from '../../common/service'; -import { ContextExternalTool, ContextRef } from '../../context-external-tool/domain'; -import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ToolReferenceMapper } from '../mapper/tool-reference.mapper'; -import { ExternalToolLogoService, ExternalToolService } from '../service'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; - -@Injectable() -export class ToolReferenceUc { - constructor( - private readonly externalToolService: ExternalToolService, - private readonly schoolExternalToolService: SchoolExternalToolService, - private readonly contextExternalToolService: ContextExternalToolService, - private readonly toolPermissionHelper: ToolPermissionHelper, - private readonly commonToolService: CommonToolService, - private readonly externalToolLogoService: ExternalToolLogoService - ) {} - - async getToolReferences( - userId: EntityId, - contextType: ToolContextType, - contextId: string, - logoUrlTemplate: string - ): Promise { - const contextRef = new ContextRef({ type: contextType, id: contextId }); - - const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( - contextRef - ); - - const toolReferencesPromises: Promise[] = contextExternalTools.map( - (contextExternalTool: ContextExternalTool) => - this.buildToolReference(userId, contextExternalTool, logoUrlTemplate) - ); - - const toolReferencesWithNull: (ToolReference | null)[] = await Promise.all(toolReferencesPromises); - const filteredToolReferences: ToolReference[] = toolReferencesWithNull.filter( - (toolReference: ToolReference | null): toolReference is ToolReference => toolReference !== null - ); - - return filteredToolReferences; - } - - private async buildToolReference( - userId: EntityId, - contextExternalTool: ContextExternalTool, - logoUrlTemplate: string - ): Promise { - try { - await this.ensureToolPermissions(userId, contextExternalTool); - } catch (e: unknown) { - if (e instanceof ForbiddenException) { - return null; - } - } - - const schoolExternalTool: SchoolExternalTool = await this.fetchSchoolExternalTool(contextExternalTool); - const externalTool: ExternalTool = await this.fetchExternalTool(schoolExternalTool); - - const status: ToolConfigurationStatus = this.commonToolService.determineToolConfigurationStatus( - externalTool, - schoolExternalTool, - contextExternalTool - ); - - const toolReference: ToolReference = ToolReferenceMapper.mapToToolReference( - externalTool, - contextExternalTool, - status - ); - toolReference.logoUrl = this.externalToolLogoService.buildLogoUrl(logoUrlTemplate, externalTool); - - return toolReference; - } - - private async ensureToolPermissions(userId: EntityId, contextExternalTool: ContextExternalTool): Promise { - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); - - const promise: Promise = this.toolPermissionHelper.ensureContextPermissions( - userId, - contextExternalTool, - context - ); - - return promise; - } - - private async fetchSchoolExternalTool(contextExternalTool: ContextExternalTool): Promise { - return this.schoolExternalToolService.getSchoolExternalToolById(contextExternalTool.schoolToolRef.schoolToolId); - } - - private async fetchExternalTool(schoolExternalTool: SchoolExternalTool): Promise { - return this.externalToolService.findExternalToolById(schoolExternalTool.toolId); - } -} diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 89bbe0c2cb7..c2504b42de2 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -12,6 +12,9 @@ import { userFactory, } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server'; +import { ToolConfigurationStatusResponse } from '../../../context-external-tool/controller/dto/tool-configuration-status.response'; +import { ExternalToolEntity } from '../../../external-tool/entity'; +import { SchoolExternalToolEntity } from '../../entity'; import { CustomParameterEntryParam, SchoolExternalToolPostParams, @@ -19,9 +22,6 @@ import { SchoolExternalToolSearchListResponse, SchoolExternalToolSearchParams, } from '../dto'; -import { ToolConfigurationStatusResponse } from '../../../external-tool/controller/dto'; -import { SchoolExternalToolEntity } from '../../entity'; -import { ExternalToolEntity } from '../../../external-tool/entity'; describe('ToolSchoolController (API)', () => { let app: INestApplication; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts index 62ad203fb02..32dd35f10bd 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/dto/school-external-tool.response.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ToolConfigurationStatusResponse } from '../../../context-external-tool/controller/dto/tool-configuration-status.response'; import { CustomParameterEntryResponse } from './custom-parameter-entry.response'; -import { ToolConfigurationStatusResponse } from '../../../external-tool/controller/dto'; export class SchoolExternalToolResponse { @ApiProperty() diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts index 916445770e3..ca2296e6df7 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.spec.ts @@ -1,5 +1,5 @@ import { schoolExternalToolFactory } from '@shared/testing/factory'; -import { ToolConfigurationStatusResponse } from '../../external-tool/controller/dto'; +import { ToolConfigurationStatusResponse } from '../../context-external-tool/controller/dto'; import { SchoolExternalToolResponse, SchoolExternalToolSearchListResponse } from '../controller/dto'; import { SchoolExternalTool } from '../domain'; import { SchoolExternalToolResponseMapper } from './school-external-tool-response.mapper'; diff --git a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts index 10ee706dd81..7388b1a6a41 100644 --- a/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/school-external-tool/mapper/school-external-tool-response.mapper.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CustomParameterEntry } from '../../common/domain'; -import { ToolConfigurationStatus } from '../../common/enum'; -import { ToolConfigurationStatusResponse } from '../../external-tool/controller/dto'; +import { ToolStatusResponseMapper } from '../../common/mapper/tool-status-response.mapper'; +import { ToolConfigurationStatusResponse } from '../../context-external-tool/controller/dto'; import { CustomParameterEntryResponse, SchoolExternalToolResponse, @@ -9,12 +9,6 @@ import { } from '../controller/dto'; import { SchoolExternalTool } from '../domain'; -export const statusMapping: Record = { - [ToolConfigurationStatus.LATEST]: ToolConfigurationStatusResponse.LATEST, - [ToolConfigurationStatus.OUTDATED]: ToolConfigurationStatusResponse.OUTDATED, - [ToolConfigurationStatus.UNKNOWN]: ToolConfigurationStatusResponse.UNKNOWN, -}; - @Injectable() export class SchoolExternalToolResponseMapper { mapToSearchListResponse(externalTools: SchoolExternalTool[]): SchoolExternalToolSearchListResponse { @@ -33,7 +27,7 @@ export class SchoolExternalToolResponseMapper { parameters: this.mapToCustomParameterEntryResponse(schoolExternalTool.parameters), toolVersion: schoolExternalTool.toolVersion, status: schoolExternalTool.status - ? statusMapping[schoolExternalTool.status] + ? ToolStatusResponseMapper.mapToResponse(schoolExternalTool.status) : ToolConfigurationStatusResponse.UNKNOWN, }; } diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts index 7ca001675b0..e43bdeb42e0 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.spec.ts @@ -51,7 +51,7 @@ describe('SchoolExternalToolValidationService', () => { ...externalToolFactory.buildWithId(), ...externalToolDoMock, }); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); const schoolExternalToolId = schoolExternalTool.id as string; return { schoolExternalTool, @@ -66,7 +66,7 @@ describe('SchoolExternalToolValidationService', () => { await service.validate(schoolExternalTool); - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(schoolExternalTool.toolId); + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); it('should call commonToolValidationService.checkForDuplicateParameters', async () => { diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts index 8cc50097d5f..315d738ca64 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-validation.service.ts @@ -15,9 +15,7 @@ export class SchoolExternalToolValidationService { async validate(schoolExternalTool: SchoolExternalTool): Promise { this.commonToolValidationService.checkForDuplicateParameters(schoolExternalTool); - const loadedExternalTool: ExternalTool = await this.externalToolService.findExternalToolById( - schoolExternalTool.toolId - ); + const loadedExternalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); this.checkVersionMatch(schoolExternalTool.toolVersion, loadedExternalTool.version); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts index 7c4031ef0b7..52f9b0a4c02 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts @@ -3,11 +3,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SchoolExternalToolRepo } from '@shared/repo'; import { externalToolFactory } from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { schoolExternalToolFactory } from '@shared/testing/factory/domainobject/tool/school-external-tool.factory'; -import { ExternalToolService } from '../../external-tool/service'; -import { SchoolExternalToolService } from './school-external-tool.service'; +import { ToolConfigurationStatus } from '../../common/enum'; import { ExternalTool } from '../../external-tool/domain'; +import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../domain'; -import { ToolConfigurationStatus } from '../../common/enum'; +import { SchoolExternalToolService } from './school-external-tool.service'; describe('SchoolExternalToolService', () => { let module: TestingModule; @@ -77,7 +77,7 @@ describe('SchoolExternalToolService', () => { await service.findSchoolExternalTools(schoolExternalTool); - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(schoolExternalTool.toolId); + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); describe('when determine status', () => { @@ -86,7 +86,7 @@ describe('SchoolExternalToolService', () => { const { schoolExternalTool, externalTool } = setup(); externalTool.version = 1337; schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); @@ -100,7 +100,7 @@ describe('SchoolExternalToolService', () => { schoolExternalTool.toolVersion = 1; externalTool.version = 0; schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); @@ -114,7 +114,7 @@ describe('SchoolExternalToolService', () => { schoolExternalTool.toolVersion = 1; externalTool.version = 1; schoolExternalToolRepo.find.mockResolvedValue([schoolExternalTool]); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + externalToolService.findById.mockResolvedValue(externalTool); const schoolExternalToolDOs: SchoolExternalTool[] = await service.findSchoolExternalTools(schoolExternalTool); @@ -136,12 +136,12 @@ describe('SchoolExternalToolService', () => { }); }); - describe('getSchoolExternalToolById', () => { + describe('findById', () => { describe('when schoolExternalToolId is given', () => { it('should call schoolExternalToolRepo.findById', async () => { const { schoolExternalToolId } = setup(); - await service.getSchoolExternalToolById(schoolExternalToolId); + await service.findById(schoolExternalToolId); expect(schoolExternalToolRepo.findById).toHaveBeenCalledWith(schoolExternalToolId); }); @@ -163,7 +163,7 @@ describe('SchoolExternalToolService', () => { await service.saveSchoolExternalTool(schoolExternalTool); - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(schoolExternalTool.toolId); + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts index 9ee30d70db6..2f011560f6a 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { SchoolExternalToolRepo } from '@shared/repo'; import { EntityId } from '@shared/domain'; -import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; +import { SchoolExternalToolRepo } from '@shared/repo'; +import { ToolConfigurationStatus } from '../../common/enum'; +import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { SchoolExternalTool } from '../domain'; -import { ExternalTool } from '../../external-tool/domain'; -import { ToolConfigurationStatus } from '../../common/enum'; +import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; @Injectable() export class SchoolExternalToolService { @@ -14,7 +14,7 @@ export class SchoolExternalToolService { private readonly externalToolService: ExternalToolService ) {} - async getSchoolExternalToolById(schoolExternalToolId: EntityId): Promise { + async findById(schoolExternalToolId: EntityId): Promise { const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolRepo.findById(schoolExternalToolId); return schoolExternalTool; } @@ -38,7 +38,7 @@ export class SchoolExternalToolService { } private async enrichDataFromExternalTool(tool: SchoolExternalTool): Promise { - const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(tool.toolId); + const externalTool: ExternalTool = await this.externalToolService.findById(tool.toolId); const status: ToolConfigurationStatus = this.determineStatus(tool, externalTool); const schoolExternalTool: SchoolExternalTool = new SchoolExternalTool({ ...tool, diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts index c0daab13cff..85f26f83679 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts @@ -3,12 +3,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission, User } from '@shared/domain'; import { schoolExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; import { AuthorizationContextBuilder } from '@src/modules/authorization'; -import { SchoolExternalToolUc } from './school-external-tool.uc'; -import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; import { SchoolExternalTool } from '../domain'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; +import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; +import { SchoolExternalToolUc } from './school-external-tool.uc'; describe('SchoolExternalToolUc', () => { let module: TestingModule; @@ -259,7 +259,7 @@ describe('SchoolExternalToolUc', () => { const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const user: User = userFactory.buildWithId(); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(tool); + schoolExternalToolService.findById.mockResolvedValue(tool); return { user, @@ -285,7 +285,7 @@ describe('SchoolExternalToolUc', () => { const setup = () => { const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const user: User = userFactory.buildWithId(); - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(tool); + schoolExternalToolService.findById.mockResolvedValue(tool); return { user, diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts index 63067c234d7..2640def12d5 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; import { EntityId, Permission } from '@shared/domain'; import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; -import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalToolService } from '../../context-external-tool/service'; -import { SchoolExternalToolDto, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; import { SchoolExternalTool } from '../domain'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; +import { SchoolExternalToolDto, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; @Injectable() export class SchoolExternalToolUc { @@ -57,9 +57,7 @@ export class SchoolExternalToolUc { } async deleteSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( - schoolExternalToolId - ); + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); @@ -71,9 +69,7 @@ export class SchoolExternalToolUc { } async getSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( - schoolExternalToolId - ); + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); diff --git a/apps/server/src/modules/tool/tool-api.module.ts b/apps/server/src/modules/tool/tool-api.module.ts index fe775e01fd3..dea3405801d 100644 --- a/apps/server/src/modules/tool/tool-api.module.ts +++ b/apps/server/src/modules/tool/tool-api.module.ts @@ -4,11 +4,14 @@ import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@src/modules/authorization'; import { LegacySchoolModule } from '@src/modules/legacy-school'; import { UserModule } from '@src/modules/user'; +import { CommonToolModule } from './common'; import { ToolContextController } from './context-external-tool/controller'; -import { ContextExternalToolUc } from './context-external-tool/uc'; +import { ToolReferenceController } from './context-external-tool/controller/tool-reference.controller'; +import { ContextExternalToolUc, ToolReferenceUc } from './context-external-tool/uc'; import { ToolConfigurationController, ToolController } from './external-tool/controller'; import { ExternalToolRequestMapper, ExternalToolResponseMapper } from './external-tool/mapper'; -import { ExternalToolConfigurationUc, ExternalToolUc, ToolReferenceUc } from './external-tool/uc'; +import { ExternalToolConfigurationService } from './external-tool/service'; +import { ExternalToolConfigurationUc, ExternalToolUc } from './external-tool/uc'; import { ToolSchoolController } from './school-external-tool/controller'; import { SchoolExternalToolRequestMapper, SchoolExternalToolResponseMapper } from './school-external-tool/mapper'; import { SchoolExternalToolUc } from './school-external-tool/uc'; @@ -16,8 +19,6 @@ import { ToolConfigModule } from './tool-config.module'; import { ToolLaunchController } from './tool-launch/controller/tool-launch.controller'; import { ToolLaunchUc } from './tool-launch/uc'; import { ToolModule } from './tool.module'; -import { ExternalToolConfigurationService } from './external-tool/service'; -import { CommonToolModule } from './common'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { CommonToolModule } from './common'; ToolConfigurationController, ToolSchoolController, ToolContextController, + ToolReferenceController, ToolController, ], providers: [ diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts index 7dba13fd2f5..12f8716b5b3 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.spec.ts @@ -133,18 +133,6 @@ describe('AbstractLaunchStrategy', () => { name: 'autoSchoolIdParam', type: CustomParameterType.AUTO_SCHOOLID, }); - const autoCourseIdCustomParameter = customParameterFactory.build({ - scope: CustomParameterScope.GLOBAL, - location: CustomParameterLocation.BODY, - name: 'autoCourseIdParam', - type: CustomParameterType.AUTO_CONTEXTID, - }); - const autoCourseNameCustomParameter = customParameterFactory.build({ - scope: CustomParameterScope.GLOBAL, - location: CustomParameterLocation.BODY, - name: 'autoCourseNameParam', - type: CustomParameterType.AUTO_CONTEXTNAME, - }); const autoSchoolNumberCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, location: CustomParameterLocation.BODY, @@ -158,8 +146,6 @@ describe('AbstractLaunchStrategy', () => { schoolCustomParameter, contextCustomParameter, autoSchoolIdCustomParameter, - autoCourseIdCustomParameter, - autoCourseNameCustomParameter, autoSchoolNumberCustomParameter, ], }); @@ -191,15 +177,7 @@ describe('AbstractLaunchStrategy', () => { schoolId ); - const course: Course = courseFactory.buildWithId( - { - name: 'testName', - }, - contextExternalTool.contextRef.id - ); - schoolService.getSchoolById.mockResolvedValue(school); - courseService.findById.mockResolvedValue(course); const sortFn = (a: PropertyData, b: PropertyData) => { if (a.name < b.name) { @@ -215,15 +193,12 @@ describe('AbstractLaunchStrategy', () => { globalCustomParameter, schoolCustomParameter, autoSchoolIdCustomParameter, - autoCourseIdCustomParameter, - autoCourseNameCustomParameter, autoSchoolNumberCustomParameter, schoolParameterEntry, contextParameterEntry, externalTool, schoolExternalTool, contextExternalTool, - course, school, sortFn, }; @@ -235,14 +210,11 @@ describe('AbstractLaunchStrategy', () => { schoolCustomParameter, contextParameterEntry, autoSchoolIdCustomParameter, - autoCourseIdCustomParameter, - autoCourseNameCustomParameter, autoSchoolNumberCustomParameter, schoolParameterEntry, externalTool, schoolExternalTool, contextExternalTool, - course, school, sortFn, } = setup(); @@ -280,18 +252,131 @@ describe('AbstractLaunchStrategy', () => { location: PropertyLocation.BODY, }, { - name: autoCourseIdCustomParameter.name, - value: course.id, + name: autoSchoolNumberCustomParameter.name, + value: school.officialSchoolNumber as string, location: PropertyLocation.BODY, }, + { + name: concreteConfigParameter.name, + value: concreteConfigParameter.value, + location: concreteConfigParameter.location, + }, + ].sort(sortFn), + }); + }); + }); + + describe('when launching with context name parameter for the context "course"', () => { + const setup = () => { + const autoCourseNameCustomParameter = customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.BODY, + name: 'autoCourseNameParam', + type: CustomParameterType.AUTO_CONTEXTNAME, + }); + + const externalTool: ExternalTool = externalToolFactory.build({ + parameters: [autoCourseNameCustomParameter], + }); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + contextRef: { + type: ToolContextType.COURSE, + }, + }); + + const course: Course = courseFactory.buildWithId( + { + name: 'testName', + }, + contextExternalTool.contextRef.id + ); + + courseService.findById.mockResolvedValue(course); + + return { + autoCourseNameCustomParameter, + externalTool, + schoolExternalTool, + contextExternalTool, + course, + }; + }; + + it('should return ToolLaunchData with the course name as parameter value', async () => { + const { externalTool, schoolExternalTool, contextExternalTool, autoCourseNameCustomParameter, course } = + setup(); + + const result: ToolLaunchData = await launchStrategy.createLaunchData('userId', { + externalTool, + schoolExternalTool, + contextExternalTool, + }); + + expect(result).toEqual({ + baseUrl: externalTool.config.baseUrl, + type: ToolLaunchDataType.BASIC, + openNewTab: false, + properties: [ { name: autoCourseNameCustomParameter.name, value: course.name, location: PropertyLocation.BODY, }, { - name: autoSchoolNumberCustomParameter.name, - value: school.officialSchoolNumber as string, + name: concreteConfigParameter.name, + value: concreteConfigParameter.value, + location: concreteConfigParameter.location, + }, + ], + }); + }); + }); + + describe('when launching with context id parameter', () => { + const setup = () => { + const autoContextIdCustomParameter = customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.BODY, + name: 'autoContextIdParam', + type: CustomParameterType.AUTO_CONTEXTID, + }); + + const externalTool: ExternalTool = externalToolFactory.build({ + parameters: [autoContextIdCustomParameter], + }); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + return { + autoContextIdCustomParameter, + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return ToolLaunchData with the context id as parameter value', async () => { + const { externalTool, schoolExternalTool, contextExternalTool, autoContextIdCustomParameter } = setup(); + + const result: ToolLaunchData = await launchStrategy.createLaunchData('userId', { + externalTool, + schoolExternalTool, + contextExternalTool, + }); + + expect(result).toEqual({ + baseUrl: externalTool.config.baseUrl, + type: ToolLaunchDataType.BASIC, + openNewTab: false, + properties: [ + { + name: autoContextIdCustomParameter.name, + value: contextExternalTool.contextRef.id, location: PropertyLocation.BODY, }, { @@ -299,7 +384,7 @@ describe('AbstractLaunchStrategy', () => { value: concreteConfigParameter.value, location: concreteConfigParameter.location, }, - ].sort(sortFn), + ], }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts index 9004e461ae2..644105a3c2b 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/abstract-launch.strategy.ts @@ -215,15 +215,18 @@ export abstract class AbstractLaunchStrategy implements IToolLaunchStrategy { return contextExternalTool.contextRef.id; } case CustomParameterType.AUTO_CONTEXTNAME: { - if (contextExternalTool.contextRef.type === ToolContextType.COURSE) { - const course: Course = await this.courseService.findById(contextExternalTool.contextRef.id); - - return course.name; + switch (contextExternalTool.contextRef.type) { + case ToolContextType.COURSE: { + const course: Course = await this.courseService.findById(contextExternalTool.contextRef.id); + + return course.name; + } + default: { + throw new ParameterTypeNotImplementedLoggableException( + `${customParameter.type}/${contextExternalTool.contextRef.type as string}` + ); + } } - - throw new ParameterTypeNotImplementedLoggableException( - `${customParameter.type}/${contextExternalTool.contextRef.type as string}` - ); } case CustomParameterType.AUTO_SCHOOLNUMBER: { const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts index a0db37651be..1c31aac36b6 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.spec.ts @@ -9,12 +9,12 @@ import { userDoFactory, } from '@shared/testing'; import { pseudonymFactory } from '@shared/testing/factory/domainobject/pseudonym.factory'; -import { PseudonymService } from '@src/modules/pseudonym/service'; +import { CourseService } from '@src/modules/learnroom/service'; import { LegacySchoolService } from '@src/modules/legacy-school'; +import { PseudonymService } from '@src/modules/pseudonym/service'; import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; import { Authorization } from 'oauth-1.0a'; -import { CourseService } from '@src/modules/learnroom/service'; import { LtiMessageType, LtiPrivacyPermission, LtiRole, ToolContextType } from '../../../common/enum'; import { ContextExternalTool } from '../../../context-external-tool/domain'; import { ExternalTool } from '../../../external-tool/domain'; diff --git a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts index 8c957ca9421..654e14b45d9 100644 --- a/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/strategy/lti11-tool-launch.strategy.ts @@ -9,7 +9,7 @@ import { Authorization } from 'oauth-1.0a'; import { LtiRole } from '../../../common/enum'; import { ExternalTool } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; -import { LaunchRequestMethod, PropertyData, PropertyLocation, AuthenticationValues } from '../../types'; +import { AuthenticationValues, LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { IToolLaunchParams } from './tool-launch-params.interface'; diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 02bb484093f..3330b0c9f0e 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -7,7 +7,14 @@ import { externalToolFactory, schoolExternalToolFactory, } from '@shared/testing'; +import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; +import { CommonToolService } from '../../common/service'; import { ContextExternalTool } from '../../context-external-tool/domain'; +import { BasicToolConfig, ExternalTool } from '../../external-tool/domain'; +import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ToolStatusOutdatedLoggableException } from '../error'; import { LaunchRequestMethod, ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { BasicToolLaunchStrategy, @@ -16,13 +23,6 @@ import { OAuth2ToolLaunchStrategy, } from './strategy'; import { ToolLaunchService } from './tool-launch.service'; -import { ToolStatusOutdatedLoggableException } from '../error'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ExternalToolService } from '../../external-tool/service'; -import { CommonToolService } from '../../common/service'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { BasicToolConfig, ExternalTool } from '../../external-tool/domain'; -import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; describe('ToolLaunchService', () => { let module: TestingModule; @@ -104,8 +104,8 @@ describe('ToolLaunchService', () => { contextExternalTool, }; - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); @@ -136,9 +136,7 @@ describe('ToolLaunchService', () => { await service.getLaunchData('userId', launchParams.contextExternalTool); - expect(schoolExternalToolService.getSchoolExternalToolById).toHaveBeenCalledWith( - launchParams.schoolExternalTool.id - ); + expect(schoolExternalToolService.findById).toHaveBeenCalledWith(launchParams.schoolExternalTool.id); }); it('should call findExternalToolById', async () => { @@ -146,7 +144,7 @@ describe('ToolLaunchService', () => { await service.getLaunchData('userId', launchParams.contextExternalTool); - expect(externalToolService.findExternalToolById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); + expect(externalToolService.findById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); }); }); @@ -165,8 +163,8 @@ describe('ToolLaunchService', () => { contextExternalTool, }; - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.LATEST); return { @@ -209,8 +207,8 @@ describe('ToolLaunchService', () => { const userId = 'userId'; - schoolExternalToolService.getSchoolExternalToolById.mockResolvedValue(schoolExternalTool); - externalToolService.findExternalToolById.mockResolvedValue(externalTool); + schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); + externalToolService.findById.mockResolvedValue(externalTool); basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); commonToolService.determineToolConfigurationStatus.mockReturnValueOnce(ToolConfigurationStatus.OUTDATED); diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts index 3321e782f09..46d2efdeb70 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.ts @@ -1,6 +1,13 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; +import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; import { CommonToolService } from '../../common/service'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { ToolStatusOutdatedLoggableException } from '../error'; import { ToolLaunchMapper } from '../mapper'; import { ToolLaunchData, ToolLaunchRequest } from '../types'; import { @@ -9,13 +16,6 @@ import { Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy, } from './strategy'; -import { ToolStatusOutdatedLoggableException } from '../error'; -import { SchoolExternalToolService } from '../../school-external-tool/service'; -import { ExternalToolService } from '../../external-tool/service'; -import { ToolConfigType, ToolConfigurationStatus } from '../../common/enum'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ExternalTool } from '../../external-tool/domain'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; @Injectable() export class ToolLaunchService { @@ -73,11 +73,9 @@ export class ToolLaunchService { private async loadToolHierarchy( schoolExternalToolId: string ): Promise<{ schoolExternalTool: SchoolExternalTool; externalTool: ExternalTool }> { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.getSchoolExternalToolById( - schoolExternalToolId - ); + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); - const externalTool: ExternalTool = await this.externalToolService.findExternalToolById(schoolExternalTool.toolId); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); return { schoolExternalTool, diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts index 62424d8b8aa..e9b7311e06a 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts @@ -2,12 +2,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { contextExternalToolFactory } from '@shared/testing'; import { ObjectId } from 'bson'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ContextExternalToolService } from '../../context-external-tool/service'; import { ToolLaunchService } from '../service'; import { ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; -import { ContextExternalToolService } from '../../context-external-tool/service'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; describe('ToolLaunchUc', () => { let module: TestingModule; @@ -66,7 +66,7 @@ describe('ToolLaunchUc', () => { const userId: string = new ObjectId().toHexString(); toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); - contextExternalToolService.getContextExternalToolById.mockResolvedValueOnce(contextExternalTool); + contextExternalToolService.findById.mockResolvedValueOnce(contextExternalTool); toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); return { @@ -82,7 +82,7 @@ describe('ToolLaunchUc', () => { await uc.getToolLaunchRequest(userId, contextExternalToolId); - expect(contextExternalToolService.getContextExternalToolById).toHaveBeenCalledWith(contextExternalToolId); + expect(contextExternalToolService.findById).toHaveBeenCalledWith(contextExternalToolId); }); it('should call service to get data', async () => { diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts index c397ae1d1af..fed27fa9aad 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; import { EntityId, Permission } from '@shared/domain'; import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; +import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ContextExternalToolService } from '../../context-external-tool/service'; import { ToolLaunchService } from '../service'; import { ToolLaunchData, ToolLaunchRequest } from '../types'; -import { ContextExternalToolService } from '../../context-external-tool/service'; -import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @Injectable() export class ToolLaunchUc { @@ -16,7 +16,7 @@ export class ToolLaunchUc { ) {} async getToolLaunchRequest(userId: EntityId, contextExternalToolId: EntityId): Promise { - const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.getContextExternalToolById( + const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findById( contextExternalToolId ); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts index 7c02cbc8a75..6806aeb3f71 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts @@ -127,7 +127,7 @@ describe('ContextExternalToolRepo', () => { }); describe('save', () => { - describe('when context is known', () => { + describe('when context is course', () => { function setup() { const domainObject: ContextExternalTool = contextExternalToolFactory.build({ displayName: 'displayName', @@ -159,6 +159,38 @@ describe('ContextExternalToolRepo', () => { }); }); + describe('when context is board card', () => { + function setup() { + const domainObject: ContextExternalTool = contextExternalToolFactory.build({ + displayName: 'displayName', + contextRef: { + id: new ObjectId().toHexString(), + type: ToolContextType.BOARD_ELEMENT, + }, + parameters: [new CustomParameterEntry({ name: 'param', value: 'value' })], + schoolToolRef: { + schoolToolId: new ObjectId().toHexString(), + schoolId: undefined, + }, + toolVersion: 1, + }); + + return { + domainObject, + }; + } + + it('should save a ContextExternalToolDO', async () => { + const { domainObject } = setup(); + const { id, ...expected } = domainObject; + + const result: ContextExternalTool = await repo.save(domainObject); + + expect(result).toMatchObject(expected); + expect(result.id).toBeDefined(); + }); + }); + describe('when context is unknown', () => { const setup = () => { const domainObject: ContextExternalTool = contextExternalToolFactory.build({ diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts index b766828beff..084adb4b727 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts @@ -115,6 +115,8 @@ export class ContextExternalToolRepo extends BaseDORepo< switch (type) { case ToolContextType.COURSE: return ContextExternalToolType.COURSE; + case ToolContextType.BOARD_ELEMENT: + return ContextExternalToolType.BOARD_ELEMENT; default: throw new Error('Unknown ToolContextType'); } @@ -124,6 +126,8 @@ export class ContextExternalToolRepo extends BaseDORepo< switch (type) { case ContextExternalToolType.COURSE: return ToolContextType.COURSE; + case ContextExternalToolType.BOARD_ELEMENT: + return ToolContextType.BOARD_ELEMENT; default: throw new Error('Unknown ContextExternalToolType'); } From fa3a4f7d671acb15f3c90c075b2a97b1e762a358 Mon Sep 17 00:00:00 2001 From: virgilchiriac <17074330+virgilchiriac@users.noreply.github.com> Date: Thu, 19 Oct 2023 12:10:55 +0200 Subject: [PATCH 32/34] BC-5566 - fix merlin feathers service (#4480) Rename the merlinToken feathers service path to a unique path instead of subpath of edu-shraring Not clear why, but feathers service was suddenly mapped wrong and calling edu-sharing service instead of merlinToken service. Changing the order of the services load helped, but then lots of tests fail. --- src/services/edusharing/index.js | 4 +- src/services/lesson/hooks/index.js | 90 ++++++++++--------- .../services/merlinGenerator.test.js | 2 +- 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/services/edusharing/index.js b/src/services/edusharing/index.js index d002686ebea..27a45244494 100644 --- a/src/services/edusharing/index.js +++ b/src/services/edusharing/index.js @@ -35,7 +35,7 @@ class EduSharingPlayer { throw new MethodNotAllowed('This feature is disabled on this instance'); } const esPlayer = EduSharingConnectorV7.getPlayerForNode(uuid); - + return esPlayer; } } @@ -49,7 +49,7 @@ class MerlinToken { module.exports = (app) => { const eduSharingRoute = '/edu-sharing'; const eduSharingPlayerRoute = '/edu-sharing/player'; - const merlinRoute = '/edu-sharing/merlinToken'; + const merlinRoute = '/edu-sharing-merlinToken'; const docRoute = '/edu-sharing/api'; app.use(eduSharingRoute, new EduSharing()); diff --git a/src/services/lesson/hooks/index.js b/src/services/lesson/hooks/index.js index b7b0b0ea5e7..458b64785ab 100644 --- a/src/services/lesson/hooks/index.js +++ b/src/services/lesson/hooks/index.js @@ -60,7 +60,7 @@ const convertMerlinUrl = async (context) => { await Promise.all( content.content.resources.map(async (resource) => { if (resource && resource.merlinReference) { - resource.url = await context.app.service('edu-sharing/merlinToken').find({ + resource.url = await context.app.service('edu-sharing-merlinToken').find({ ...context.params, query: { ...context.params.query, merlinReference: resource.merlinReference }, }); @@ -82,7 +82,7 @@ const convertMerlinUrl = async (context) => { await Promise.all( materialIds.map(async (material) => { if (material.merlinReference) { - material.url = await context.app.service('edu-sharing/merlinToken').find({ + material.url = await context.app.service('edu-sharing-merlinToken').find({ ...context.params, query: { ...context.params.query, @@ -229,48 +229,50 @@ const populateWhitelist = { ], }; -exports.before = () => ({ - all: [authenticate('jwt'), mapUsers], - find: [ - hasPermission('TOPIC_VIEW'), - iff(isProvider('external'), validateLessonFind), - iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons), - ], - get: [ - hasPermission('TOPIC_VIEW'), - iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons), - ], - create: [ - checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', true), - injectUserId, - checkCorrectCourseOrTeamId, - setPosition, - iff(isProvider('external'), preventPopulate), - ], - update: [ - iff(isProvider('external'), preventPopulate), - permitGroupOperation, - ifNotLocal(checkCorrectCourseOrTeamId), - iff(isProvider('external'), restrictToUsersCoursesLessons), - checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), - ], - patch: [ - attachMerlinReferenceToLesson, - checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), - permitGroupOperation, - ifNotLocal(checkCorrectCourseOrTeamId), - iff(isProvider('external'), restrictToUsersCoursesLessons), - iff(isProvider('external'), preventPopulate), - ], - remove: [ - checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', false), - permitGroupOperation, - iff(isProvider('external'), restrictToUsersCoursesLessons), - iff(isProvider('external'), preventPopulate), - ], -}); +exports.before = () => { + return { + all: [authenticate('jwt'), mapUsers], + find: [ + hasPermission('TOPIC_VIEW'), + iff(isProvider('external'), validateLessonFind), + iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), + iff(isProvider('external'), restrictToUsersCoursesLessons), + ], + get: [ + hasPermission('TOPIC_VIEW'), + iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), + iff(isProvider('external'), restrictToUsersCoursesLessons), + ], + create: [ + checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', true), + injectUserId, + checkCorrectCourseOrTeamId, + setPosition, + iff(isProvider('external'), preventPopulate), + ], + update: [ + iff(isProvider('external'), preventPopulate), + permitGroupOperation, + ifNotLocal(checkCorrectCourseOrTeamId), + iff(isProvider('external'), restrictToUsersCoursesLessons), + checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), + ], + patch: [ + attachMerlinReferenceToLesson, + checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), + permitGroupOperation, + ifNotLocal(checkCorrectCourseOrTeamId), + iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), preventPopulate), + ], + remove: [ + checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', false), + permitGroupOperation, + iff(isProvider('external'), restrictToUsersCoursesLessons), + iff(isProvider('external'), preventPopulate), + ], + }; +}; exports.after = { all: [], diff --git a/test/services/edusharing/services/merlinGenerator.test.js b/test/services/edusharing/services/merlinGenerator.test.js index 43d5fbde13e..00e83f80461 100644 --- a/test/services/edusharing/services/merlinGenerator.test.js +++ b/test/services/edusharing/services/merlinGenerator.test.js @@ -17,7 +17,7 @@ describe('Merlin Token Generator', () => { before(async () => { app = await appPromise(); - MerlinTokenGeneratorService = app.service('edu-sharing/merlinToken'); + MerlinTokenGeneratorService = app.service('edu-sharing-merlinToken'); server = await app.listen(0); nestServices = await setupNestServices(app); }); From 4e8cfa0df589dd97e8aa7de42df840be46eca040 Mon Sep 17 00:00:00 2001 From: Cedric Evers <12080057+CeEv@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:33:56 +0200 Subject: [PATCH 33/34] BC-4942 - authorization reference service (#4413) * Split reference authorisation service from authorisation service * Replace on some places the authorisation methodes * Move rules into authorisation module to fix dependency issues * Fix invalid tests * Fix invalid imports --- .../src/modules/authorization/README.md | 101 ++--- .../authorization-reference.module.ts | 43 ++ .../authorization/authorization.module.ts | 69 ++-- .../authorization/authorization.service.ts | 100 ----- .../error}/forbidden.loggable-exception.ts | 2 +- .../authorization/domain/error/index.ts | 1 + .../src/modules/authorization/domain/index.ts | 4 + .../authorization-context.builder.spec.ts | 2 +- .../mapper}/authorization-context.builder.ts | 2 +- .../authorization/domain/mapper/index.ts | 1 + .../domain/rules/board-do.rule.spec.ts | 8 +- .../domain/rules/board-do.rule.ts | 8 +- .../rules/context-external-tool.rule.spec.ts | 9 +- .../rules/context-external-tool.rule.ts | 6 +- .../domain/rules/course-group.rule.spec.ts | 4 +- .../domain/rules/course-group.rule.ts | 4 +- .../domain/rules/course.rule.spec.ts | 4 +- .../domain/rules/course.rule.ts | 4 +- .../authorization/domain/rules/index.ts | 16 + .../domain/rules/legacy-school.rule.spec.ts | 4 +- .../domain/rules/legacy-school.rule.ts | 6 +- .../domain/rules/lesson.rule.spec.ts | 108 +++-- .../domain/rules/lesson.rule.ts | 12 +- .../rules/school-external-tool.rule.spec.ts | 8 +- .../domain/rules/school-external-tool.rule.ts | 6 +- .../domain/rules/submission.rule.spec.ts | 43 +- .../domain/rules/submission.rule.ts | 8 +- .../domain/rules/task.rule.spec.ts | 9 +- .../authorization}/domain/rules/task.rule.ts | 4 +- .../domain/rules/team.rule.spec.ts | 9 +- .../authorization}/domain/rules/team.rule.ts | 4 +- .../rules/user-login-migration.rule.spec.ts | 8 +- .../domain/rules/user-login-migration.rule.ts | 8 +- .../domain/rules/user.rule.spec.ts | 4 +- .../authorization}/domain/rules/user.rule.ts | 4 +- .../authorization-reference.service.spec.ts | 183 +++++++++ .../authorization-reference.service.ts | 41 ++ .../service}/authorization.helper.spec.ts | 0 .../service}/authorization.helper.ts | 0 .../service}/authorization.service.spec.ts | 186 ++------- .../domain/service/authorization.service.ts | 59 +++ .../authorization/domain/service/index.ts | 5 + .../service}/reference.loader.spec.ts | 35 +- .../{ => domain/service}/reference.loader.ts | 15 +- .../{ => domain/service}/rule-manager.spec.ts | 8 +- .../{ => domain/service}/rule-manager.ts | 10 +- .../{types => domain/type}/action.enum.ts | 0 .../allowed-authorization-object-type.enum.ts | 0 .../type}/authorization-context.interface.ts | 0 .../type}/authorization-loader-service.ts | 0 .../{types => domain/type}/index.ts | 2 +- .../{types => domain/type}/rule.interface.ts | 0 .../server/src/modules/authorization/index.ts | 20 +- apps/server/src/modules/board/uc/board.uc.ts | 4 +- apps/server/src/modules/board/uc/card.uc.ts | 3 +- .../server/src/modules/board/uc/element.uc.ts | 3 +- .../board/uc/submission-item.uc.spec.ts | 3 +- .../modules/board/uc/submission-item.uc.ts | 3 +- .../src/modules/files-storage/README.md | 2 +- .../files-storage/files-storage-api.module.ts | 4 +- .../mapper/files-storage.mapper.spec.ts | 2 +- .../mapper/files-storage.mapper.ts | 2 +- .../uc/files-storage-copy.uc.spec.ts | 43 +- .../uc/files-storage-delete.uc.spec.ts | 26 +- .../files-storage-download-preview.uc.spec.ts | 14 +- .../uc/files-storage-download.uc.spec.ts | 18 +- .../uc/files-storage-get.uc.spec.ts | 18 +- .../uc/files-storage-restore.uc.spec.ts | 24 +- .../uc/files-storage-update.uc.spec.ts | 14 +- .../uc/files-storage-upload.uc.spec.ts | 19 +- .../files-storage/uc/files-storage.uc.ts | 7 +- .../api-test/rooms-copy-timeout.api.spec.ts | 11 +- apps/server/src/modules/learnroom/index.ts | 1 + .../modules/learnroom/learnroom-api.module.ts | 3 +- .../learnroom/uc/course-copy.uc.spec.ts | 150 +++---- .../modules/learnroom/uc/course-copy.uc.ts | 12 +- .../learnroom/uc/course-export.uc.spec.ts | 83 +++- .../modules/learnroom/uc/course-export.uc.ts | 17 +- .../learnroom/uc/lesson-copy.uc.spec.ts | 388 +++++++++++------- .../modules/learnroom/uc/lesson-copy.uc.ts | 54 +-- .../learnroom/uc/room-board-dto.factory.ts | 3 +- .../legacy-school/uc/legacy-school.uc.spec.ts | 17 +- .../legacy-school/uc/legacy-school.uc.ts | 33 +- .../uc/oauth-provider.client-crud.uc.ts | 2 +- .../api-test/sharing-create-token.api.spec.ts | 32 +- .../api-test/sharing-import-token.api.spec.ts | 55 ++- .../api-test/sharing-lookup-token.api.spec.ts | 255 +++++++----- .../mapper/context-type.mapper.spec.ts | 2 +- .../sharing/mapper/context-type.mapper.ts | 3 +- .../sharing/mapper/parent-type.mapper.spec.ts | 2 +- .../sharing/mapper/parent-type.mapper.ts | 2 +- .../src/modules/sharing/sharing.module.ts | 13 +- .../modules/sharing/uc/share-token.uc.spec.ts | 44 +- .../src/modules/sharing/uc/share-token.uc.ts | 31 +- .../src/modules/task/uc/task-copy.uc.spec.ts | 157 +++---- .../src/modules/task/uc/task-copy.uc.ts | 85 ++-- .../modules/tool/common/common-tool.module.ts | 3 +- .../common/mapper/context-type.mapper.spec.ts | 11 + .../tool/common/mapper/context-type.mapper.ts | 2 +- .../tool/common/uc/tool-permission-helper.ts | 45 +- .../common/uc/tool-permissions-helper.spec.ts | 119 +++++- .../api-test/tool-context.api.spec.ts | 117 +++--- .../controller/tool-context.controller.ts | 1 + .../uc/context-external-tool.uc.spec.ts | 8 +- .../uc/context-external-tool.uc.ts | 14 +- .../api-test/tool-configuration.api.spec.ts | 84 ++-- .../uc/external-tool-configuration.uc.ts | 2 +- .../tool/tool-launch/tool-launch.module.ts | 4 +- .../mapper/video-conference.mapper.ts | 8 +- .../service/video-conference.service.spec.ts | 166 ++++++-- .../service/video-conference.service.ts | 91 ++-- .../uc/video-conference-create.uc.ts | 6 + .../uc/video-conference-deprecated.uc.spec.ts | 13 +- .../uc/video-conference-deprecated.uc.ts | 14 +- .../uc/video-conference-end.uc.ts | 6 + .../uc/video-conference-info.uc.ts | 6 + .../video-conference.module.ts | 2 + apps/server/src/shared/domain/rules/index.ts | 39 -- .../src/shared/infra/antivirus/index.ts | 6 +- .../src/shared/testing/test-api-client.ts | 4 + 120 files changed, 2162 insertions(+), 1470 deletions(-) create mode 100644 apps/server/src/modules/authorization/authorization-reference.module.ts delete mode 100644 apps/server/src/modules/authorization/authorization.service.ts rename apps/server/src/modules/authorization/{errors => domain/error}/forbidden.loggable-exception.ts (94%) create mode 100644 apps/server/src/modules/authorization/domain/error/index.ts create mode 100644 apps/server/src/modules/authorization/domain/index.ts rename apps/server/src/modules/authorization/{ => domain/mapper}/authorization-context.builder.spec.ts (96%) rename apps/server/src/modules/authorization/{ => domain/mapper}/authorization-context.builder.ts (91%) create mode 100644 apps/server/src/modules/authorization/domain/mapper/index.ts rename apps/server/src/{shared => modules/authorization}/domain/rules/board-do.rule.spec.ts (95%) rename apps/server/src/{shared => modules/authorization}/domain/rules/board-do.rule.ts (81%) rename apps/server/src/{shared => modules/authorization}/domain/rules/context-external-tool.rule.spec.ts (93%) rename apps/server/src/{shared => modules/authorization}/domain/rules/context-external-tool.rule.ts (85%) rename apps/server/src/{shared => modules/authorization}/domain/rules/course-group.rule.spec.ts (97%) rename apps/server/src/{shared => modules/authorization}/domain/rules/course-group.rule.ts (84%) rename apps/server/src/{shared => modules/authorization}/domain/rules/course.rule.spec.ts (95%) rename apps/server/src/{shared => modules/authorization}/domain/rules/course.rule.ts (82%) create mode 100644 apps/server/src/modules/authorization/domain/rules/index.ts rename apps/server/src/{shared => modules/authorization}/domain/rules/legacy-school.rule.spec.ts (93%) rename apps/server/src/{shared => modules/authorization}/domain/rules/legacy-school.rule.ts (78%) rename apps/server/src/{shared => modules/authorization}/domain/rules/lesson.rule.spec.ts (50%) rename apps/server/src/{shared => modules/authorization}/domain/rules/lesson.rule.ts (88%) rename apps/server/src/{shared => modules/authorization}/domain/rules/school-external-tool.rule.spec.ts (92%) rename apps/server/src/{shared => modules/authorization}/domain/rules/school-external-tool.rule.ts (85%) rename apps/server/src/{shared => modules/authorization}/domain/rules/submission.rule.spec.ts (93%) rename apps/server/src/{shared => modules/authorization}/domain/rules/submission.rule.ts (88%) rename apps/server/src/{shared => modules/authorization}/domain/rules/task.rule.spec.ts (96%) rename apps/server/src/{shared => modules/authorization}/domain/rules/task.rule.ts (90%) rename apps/server/src/{shared => modules/authorization}/domain/rules/team.rule.spec.ts (91%) rename apps/server/src/{shared => modules/authorization}/domain/rules/team.rule.ts (81%) rename apps/server/src/{shared => modules/authorization}/domain/rules/user-login-migration.rule.spec.ts (94%) rename apps/server/src/{shared => modules/authorization}/domain/rules/user-login-migration.rule.ts (72%) rename apps/server/src/{shared => modules/authorization}/domain/rules/user.rule.spec.ts (94%) rename apps/server/src/{shared => modules/authorization}/domain/rules/user.rule.ts (78%) create mode 100644 apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts create mode 100644 apps/server/src/modules/authorization/domain/service/authorization-reference.service.ts rename apps/server/src/modules/authorization/{ => domain/service}/authorization.helper.spec.ts (100%) rename apps/server/src/modules/authorization/{ => domain/service}/authorization.helper.ts (100%) rename apps/server/src/modules/authorization/{ => domain/service}/authorization.service.spec.ts (60%) create mode 100644 apps/server/src/modules/authorization/domain/service/authorization.service.ts create mode 100644 apps/server/src/modules/authorization/domain/service/index.ts rename apps/server/src/modules/authorization/{ => domain/service}/reference.loader.spec.ts (87%) rename apps/server/src/modules/authorization/{ => domain/service}/reference.loader.ts (88%) rename apps/server/src/modules/authorization/{ => domain/service}/rule-manager.spec.ts (97%) rename apps/server/src/modules/authorization/{ => domain/service}/rule-manager.ts (88%) rename apps/server/src/modules/authorization/{types => domain/type}/action.enum.ts (100%) rename apps/server/src/modules/authorization/{types => domain/type}/allowed-authorization-object-type.enum.ts (100%) rename apps/server/src/modules/authorization/{types => domain/type}/authorization-context.interface.ts (100%) rename apps/server/src/modules/authorization/{types => domain/type}/authorization-loader-service.ts (100%) rename apps/server/src/modules/authorization/{types => domain/type}/index.ts (100%) rename apps/server/src/modules/authorization/{types => domain/type}/rule.interface.ts (100%) create mode 100644 apps/server/src/modules/tool/common/mapper/context-type.mapper.spec.ts delete mode 100644 apps/server/src/shared/domain/rules/index.ts diff --git a/apps/server/src/modules/authorization/README.md b/apps/server/src/modules/authorization/README.md index 645feed2e64..7d2b69d209b 100644 --- a/apps/server/src/modules/authorization/README.md +++ b/apps/server/src/modules/authorization/README.md @@ -132,17 +132,7 @@ When calling other internal micro service for already authorized operations plea // next orchestration steps ``` -### Example 2 - Execute a Single Operation with Loading Resources - -```javascript -// If you don't have an entity but an entity type and id, you can check permission by reference -await this.authorizationService.checkPermissionByReferences(userId, AllowedEntity.course, courseId, AuthorizationContextBuilder.read([])); -// or -await this.authorizationService.hasPermissionByReferences(userId, AllowedEntity.course, courseId, AuthorizationContextBuilder.read([])); -// next orchestration steps -``` - -### Example 3 - Set Permission(s) of User as Required +### Example 2 - Set Permission(s) of User as Required ```javascript // Multiple permissions can be added. For a successful authorization, the user need all of them. @@ -173,14 +163,13 @@ this.authorizationService.hasPermission(userId, course, PermissionContexts.creat ```ts async createSchoolBySuperhero(userId: EntityId, params: { name: string }) { - const user = this.authorizationService.getUserWithPermissions(userId); - this.authorizationService.hasAllPermissions(user, [Permission.SCHOOL_CREATE]); - - const school = new School(params); + const user = this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.hasAllPermissions(user, [Permission.SCHOOL_CREATE]); - await this.schoolService.save(school); + const school = new School(params); + await this.schoolService.save(school); - return true; + return true; } ``` @@ -191,15 +180,15 @@ async createSchoolBySuperhero(userId: EntityId, params: { name: string }) { async createUserByAdmin(userId: EntityId, params: { email: string, firstName: string, lastName: string, schoolId: EntityId }) { - const user = this.authorizationService.getUserWithPermissions(userId); - - await this.authorizationService.checkPermissionByReferences(userId, AllowedEntity.school, schoolId, AuthorizationContextBuilder.write([Permission.INSTANCE, Permission.CREATE_USER])); - - const newUser = new User(params) + const user = this.authorizationService.getUserWithPermissions(userId); + + const context = AuthorizationContextBuilder.write([Permission.INSTANCE, Permission.CREATE_USER]) + await this.authorizationService.checkPermission(user, school, context); - await this.userService.save(newUser); + const newUser = new User(params) + await this.userService.save(newUser); - return true; + return true; } ``` @@ -210,18 +199,17 @@ async createUserByAdmin(userId: EntityId, params: { email: string, firstName: st // admin async editCourseByAdmin(userId: EntityId, params: { courseId: EntityId, description: string }) { - const course = this.courseService.getCourse(params.courseId); - const user = this.authorizationService.getUserWithPermissions(userId); - - const school = course.school - - this.authorizationService.hasPermissions(user, school, [Permission.INSTANCE, Permission.COURSE_EDIT]); + const course = this.courseService.getCourse(params.courseId); + const user = this.authorizationService.getUserWithPermissions(userId); + const school = course.school; - course.description = params.description; + const context = AuthorizationContextBuilder.write([Permission.INSTANCE, Permission.CREATE_USER]); + this.authorizationService.checkPermissions(user, school, context); - await this.courseService.save(course); + course.description = params.description; + await this.courseService.save(course); - return true; + return true; } ``` @@ -234,18 +222,17 @@ async createCourse(userId: EntityId, params: { schoolId: EntityId }) { const user = this.authorizationService.getUserWithPermissions(userId); const school = this.schoolService.getSchool(params.schoolId); - this.authorizationService.checkPermission(user, school - { - action: Actions.write, - requiredPermissions: [Permission.COURSE_CREATE], - } - ); + this.authorizationService.checkPermission(user, school + { + action: Actions.write, + requiredPermissions: [Permission.COURSE_CREATE], + } + ); - const course = new Course({ school }); + const course = new Course({ school }); + await this.courseService.saveCourse(course); - await this.courseService.saveCourse(course); - - return course; + return course; } ``` @@ -255,21 +242,20 @@ async createCourse(userId: EntityId, params: { schoolId: EntityId }) { ```ts // User can create a lesson to course, so you have a courseId async createLesson(userId: EntityId, params: { courseId: EntityId }) { - const course = this.courseService.getCourse(params.courseId); - const user = this.authorizationService.getUserWithPermissions(userId); + const course = this.courseService.getCourse(params.courseId); + const user = this.authorizationService.getUserWithPermissions(userId); // check authorization for user and course - this.authorizationService.checkPermission(user, course - { - action: Actions.write, - requiredPermissions: [Permission.COURSE_EDIT], - } - ); - - const lesson = new Lesson({course}); + this.authorizationService.checkPermission(user, course + { + action: Actions.write, + requiredPermissions: [Permission.COURSE_EDIT], + } + ); - await this.lessonService.saveLesson(lesson); + const lesson = new Lesson({course}); + await this.lessonService.saveLesson(lesson); - return true; + return true; } ``` @@ -345,8 +331,9 @@ The authorization module is the core of authorization. It collects all needed in ### Reference.loader -For situations where only the id and the domain object (string) type is known, it is possible to use the \*ByReferences methods. -They load the reference directly. +It should be use only inside of the authorization module. +It is use to load registrated ressouces by the id and name of the ressource. +This is needed to solve the API requests from external services. (API implementation is missing for now) > Please keep in mind that it can have an impact on the performance if you use it wrongly. > We keep it as a seperate method to avoid the usage in areas where the domain object should exist, because we see the risk that a developer could be tempted by the ease of only passing the id. diff --git a/apps/server/src/modules/authorization/authorization-reference.module.ts b/apps/server/src/modules/authorization/authorization-reference.module.ts new file mode 100644 index 00000000000..7346f2178dd --- /dev/null +++ b/apps/server/src/modules/authorization/authorization-reference.module.ts @@ -0,0 +1,43 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { + CourseGroupRepo, + CourseRepo, + LessonRepo, + SchoolExternalToolRepo, + LegacySchoolRepo, + SubmissionRepo, + TaskRepo, + TeamsRepo, + UserRepo, +} from '@shared/repo'; +import { ToolModule } from '@src/modules/tool'; +import { LoggerModule } from '@src/core/logger'; +import { BoardModule } from '@src/modules/board'; +import { ReferenceLoader, AuthorizationReferenceService, AuthorizationHelper } from './domain'; +import { AuthorizationModule } from './authorization.module'; + +/** + * This module is part of an intermediate state. In the future it should be replaced by an AuthorizationApiModule. + * For now it is used where the authorization itself needs to load data from the database. + * Avoid using this module and load the needed data in your use cases and then use the normal AuthorizationModule! + */ +@Module({ + // TODO: remove forwardRef to TooModule N21-1055 + imports: [AuthorizationModule, forwardRef(() => ToolModule), forwardRef(() => BoardModule), LoggerModule], + providers: [ + AuthorizationHelper, + ReferenceLoader, + UserRepo, + CourseRepo, + CourseGroupRepo, + TaskRepo, + LegacySchoolRepo, + LessonRepo, + TeamsRepo, + SubmissionRepo, + SchoolExternalToolRepo, + AuthorizationReferenceService, + ], + exports: [AuthorizationReferenceService], +}) +export class AuthorizationReferenceModule {} diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index c983ee187fd..37ca0a2b229 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -1,53 +1,46 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { ALL_RULES } from '@shared/domain/rules'; +import { Module } from '@nestjs/common'; +import { UserRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { FeathersModule } from '@shared/infra/feathers'; import { - CourseGroupRepo, - CourseRepo, - LessonRepo, - SchoolExternalToolRepo, - LegacySchoolRepo, - SubmissionRepo, - TaskRepo, - TeamsRepo, - UserRepo, -} from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; -import { LegacySchoolModule } from '@src/modules/legacy-school'; -import { ToolModule } from '@src/modules/tool'; -import { BoardModule } from '../board'; -import { AuthorizationHelper } from './authorization.helper'; -import { AuthorizationService } from './authorization.service'; + BoardDoRule, + ContextExternalToolRule, + CourseGroupRule, + CourseRule, + LessonRule, + SchoolExternalToolRule, + SubmissionRule, + TaskRule, + TeamRule, + UserRule, + UserLoginMigrationRule, + LegacySchoolRule, +} from './domain/rules'; +import { AuthorizationHelper, AuthorizationService, RuleManager } from './domain'; import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; -import { ReferenceLoader } from './reference.loader'; -import { RuleManager } from './rule-manager'; @Module({ - // TODO: remove forwardRef to TooModule N21-1055 - imports: [ - FeathersModule, - LoggerModule, - LegacySchoolModule, - forwardRef(() => ToolModule), - forwardRef(() => BoardModule), - ], + imports: [FeathersModule, LoggerModule], providers: [ FeathersAuthorizationService, FeathersAuthProvider, AuthorizationService, - ...ALL_RULES, - ReferenceLoader, UserRepo, - CourseRepo, - CourseGroupRepo, - TaskRepo, - LegacySchoolRepo, - LessonRepo, - TeamsRepo, - SubmissionRepo, - SchoolExternalToolRepo, RuleManager, AuthorizationHelper, + // rules + BoardDoRule, + ContextExternalToolRule, + CourseGroupRule, + CourseRule, + LessonRule, + SchoolExternalToolRule, + SubmissionRule, + TaskRule, + TeamRule, + UserRule, + UserLoginMigrationRule, + LegacySchoolRule, ], exports: [FeathersAuthorizationService, AuthorizationService], }) diff --git a/apps/server/src/modules/authorization/authorization.service.ts b/apps/server/src/modules/authorization/authorization.service.ts deleted file mode 100644 index b89561f1c30..00000000000 --- a/apps/server/src/modules/authorization/authorization.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; -import { BaseDO, EntityId, User } from '@shared/domain'; -import { AuthorizableObject } from '@shared/domain/domain-object'; -import { ErrorUtils } from '@src/core/error/utils'; -import { AuthorizationHelper } from './authorization.helper'; -import { ForbiddenLoggableException } from './errors/forbidden.loggable-exception'; -import { ReferenceLoader } from './reference.loader'; -import { RuleManager } from './rule-manager'; -import { AuthorizableReferenceType, AuthorizationContext } from './types'; - -@Injectable() -export class AuthorizationService { - constructor( - private readonly ruleManager: RuleManager, - private readonly loader: ReferenceLoader, - private readonly authorizationHelper: AuthorizationHelper - ) {} - - public checkPermission(user: User, object: AuthorizableObject | BaseDO, context: AuthorizationContext): void { - if (!this.hasPermission(user, object, context)) { - throw new ForbiddenLoggableException(user.id, object.constructor.name, context); - } - } - - public hasPermission(user: User, object: AuthorizableObject | BaseDO, context: AuthorizationContext): boolean { - const rule = this.ruleManager.selectRule(user, object, context); - const hasPermission = rule.hasPermission(user, object, context); - - return hasPermission; - } - - /** - * @deprecated - */ - public async checkPermissionByReferences( - userId: EntityId, - entityName: AuthorizableReferenceType, - entityId: EntityId, - context: AuthorizationContext - ): Promise { - if (!(await this.hasPermissionByReferences(userId, entityName, entityId, context))) { - throw new ForbiddenLoggableException(userId, entityName, context); - } - } - - /** - * @deprecated - */ - public async hasPermissionByReferences( - userId: EntityId, - entityName: AuthorizableReferenceType, - entityId: EntityId, - context: AuthorizationContext - ): Promise { - // TODO: This try-catch-block should be removed. See ticket: https://ticketsystem.dbildungscloud.de/browse/BC-4023 - try { - const [user, object] = await Promise.all([ - this.getUserWithPermissions(userId), - this.loader.loadAuthorizableObject(entityName, entityId), - ]); - const rule = this.ruleManager.selectRule(user, object, context); - const hasPermission = rule.hasPermission(user, object, context); - - return hasPermission; - } catch (error) { - throw new ForbiddenException( - null, - ErrorUtils.createHttpExceptionOptions(error, 'AuthorizationService:hasPermissionByReferences') - ); - } - } - - public checkAllPermissions(user: User, requiredPermissions: string[]): void { - if (!this.authorizationHelper.hasAllPermissions(user, requiredPermissions)) { - // TODO: Should be ForbiddenException - throw new UnauthorizedException(); - } - } - - public hasAllPermissions(user: User, requiredPermissions: string[]): boolean { - return this.authorizationHelper.hasAllPermissions(user, requiredPermissions); - } - - public checkOneOfPermissions(user: User, requiredPermissions: string[]): void { - if (!this.authorizationHelper.hasOneOfPermissions(user, requiredPermissions)) { - // TODO: Should be ForbiddenException - throw new UnauthorizedException(); - } - } - - public hasOneOfPermissions(user: User, requiredPermissions: string[]): boolean { - return this.authorizationHelper.hasOneOfPermissions(user, requiredPermissions); - } - - public async getUserWithPermissions(userId: EntityId): Promise { - const userWithPermissions = await this.loader.getUserWithPermissions(userId); - - return userWithPermissions; - } -} diff --git a/apps/server/src/modules/authorization/errors/forbidden.loggable-exception.ts b/apps/server/src/modules/authorization/domain/error/forbidden.loggable-exception.ts similarity index 94% rename from apps/server/src/modules/authorization/errors/forbidden.loggable-exception.ts rename to apps/server/src/modules/authorization/domain/error/forbidden.loggable-exception.ts index f775fb903df..9557ed14ede 100644 --- a/apps/server/src/modules/authorization/errors/forbidden.loggable-exception.ts +++ b/apps/server/src/modules/authorization/domain/error/forbidden.loggable-exception.ts @@ -2,7 +2,7 @@ import { ForbiddenException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; import { Loggable } from '@src/core/logger/interfaces'; import { ErrorLogMessage } from '@src/core/logger/types'; -import { AuthorizationContext } from '../types'; +import { AuthorizationContext } from '../type'; export class ForbiddenLoggableException extends ForbiddenException implements Loggable { constructor( diff --git a/apps/server/src/modules/authorization/domain/error/index.ts b/apps/server/src/modules/authorization/domain/error/index.ts new file mode 100644 index 00000000000..f2c782cbe56 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/error/index.ts @@ -0,0 +1 @@ +export * from './forbidden.loggable-exception'; diff --git a/apps/server/src/modules/authorization/domain/index.ts b/apps/server/src/modules/authorization/domain/index.ts new file mode 100644 index 00000000000..0f5cfe67874 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/index.ts @@ -0,0 +1,4 @@ +export * from './service'; +export * from './mapper'; +export * from './error'; +export * from './type'; diff --git a/apps/server/src/modules/authorization/authorization-context.builder.spec.ts b/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.spec.ts similarity index 96% rename from apps/server/src/modules/authorization/authorization-context.builder.spec.ts rename to apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.spec.ts index 856ec92d4a6..5944d7f22e0 100644 --- a/apps/server/src/modules/authorization/authorization-context.builder.spec.ts +++ b/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.spec.ts @@ -1,6 +1,6 @@ import { Permission } from '@shared/domain'; import { AuthorizationContextBuilder } from './authorization-context.builder'; -import { Action } from './types'; +import { Action } from '../type'; describe('AuthorizationContextBuilder', () => { it('Should allow to set required permissions.', () => { diff --git a/apps/server/src/modules/authorization/authorization-context.builder.ts b/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts similarity index 91% rename from apps/server/src/modules/authorization/authorization-context.builder.ts rename to apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts index 58259aa1b6f..86b16685b58 100644 --- a/apps/server/src/modules/authorization/authorization-context.builder.ts +++ b/apps/server/src/modules/authorization/domain/mapper/authorization-context.builder.ts @@ -1,5 +1,5 @@ import { Permission } from '@shared/domain'; -import { AuthorizationContext, Action } from './types'; +import { AuthorizationContext, Action } from '../type'; export class AuthorizationContextBuilder { private static build(requiredPermissions: Permission[], action: Action): AuthorizationContext { diff --git a/apps/server/src/modules/authorization/domain/mapper/index.ts b/apps/server/src/modules/authorization/domain/mapper/index.ts new file mode 100644 index 00000000000..6f21d79acad --- /dev/null +++ b/apps/server/src/modules/authorization/domain/mapper/index.ts @@ -0,0 +1 @@ +export * from './authorization-context.builder'; diff --git a/apps/server/src/shared/domain/rules/board-do.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts similarity index 95% rename from apps/server/src/shared/domain/rules/board-do.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts index 3574250b67c..bda3680b460 100644 --- a/apps/server/src/shared/domain/rules/board-do.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts @@ -1,10 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { Action } from '@src/modules'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; import { ObjectId } from 'bson'; -import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '../domainobject'; -import { Permission } from '../interface'; +import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '@shared/domain/domainobject'; +import { Permission } from '@shared/domain/interface'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { BoardDoRule } from './board-do.rule'; describe(BoardDoRule.name, () => { diff --git a/apps/server/src/shared/domain/rules/board-do.rule.ts b/apps/server/src/modules/authorization/domain/rules/board-do.rule.ts similarity index 81% rename from apps/server/src/shared/domain/rules/board-do.rule.ts rename to apps/server/src/modules/authorization/domain/rules/board-do.rule.ts index 575c9f0db5a..2042365e071 100644 --- a/apps/server/src/shared/domain/rules/board-do.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/board-do.rule.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; -import { BoardDoAuthorizable, BoardRoles } from '../domainobject'; -import { User } from '../entity'; +import { BoardDoAuthorizable, BoardRoles } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; +import { Action, AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class BoardDoRule implements Rule { diff --git a/apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts similarity index 93% rename from apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts index 90a25a1ca82..ddd458959ed 100644 --- a/apps/server/src/shared/domain/rules/context-external-tool.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts @@ -7,16 +7,15 @@ import { setupEntities, userFactory, } from '@shared/testing'; - -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; import { ContextExternalTool } from '@src/modules/tool/context-external-tool/domain'; import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; -import { Role, User } from '../entity'; -import { Permission } from '../interface'; +import { Role, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; import { ContextExternalToolRule } from './context-external-tool.rule'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; describe('ContextExternalToolRule', () => { let service: ContextExternalToolRule; diff --git a/apps/server/src/shared/domain/rules/context-external-tool.rule.ts b/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.ts similarity index 85% rename from apps/server/src/shared/domain/rules/context-external-tool.rule.ts rename to apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.ts index 35be641e550..5d57c95a160 100644 --- a/apps/server/src/shared/domain/rules/context-external-tool.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; import { ContextExternalTool } from '@src/modules/tool/context-external-tool/domain'; import { ContextExternalToolEntity } from '@src/modules/tool/context-external-tool/entity'; -import { User } from '../entity'; +import { User } from '@shared/domain/entity'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class ContextExternalToolRule implements Rule { diff --git a/apps/server/src/shared/domain/rules/course-group.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/course-group.rule.spec.ts similarity index 97% rename from apps/server/src/shared/domain/rules/course-group.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/course-group.rule.spec.ts index 8296f75f917..62c14baa138 100644 --- a/apps/server/src/shared/domain/rules/course-group.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/course-group.rule.spec.ts @@ -2,10 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CourseGroup, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { courseFactory, courseGroupFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; import { CourseGroupRule } from './course-group.rule'; import { CourseRule } from './course.rule'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; describe('CourseGroupRule', () => { let service: CourseGroupRule; diff --git a/apps/server/src/shared/domain/rules/course-group.rule.ts b/apps/server/src/modules/authorization/domain/rules/course-group.rule.ts similarity index 84% rename from apps/server/src/shared/domain/rules/course-group.rule.ts rename to apps/server/src/modules/authorization/domain/rules/course-group.rule.ts index 14638862ba2..863d7072ec8 100644 --- a/apps/server/src/shared/domain/rules/course-group.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/course-group.rule.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { CourseGroup, User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; import { CourseRule } from './course.rule'; +import { Action, AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class CourseGroupRule implements Rule { diff --git a/apps/server/src/shared/domain/rules/course.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts similarity index 95% rename from apps/server/src/shared/domain/rules/course.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts index eff0bd41fee..1c4dcc7d670 100644 --- a/apps/server/src/shared/domain/rules/course.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts @@ -2,9 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Course, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { courseFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; import { CourseRule } from './course.rule'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; describe('CourseRule', () => { let service: CourseRule; diff --git a/apps/server/src/shared/domain/rules/course.rule.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.ts similarity index 82% rename from apps/server/src/shared/domain/rules/course.rule.ts rename to apps/server/src/modules/authorization/domain/rules/course.rule.ts index 90183dbfd0f..e923e1ab967 100644 --- a/apps/server/src/shared/domain/rules/course.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Course, User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { Action, AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class CourseRule implements Rule { diff --git a/apps/server/src/modules/authorization/domain/rules/index.ts b/apps/server/src/modules/authorization/domain/rules/index.ts new file mode 100644 index 00000000000..bd4ffe27a59 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/index.ts @@ -0,0 +1,16 @@ +/** + * Rules are currently placed in authorization module to avoid dependency cycles. + * In future they must be moved to the feature modules and register it in registration service. + */ +export * from './board-do.rule'; +export * from './context-external-tool.rule'; +export * from './course-group.rule'; +export * from './course.rule'; +export * from './legacy-school.rule'; +export * from './lesson.rule'; +export * from './school-external-tool.rule'; +export * from './submission.rule'; +export * from './task.rule'; +export * from './team.rule'; +export * from './user-login-migration.rule'; +export * from './user.rule'; diff --git a/apps/server/src/shared/domain/rules/legacy-school.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts similarity index 93% rename from apps/server/src/shared/domain/rules/legacy-school.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts index c547f772de5..489def39318 100644 --- a/apps/server/src/shared/domain/rules/legacy-school.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts @@ -1,9 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { roleFactory, legacySchoolDoFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; import { ObjectID } from 'bson'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { LegacySchoolRule } from './legacy-school.rule'; describe('LegacySchoolRule', () => { diff --git a/apps/server/src/shared/domain/rules/legacy-school.rule.ts b/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.ts similarity index 78% rename from apps/server/src/shared/domain/rules/legacy-school.rule.ts rename to apps/server/src/modules/authorization/domain/rules/legacy-school.rule.ts index 5068d327c35..e115727091a 100644 --- a/apps/server/src/shared/domain/rules/legacy-school.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { BaseDO, LegacySchoolDo } from '@shared/domain'; import { User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; -import { AuthorizableObject } from '../domain-object'; +import { AuthorizableObject } from '@shared/domain/domain-object'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; /** * @deprecated because it uses the deprecated LegacySchoolDo. diff --git a/apps/server/src/shared/domain/rules/lesson.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts similarity index 50% rename from apps/server/src/shared/domain/rules/lesson.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts index 13c605f77e4..a8e4bdd8038 100644 --- a/apps/server/src/shared/domain/rules/lesson.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts @@ -10,17 +10,20 @@ import { setupEntities, userFactory, } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; -import { CourseGroupRule, CourseRule } from '.'; +import { NotImplementedException } from '@nestjs/common'; +import { Action, AuthorizationContext } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { CourseGroupRule } from './course-group.rule'; +import { CourseRule } from './course.rule'; import { LessonRule } from './lesson.rule'; +import { AuthorizationContextBuilder } from '../mapper'; describe('LessonRule', () => { - let service: LessonRule; + let rule: LessonRule; let authorizationHelper: AuthorizationHelper; let courseRule: DeepPartial; let courseGroupRule: DeepPartial; - let user: User; + let globalUser: User; let entity: LessonEntity; const permissionA = 'a' as Permission; const permissionB = 'b' as Permission; @@ -33,7 +36,7 @@ describe('LessonRule', () => { providers: [AuthorizationHelper, LessonRule, CourseRule, CourseGroupRule], }).compile(); - service = await module.get(LessonRule); + rule = await module.get(LessonRule); authorizationHelper = await module.get(AuthorizationHelper); courseRule = await module.get(CourseRule); courseGroupRule = await module.get(CourseGroupRule); @@ -41,58 +44,117 @@ describe('LessonRule', () => { beforeEach(() => { const role = roleFactory.build({ permissions: [permissionA, permissionB] }); - user = userFactory.build({ roles: [role] }); + globalUser = userFactory.build({ roles: [role] }); }); it('should call hasAllPermissions on AuthorizationHelper', () => { entity = lessonFactory.build(); const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); - service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, []); + rule.hasPermission(globalUser, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toBeCalledWith(globalUser, []); }); it('should call courseRule.hasPermission', () => { - const course = courseFactory.build({ teachers: [user] }); + const course = courseFactory.build({ teachers: [globalUser] }); entity = lessonFactory.build({ course }); const spy = jest.spyOn(courseRule, 'hasPermission'); - service.hasPermission(user, entity, { action: Action.write, requiredPermissions: [permissionA] }); - expect(spy).toBeCalledWith(user, entity.course, { action: Action.write, requiredPermissions: [] }); + rule.hasPermission(globalUser, entity, { action: Action.write, requiredPermissions: [permissionA] }); + expect(spy).toBeCalledWith(globalUser, entity.course, { action: Action.write, requiredPermissions: [] }); }); it('should call courseGroupRule.hasPermission', () => { - const course = courseFactory.build({ teachers: [user] }); + const course = courseFactory.build({ teachers: [globalUser] }); const courseGroup = courseGroupFactory.build({ course }); entity = lessonFactory.build({ course: undefined, courseGroup }); const spy = jest.spyOn(courseGroupRule, 'hasPermission'); - service.hasPermission(user, entity, { action: Action.write, requiredPermissions: [permissionA] }); - expect(spy).toBeCalledWith(user, entity.courseGroup, { action: Action.write, requiredPermissions: [] }); + rule.hasPermission(globalUser, entity, { action: Action.write, requiredPermissions: [permissionA] }); + expect(spy).toBeCalledWith(globalUser, entity.courseGroup, { action: Action.write, requiredPermissions: [] }); + }); + + describe('Given user request not implemented action', () => { + const getContext = (): AuthorizationContext => { + const context: AuthorizationContext = { + requiredPermissions: [], + // @ts-expect-error Testcase + action: 'not_implemented', + }; + + return context; + }; + + describe('when valid data exists', () => { + const setup = () => { + const user = userFactory.build(); + const course = courseFactory.build({ teachers: [user] }); + const lesson = lessonFactory.build({ course }); + const context = getContext(); + + return { + user, + lesson, + context, + }; + }; + + it('should reject with NotImplementedException', () => { + const { user, lesson, context } = setup(); + + expect(() => rule.hasPermission(user, lesson, context)).toThrowError(NotImplementedException); + }); + }); + }); + + describe('Given user request Action.write', () => { + const getWriteContext = () => AuthorizationContextBuilder.write([]); + + describe('when lesson has no course or coursegroup', () => { + const setup = () => { + const user = userFactory.build(); + const lessonEntity = lessonFactory.build({ course: undefined }); + const context = getWriteContext(); + + return { + user, + lessonEntity, + context, + }; + }; + + it('should return false', () => { + const { user, lessonEntity, context } = setup(); + + const result = rule.hasPermission(user, lessonEntity, context); + + expect(result).toBe(false); + }); + }); }); describe('User [TEACHER]', () => { it('should return "true" if user in scope', () => { - const course = courseFactory.build({ teachers: [user] }); + const course = courseFactory.build({ teachers: [globalUser] }); entity = lessonFactory.build({ course }); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionA] }); + const res = rule.hasPermission(globalUser, entity, { action: Action.read, requiredPermissions: [permissionA] }); expect(res).toBe(true); }); it('should return "true" if user has access to hidden entity', () => { - const course = courseFactory.build({ teachers: [user] }); + const course = courseFactory.build({ teachers: [globalUser] }); entity = lessonFactory.build({ course, hidden: true }); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionA] }); + const res = rule.hasPermission(globalUser, entity, { action: Action.read, requiredPermissions: [permissionA] }); expect(res).toBe(true); }); it('should return "false" if user has not permission', () => { entity = lessonFactory.build(); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionC] }); + const res = rule.hasPermission(globalUser, entity, { action: Action.read, requiredPermissions: [permissionC] }); expect(res).toBe(false); }); it('should return "false" if user has not access to entity', () => { entity = lessonFactory.build(); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionC] }); + const res = rule.hasPermission(globalUser, entity, { action: Action.read, requiredPermissions: [permissionC] }); expect(res).toBe(false); }); }); @@ -106,14 +168,14 @@ describe('LessonRule', () => { it('should return "false" if user has access to entity', () => { const course = courseFactory.build({ students: [student] }); entity = lessonFactory.build({ course }); - const res = service.hasPermission(student, entity, { action: Action.read, requiredPermissions: [permissionA] }); + const res = rule.hasPermission(student, entity, { action: Action.read, requiredPermissions: [permissionA] }); expect(res).toBe(true); }); it('should return "false" if user has not access to hidden entity', () => { const course = courseFactory.build({ students: [student] }); entity = lessonFactory.build({ course, hidden: true }); - const res = service.hasPermission(student, entity, { action: Action.read, requiredPermissions: [permissionA] }); + const res = rule.hasPermission(student, entity, { action: Action.read, requiredPermissions: [permissionA] }); expect(res).toBe(false); }); }); diff --git a/apps/server/src/shared/domain/rules/lesson.rule.ts b/apps/server/src/modules/authorization/domain/rules/lesson.rule.ts similarity index 88% rename from apps/server/src/shared/domain/rules/lesson.rule.ts rename to apps/server/src/modules/authorization/domain/rules/lesson.rule.ts index ff264af13ae..1f59f98ad49 100644 --- a/apps/server/src/shared/domain/rules/lesson.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/lesson.rule.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotImplementedException } from '@nestjs/common'; import { Course, CourseGroup, LessonEntity, User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { Action, AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { CourseGroupRule } from './course-group.rule'; import { CourseRule } from './course.rule'; @@ -27,6 +27,8 @@ export class LessonRule implements Rule { hasLessonPermission = this.lessonReadPermission(user, entity); } else if (action === Action.write) { hasLessonPermission = this.lessonWritePermission(user, entity); + } else { + throw new NotImplementedException('Action is not supported.'); } const hasUserPermission = this.authorizationHelper.hasAllPermissions(user, requiredPermissions); @@ -55,12 +57,14 @@ export class LessonRule implements Rule { } private parentPermission(user: User, entity: LessonEntity, action: Action): boolean { - let result = false; + let result: boolean; if (entity.courseGroup) { result = this.courseGroupPermission(user, entity.courseGroup, action); } else if (entity.course) { result = this.coursePermission(user, entity.course, action); // ask course for student = read || teacher, sub-teacher = write + } else { + result = false; } return result; diff --git a/apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts similarity index 92% rename from apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts index b24ed4d0ac8..d1781bb8576 100644 --- a/apps/server/src/shared/domain/rules/school-external-tool.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts @@ -7,13 +7,11 @@ import { userFactory, schoolExternalToolFactory, } from '@shared/testing'; - -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; -import { Role, User } from '../entity'; -import { Permission } from '../interface'; +import { Role, User, Permission } from '@shared/domain'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { SchoolExternalToolRule } from './school-external-tool.rule'; describe('SchoolExternalToolRule', () => { diff --git a/apps/server/src/shared/domain/rules/school-external-tool.rule.ts b/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.ts similarity index 85% rename from apps/server/src/shared/domain/rules/school-external-tool.rule.ts rename to apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.ts index bd28502faa2..041c8b523e2 100644 --- a/apps/server/src/shared/domain/rules/school-external-tool.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@src/modules/tool/school-external-tool/entity'; -import { User } from '../entity'; +import { User } from '@shared/domain/entity'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class SchoolExternalToolRule implements Rule { diff --git a/apps/server/src/shared/domain/rules/submission.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/submission.rule.spec.ts similarity index 93% rename from apps/server/src/shared/domain/rules/submission.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/submission.rule.spec.ts index 098f83547e8..8b054671970 100644 --- a/apps/server/src/shared/domain/rules/submission.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/submission.rule.spec.ts @@ -9,9 +9,14 @@ import { taskFactory, userFactory, } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; -import { CourseGroupRule, CourseRule, LessonRule, SubmissionRule, TaskRule } from '.'; +import { NotImplementedException } from '@nestjs/common'; +import { Action, AuthorizationContext } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { SubmissionRule } from './submission.rule'; +import { TaskRule } from './task.rule'; +import { CourseRule } from './course.rule'; +import { LessonRule } from './lesson.rule'; +import { CourseGroupRule } from './course-group.rule'; const buildUserWithPermission = (permission) => { const role = roleFactory.buildWithId({ permissions: [permission] }); @@ -76,6 +81,38 @@ describe('SubmissionRule', () => { }); describe('hasPermission', () => { + describe('Given user request not implemented action', () => { + const getContext = (): AuthorizationContext => { + const context: AuthorizationContext = { + requiredPermissions: [], + // @ts-expect-error Testcase + action: 'not_implemented', + }; + + return context; + }; + + describe('when valid data exists', () => { + const setup = () => { + const user = userFactory.build(); + const submission = submissionFactory.build({ student: user }); + const context = getContext(); + + return { + user, + submission, + context, + }; + }; + + it('should reject with NotImplementedException', () => { + const { user, submission, context } = setup(); + + expect(() => submissionRule.hasPermission(user, submission, context)).toThrowError(NotImplementedException); + }); + }); + }); + describe('when user roles do not contain required permissions', () => { const setup = () => { const permission = 'a' as Permission; diff --git a/apps/server/src/shared/domain/rules/submission.rule.ts b/apps/server/src/modules/authorization/domain/rules/submission.rule.ts similarity index 88% rename from apps/server/src/shared/domain/rules/submission.rule.ts rename to apps/server/src/modules/authorization/domain/rules/submission.rule.ts index 3234f8e8cff..6bff9504f5c 100644 --- a/apps/server/src/shared/domain/rules/submission.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/submission.rule.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotImplementedException } from '@nestjs/common'; import { Submission, User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { Action, AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { TaskRule } from './task.rule'; @Injectable() @@ -31,6 +31,8 @@ export class SubmissionRule implements Rule { hasAccessToSubmission = this.hasWriteAccess(user, submission); } else if (action === Action.read) { hasAccessToSubmission = this.hasReadAccess(user, submission); + } else { + throw new NotImplementedException('Action is not supported.'); } return hasAccessToSubmission; diff --git a/apps/server/src/shared/domain/rules/task.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts similarity index 96% rename from apps/server/src/shared/domain/rules/task.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts index 0fc886df84f..31d68661ff0 100644 --- a/apps/server/src/shared/domain/rules/task.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts @@ -2,9 +2,12 @@ import { DeepPartial } from '@mikro-orm/core'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, RoleName } from '@shared/domain/interface'; import { courseFactory, lessonFactory, roleFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { CourseGroupRule, CourseRule, LessonRule, TaskRule } from '.'; -import { Action } from '../../../modules/authorization/types/action.enum'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { CourseGroupRule } from './course-group.rule'; +import { TaskRule } from './task.rule'; +import { CourseRule } from './course.rule'; +import { LessonRule } from './lesson.rule'; describe('TaskRule', () => { let service: TaskRule; diff --git a/apps/server/src/shared/domain/rules/task.rule.ts b/apps/server/src/modules/authorization/domain/rules/task.rule.ts similarity index 90% rename from apps/server/src/shared/domain/rules/task.rule.ts rename to apps/server/src/modules/authorization/domain/rules/task.rule.ts index 4c358593109..3ebc04d9f71 100644 --- a/apps/server/src/shared/domain/rules/task.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/task.rule.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Task, User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action, AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { Action, AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { CourseRule } from './course.rule'; import { LessonRule } from './lesson.rule'; diff --git a/apps/server/src/shared/domain/rules/team.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/team.rule.spec.ts similarity index 91% rename from apps/server/src/shared/domain/rules/team.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/team.rule.spec.ts index d29eaaaa6f8..da99354a49b 100644 --- a/apps/server/src/shared/domain/rules/team.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/team.rule.spec.ts @@ -1,10 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { teamFactory } from '@shared/testing/factory/team.factory'; -import { TeamRule } from '@shared/domain/rules/team.rule'; -import { AuthorizationContextBuilder } from '@src/modules/authorization/authorization-context.builder'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; +import { roleFactory, setupEntities, userFactory, teamFactory } from '@shared/testing'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { TeamRule } from './team.rule'; +import { AuthorizationContextBuilder } from '../mapper'; describe('TeamRule', () => { let rule: TeamRule; diff --git a/apps/server/src/shared/domain/rules/team.rule.ts b/apps/server/src/modules/authorization/domain/rules/team.rule.ts similarity index 81% rename from apps/server/src/shared/domain/rules/team.rule.ts rename to apps/server/src/modules/authorization/domain/rules/team.rule.ts index 23ad0d55cf7..2d8f5e90edf 100644 --- a/apps/server/src/shared/domain/rules/team.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/team.rule.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { TeamEntity, TeamUserEntity, User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class TeamRule implements Rule { diff --git a/apps/server/src/shared/domain/rules/user-login-migration.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts similarity index 94% rename from apps/server/src/shared/domain/rules/user-login-migration.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts index a7c6b1e5f7a..f7fe9d3c53f 100644 --- a/apps/server/src/shared/domain/rules/user-login-migration.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts @@ -2,10 +2,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { schoolFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; -import { Action, AuthorizationContext } from '@src/modules/authorization'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { UserLoginMigrationDO } from '../domainobject'; -import { Permission } from '../interface'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { Permission } from '@shared/domain/interface'; +import { Action, AuthorizationContext } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { UserLoginMigrationRule } from './user-login-migration.rule'; describe('UserLoginMigrationRule', () => { diff --git a/apps/server/src/shared/domain/rules/user-login-migration.rule.ts b/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.ts similarity index 72% rename from apps/server/src/shared/domain/rules/user-login-migration.rule.ts rename to apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.ts index 084e4d26372..3ae82d02505 100644 --- a/apps/server/src/shared/domain/rules/user-login-migration.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; -import { UserLoginMigrationDO } from '../domainobject'; -import { User } from '../entity'; +import { UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class UserLoginMigrationRule implements Rule { diff --git a/apps/server/src/shared/domain/rules/user.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/user.rule.spec.ts similarity index 94% rename from apps/server/src/shared/domain/rules/user.rule.spec.ts rename to apps/server/src/modules/authorization/domain/rules/user.rule.spec.ts index bd771961ed3..85492348f75 100644 --- a/apps/server/src/shared/domain/rules/user.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/user.rule.spec.ts @@ -2,8 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Role, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { Action } from '@src/modules/authorization/types'; +import { Action } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; import { UserRule } from './user.rule'; describe('UserRule', () => { diff --git a/apps/server/src/shared/domain/rules/user.rule.ts b/apps/server/src/modules/authorization/domain/rules/user.rule.ts similarity index 78% rename from apps/server/src/shared/domain/rules/user.rule.ts rename to apps/server/src/modules/authorization/domain/rules/user.rule.ts index 3dd9bc6d229..2a1365881e1 100644 --- a/apps/server/src/shared/domain/rules/user.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/user.rule.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { User } from '@shared/domain/entity'; -import { AuthorizationHelper } from '@src/modules/authorization/authorization.helper'; -import { AuthorizationContext, Rule } from '@src/modules/authorization/types'; +import { AuthorizationContext, Rule } from '../type'; +import { AuthorizationHelper } from '../service/authorization.helper'; @Injectable() export class UserRule implements Rule { diff --git a/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts new file mode 100644 index 00000000000..8ab1719a72d --- /dev/null +++ b/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts @@ -0,0 +1,183 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { AuthorizableReferenceType } from '../type'; +import { AuthorizationService } from './authorization.service'; +import { ReferenceLoader } from './reference.loader'; +import { AuthorizationContextBuilder } from '../mapper'; +import { ForbiddenLoggableException } from '../error'; +import { AuthorizationReferenceService } from './authorization-reference.service'; + +describe('AuthorizationReferenceService', () => { + let service: AuthorizationReferenceService; + let authorizationService: DeepMocked; + let loader: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthorizationReferenceService, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: ReferenceLoader, + useValue: createMock(), + }, + ], + }).compile(); + + service = await module.get(AuthorizationReferenceService); + authorizationService = await module.get(AuthorizationService); + loader = await module.get(ReferenceLoader); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('checkPermissionByReferences', () => { + const setupData = () => { + const entityId = new ObjectId().toHexString(); + const userId = new ObjectId().toHexString(); + const context = AuthorizationContextBuilder.read([]); + const entityName = AuthorizableReferenceType.Course; + + return { context, entityId, userId, entityName }; + }; + + describe('when hasPermissionByReferences returns false', () => { + const setup = () => { + const { entityId, userId, context, entityName } = setupData(); + + const spy = jest.spyOn(service, 'hasPermissionByReferences').mockResolvedValueOnce(false); + + return { context, userId, entityId, entityName, spy }; + }; + + it('should reject with ForbiddenLoggableException', async () => { + const { context, userId, entityId, entityName, spy } = setup(); + + await expect(service.checkPermissionByReferences(userId, entityName, entityId, context)).rejects.toThrow( + new ForbiddenLoggableException(userId, entityName, context) + ); + + spy.mockRestore(); + }); + }); + + describe('when hasPermissionByReferences returns true', () => { + const setup = () => { + const { entityId, userId, context, entityName } = setupData(); + + const spy = jest.spyOn(service, 'hasPermissionByReferences').mockResolvedValueOnce(true); + + return { context, userId, entityId, entityName, spy }; + }; + + it('should resolve without error', async () => { + const { context, userId, entityId, entityName, spy } = setup(); + + await expect(service.checkPermissionByReferences(userId, entityName, entityId, context)).resolves.not.toThrow(); + + spy.mockRestore(); + }); + }); + }); + + describe('hasPermissionByReferences', () => { + const setupData = () => { + const entity = courseFactory.buildWithId(); + const user = userFactory.buildWithId(); + const context = AuthorizationContextBuilder.read([]); + const entityName = AuthorizableReferenceType.Course; + + return { context, entity, user, entityName }; + }; + + describe('when loader throws an error', () => { + const setup = () => { + const { entity, user, context, entityName } = setupData(); + + loader.loadAuthorizableObject.mockRejectedValueOnce(new NotFoundException()); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.hasPermission.mockReturnValueOnce(true); + + return { context, userId: user.id, entityId: entity.id, entityName }; + }; + + it('should reject with this error', async () => { + const { context, userId, entityId, entityName } = setup(); + + await expect(service.hasPermissionByReferences(userId, entityName, entityId, context)).rejects.toThrow( + new NotFoundException() + ); + }); + }); + + describe('when authorizationService throws an error', () => { + const setup = () => { + const { entity, user, context, entityName } = setupData(); + + loader.loadAuthorizableObject.mockRejectedValueOnce(entity); + authorizationService.getUserWithPermissions.mockRejectedValueOnce(new NotFoundException()); + authorizationService.hasPermission.mockReturnValueOnce(true); + + return { context, userId: user.id, entityId: entity.id, entityName }; + }; + + it('should reject with this error', async () => { + const { context, userId, entityId, entityName } = setup(); + + await expect(service.hasPermissionByReferences(userId, entityName, entityId, context)).rejects.toThrow( + new NotFoundException() + ); + }); + }); + + describe('when loader can load entites and authorization resolve with true', () => { + const setup = () => { + const { entity, user, context, entityName } = setupData(); + + loader.loadAuthorizableObject.mockResolvedValueOnce(entity); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.hasPermission.mockReturnValueOnce(true); + + return { context, userId: user.id, entityId: entity.id, entityName }; + }; + + it('should resolve to true', async () => { + const { context, userId, entityId, entityName } = setup(); + + const result = await service.hasPermissionByReferences(userId, entityName, entityId, context); + + expect(result).toBe(true); + }); + }); + + describe('when loader can load entities and authorization resolve with false', () => { + const setup = () => { + const { entity, user, context, entityName } = setupData(); + + loader.loadAuthorizableObject.mockResolvedValueOnce(entity); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.hasPermission.mockReturnValueOnce(false); + + return { context, userId: user.id, entityId: entity.id, entityName }; + }; + + it('should resolve to false', async () => { + const { context, userId, entityId, entityName } = setup(); + + const result = await service.hasPermissionByReferences(userId, entityName, entityId, context); + + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authorization/domain/service/authorization-reference.service.ts b/apps/server/src/modules/authorization/domain/service/authorization-reference.service.ts new file mode 100644 index 00000000000..814df9378da --- /dev/null +++ b/apps/server/src/modules/authorization/domain/service/authorization-reference.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { ReferenceLoader } from './reference.loader'; +import { AuthorizationContext, AuthorizableReferenceType } from '../type'; +import { ForbiddenLoggableException } from '../error'; +import { AuthorizationService } from './authorization.service'; + +/** + * Should by use only internal in authorization module. See ticket: BC-3990 + */ +@Injectable() +export class AuthorizationReferenceService { + constructor(private readonly loader: ReferenceLoader, private readonly authorizationService: AuthorizationService) {} + + public async checkPermissionByReferences( + userId: EntityId, + entityName: AuthorizableReferenceType, + entityId: EntityId, + context: AuthorizationContext + ): Promise { + if (!(await this.hasPermissionByReferences(userId, entityName, entityId, context))) { + throw new ForbiddenLoggableException(userId, entityName, context); + } + } + + public async hasPermissionByReferences( + userId: EntityId, + entityName: AuthorizableReferenceType, + entityId: EntityId, + context: AuthorizationContext + ): Promise { + const [user, object] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.loader.loadAuthorizableObject(entityName, entityId), + ]); + + const hasPermission = this.authorizationService.hasPermission(user, object, context); + + return hasPermission; + } +} diff --git a/apps/server/src/modules/authorization/authorization.helper.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts similarity index 100% rename from apps/server/src/modules/authorization/authorization.helper.spec.ts rename to apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts diff --git a/apps/server/src/modules/authorization/authorization.helper.ts b/apps/server/src/modules/authorization/domain/service/authorization.helper.ts similarity index 100% rename from apps/server/src/modules/authorization/authorization.helper.ts rename to apps/server/src/modules/authorization/domain/service/authorization.helper.ts diff --git a/apps/server/src/modules/authorization/authorization.service.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization.service.spec.ts similarity index 60% rename from apps/server/src/modules/authorization/authorization.service.spec.ts rename to apps/server/src/modules/authorization/domain/service/authorization.service.spec.ts index 766c2c84d23..f113c64472c 100644 --- a/apps/server/src/modules/authorization/authorization.service.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/authorization.service.spec.ts @@ -1,33 +1,34 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ForbiddenException, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; +import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; import { setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationContextBuilder } from './authorization-context.builder'; +import { UserRepo } from '@shared/repo'; +import { AuthorizationContextBuilder } from '../mapper'; import { AuthorizationHelper } from './authorization.helper'; import { AuthorizationService } from './authorization.service'; -import { ForbiddenLoggableException } from './errors/forbidden.loggable-exception'; +import { ForbiddenLoggableException } from '../error'; import { ReferenceLoader } from './reference.loader'; import { RuleManager } from './rule-manager'; -import { AuthorizableReferenceType, Rule } from './types'; +import { Rule } from '../type'; -describe('AuthorizationService', () => { - class TestRule implements Rule { - constructor(private returnValueOfhasPermission: boolean) {} +class TestRule implements Rule { + constructor(private returnValueOfhasPermission: boolean) {} - isApplicable(): boolean { - return true; - } + isApplicable(): boolean { + return true; + } - hasPermission(): boolean { - return this.returnValueOfhasPermission; - } + hasPermission(): boolean { + return this.returnValueOfhasPermission; } +} +describe('AuthorizationService', () => { let service: AuthorizationService; let ruleManager: DeepMocked; - let loader: DeepMocked; let authorizationHelper: DeepMocked; + let userRepo: DeepMocked; const testPermission = 'CAN_TEST' as Permission; @@ -49,13 +50,17 @@ describe('AuthorizationService', () => { provide: AuthorizationHelper, useValue: createMock(), }, + { + provide: UserRepo, + useValue: createMock(), + }, ], }).compile(); service = await module.get(AuthorizationService); ruleManager = await module.get(RuleManager); - loader = await module.get(ReferenceLoader); authorizationHelper = await module.get(AuthorizationHelper); + userRepo = await module.get(UserRepo); }); afterEach(() => { @@ -66,7 +71,7 @@ describe('AuthorizationService', () => { describe('when hasPermission returns false', () => { const setup = () => { const context = AuthorizationContextBuilder.read([]); - const user = userFactory.build(); + const user = userFactory.buildWithId(); const spy = jest.spyOn(service, 'hasPermission').mockReturnValueOnce(false); @@ -85,7 +90,7 @@ describe('AuthorizationService', () => { describe('when hasPermission returns true', () => { const setup = () => { const context = AuthorizationContextBuilder.read([]); - const user = userFactory.build(); + const user = userFactory.buildWithId(); const spy = jest.spyOn(service, 'hasPermission').mockReturnValueOnce(true); @@ -106,7 +111,7 @@ describe('AuthorizationService', () => { describe('when the selected rule returns false', () => { const setup = () => { const context = AuthorizationContextBuilder.read([]); - const user = userFactory.build(); + const user = userFactory.buildWithId(); const testRule = new TestRule(false); ruleManager.selectRule.mockReturnValueOnce(testRule); @@ -126,7 +131,7 @@ describe('AuthorizationService', () => { describe('when the selected rule returns true', () => { const setup = () => { const context = AuthorizationContextBuilder.read([]); - const user = userFactory.build(); + const user = userFactory.buildWithId(); const testRule = new TestRule(true); ruleManager.selectRule.mockReturnValueOnce(testRule); @@ -144,123 +149,10 @@ describe('AuthorizationService', () => { }); }); - describe('checkPermissionByReferences', () => { - describe('when hasPermissionByReferences returns false', () => { - const setup = () => { - const context = AuthorizationContextBuilder.read([]); - const userId = 'test'; - const entityId = 'test'; - const entityName = AuthorizableReferenceType.Course; - - const spy = jest.spyOn(service, 'hasPermissionByReferences').mockResolvedValueOnce(false); - - return { context, userId, entityId, entityName, spy }; - }; - - it('should reject with ForbiddenLoggableException', async () => { - const { context, userId, entityId, entityName, spy } = setup(); - - await expect(service.checkPermissionByReferences(userId, entityName, entityId, context)).rejects.toThrow( - ForbiddenLoggableException - ); - - spy.mockRestore(); - }); - }); - - describe('when hasPermissionByReferences returns true', () => { - const setup = () => { - const context = AuthorizationContextBuilder.read([]); - const userId = 'test'; - const entityId = 'test'; - const entityName = AuthorizableReferenceType.Course; - - const spy = jest.spyOn(service, 'hasPermissionByReferences').mockResolvedValueOnce(true); - - return { context, userId, entityId, entityName, spy }; - }; - - it('should resolve', async () => { - const { context, userId, entityId, entityName, spy } = setup(); - - await expect(service.checkPermissionByReferences(userId, entityName, entityId, context)).resolves.not.toThrow(); - - spy.mockRestore(); - }); - }); - }); - - describe('hasPermissionByReferences', () => { - describe('when loader throws an error', () => { - const setup = () => { - const context = AuthorizationContextBuilder.read([]); - const userId = 'test'; - const entityId = 'test'; - const entityName = AuthorizableReferenceType.Course; - - loader.loadAuthorizableObject.mockRejectedValueOnce(InternalServerErrorException); - - return { context, userId, entityId, entityName }; - }; - - it('should reject with ForbiddenException', async () => { - const { context, userId, entityId, entityName } = setup(); - - await expect(service.hasPermissionByReferences(userId, entityName, entityId, context)).rejects.toThrow( - ForbiddenException - ); - }); - }); - - describe('when the selected rule returns true', () => { - const setup = () => { - const context = AuthorizationContextBuilder.read([]); - const userId = 'test'; - const entityId = 'test'; - const entityName = AuthorizableReferenceType.Course; - const testRule = new TestRule(true); - - ruleManager.selectRule.mockReturnValueOnce(testRule); - - return { context, userId, entityId, entityName }; - }; - - it('should resolve to true', async () => { - const { context, userId, entityId, entityName } = setup(); - - const result = await service.hasPermissionByReferences(userId, entityName, entityId, context); - - expect(result).toBe(true); - }); - }); - - describe('when the selected rule returns false', () => { - const setup = () => { - const context = AuthorizationContextBuilder.read([]); - const userId = 'test'; - const entityId = 'test'; - const entityName = AuthorizableReferenceType.Course; - const testRule = new TestRule(false); - - ruleManager.selectRule.mockReturnValueOnce(testRule); - - return { context, userId, entityId, entityName }; - }; - - it('should resolve to false', async () => { - const { context, userId, entityId, entityName } = setup(); - - const result = await service.hasPermissionByReferences(userId, entityName, entityId, context); - - expect(result).toBe(false); - }); - }); - }); - describe('checkAllPermissions', () => { describe('when helper method returns false', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasAllPermissions.mockReturnValueOnce(false); @@ -277,7 +169,7 @@ describe('AuthorizationService', () => { describe('when helper method returns true', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); @@ -296,7 +188,7 @@ describe('AuthorizationService', () => { describe('hasAllPermissions', () => { describe('when helper method returns false', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasAllPermissions.mockReturnValueOnce(false); @@ -315,7 +207,7 @@ describe('AuthorizationService', () => { describe('when helper method returns true', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); @@ -336,7 +228,7 @@ describe('AuthorizationService', () => { describe('checkOneOfPermissions', () => { describe('when helper method returns false', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasOneOfPermissions.mockReturnValueOnce(false); @@ -353,7 +245,7 @@ describe('AuthorizationService', () => { describe('when helper method returns true', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasOneOfPermissions.mockReturnValueOnce(true); @@ -372,7 +264,7 @@ describe('AuthorizationService', () => { describe('hasOneOfPermissions', () => { describe('when helper method returns false', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasOneOfPermissions.mockReturnValueOnce(false); @@ -391,7 +283,7 @@ describe('AuthorizationService', () => { describe('when helper method returns true', () => { const setup = () => { - const user = userFactory.build(); + const user = userFactory.buildWithId(); const requiredPermissions = [testPermission]; authorizationHelper.hasOneOfPermissions.mockReturnValueOnce(true); @@ -410,12 +302,18 @@ describe('AuthorizationService', () => { }); describe('getUserWithPermissions', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + userRepo.findById.mockResolvedValueOnce(user); + + return { user }; + }; + it('should return user received from loader', async () => { - const userId = 'test'; - const user = userFactory.build(); - loader.getUserWithPermissions.mockResolvedValueOnce(user); + const { user } = setup(); - const result = await service.getUserWithPermissions(userId); + const result = await service.getUserWithPermissions(user.id); expect(result).toEqual(user); }); diff --git a/apps/server/src/modules/authorization/domain/service/authorization.service.ts b/apps/server/src/modules/authorization/domain/service/authorization.service.ts new file mode 100644 index 00000000000..5218dffda81 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/service/authorization.service.ts @@ -0,0 +1,59 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { BaseDO, EntityId, User } from '@shared/domain'; +import { AuthorizableObject } from '@shared/domain/domain-object'; +import { UserRepo } from '@shared/repo'; +import { AuthorizationHelper } from './authorization.helper'; +import { ForbiddenLoggableException } from '../error'; +import { RuleManager } from './rule-manager'; +import { AuthorizationContext } from '../type'; + +@Injectable() +export class AuthorizationService { + constructor( + private readonly ruleManager: RuleManager, + private readonly authorizationHelper: AuthorizationHelper, + private readonly userRepo: UserRepo + ) {} + + public checkPermission(user: User, object: AuthorizableObject | BaseDO, context: AuthorizationContext): void { + if (!this.hasPermission(user, object, context)) { + throw new ForbiddenLoggableException(user.id, object.constructor.name, context); + } + } + + public hasPermission(user: User, object: AuthorizableObject | BaseDO, context: AuthorizationContext): boolean { + const rule = this.ruleManager.selectRule(user, object, context); + const hasPermission = rule.hasPermission(user, object, context); + + return hasPermission; + } + + public checkAllPermissions(user: User, requiredPermissions: string[]): void { + if (!this.authorizationHelper.hasAllPermissions(user, requiredPermissions)) { + // TODO: Should be ForbiddenLoggableException + throw new UnauthorizedException(); + } + } + + public hasAllPermissions(user: User, requiredPermissions: string[]): boolean { + return this.authorizationHelper.hasAllPermissions(user, requiredPermissions); + } + + public checkOneOfPermissions(user: User, requiredPermissions: string[]): void { + if (!this.authorizationHelper.hasOneOfPermissions(user, requiredPermissions)) { + // TODO: Should be ForbiddenLoggableException + throw new UnauthorizedException(); + } + } + + public hasOneOfPermissions(user: User, requiredPermissions: string[]): boolean { + return this.authorizationHelper.hasOneOfPermissions(user, requiredPermissions); + } + + public async getUserWithPermissions(userId: EntityId): Promise { + // replace with service method getUserWithPermissions BC-5069 + const userWithPopulatedRoles = await this.userRepo.findById(userId, true); + + return userWithPopulatedRoles; + } +} diff --git a/apps/server/src/modules/authorization/domain/service/index.ts b/apps/server/src/modules/authorization/domain/service/index.ts new file mode 100644 index 00000000000..4175cc4b7a7 --- /dev/null +++ b/apps/server/src/modules/authorization/domain/service/index.ts @@ -0,0 +1,5 @@ +export * from './authorization.service'; +export * from './authorization.helper'; +export * from './rule-manager'; +export * from './authorization-reference.service'; +export * from './reference.loader'; diff --git a/apps/server/src/modules/authorization/reference.loader.spec.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts similarity index 87% rename from apps/server/src/modules/authorization/reference.loader.spec.ts rename to apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts index 9bba78b1880..0403ebcbfd5 100644 --- a/apps/server/src/modules/authorization/reference.loader.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.spec.ts @@ -14,11 +14,11 @@ import { TeamsRepo, UserRepo, } from '@shared/repo'; -import { roleFactory, setupEntities, userFactory } from '@shared/testing'; +import { setupEntities, userFactory } from '@shared/testing'; import { BoardDoAuthorizableService } from '@src/modules/board'; import { ContextExternalToolAuthorizableService } from '@src/modules/tool/context-external-tool/service/context-external-tool-authorizable.service'; import { ReferenceLoader } from './reference.loader'; -import { AuthorizableReferenceType } from './types'; +import { AuthorizableReferenceType } from '../type'; describe('reference.loader', () => { let service: ReferenceLoader; @@ -138,7 +138,7 @@ describe('reference.loader', () => { it('should call userRepo.findById', async () => { await service.loadAuthorizableObject(AuthorizableReferenceType.User, entityId); - expect(userRepo.findById).toBeCalledWith(entityId, true); + expect(userRepo.findById).toBeCalledWith(entityId); }); it('should call lessonRepo.findById', async () => { @@ -192,33 +192,4 @@ describe('reference.loader', () => { ).rejects.toThrow(NotImplementedException); }); }); - - describe('getUserWithPermissions', () => { - describe('when user successfully', () => { - const setup = () => { - const roles = [roleFactory.build()]; - const user = userFactory.buildWithId({ roles }); - userRepo.findById.mockResolvedValue(user); - return { - user, - }; - }; - - it('should call userRepo.findById with specific arguments', async () => { - const { user } = setup(); - - await service.getUserWithPermissions(user.id); - - expect(userRepo.findById).toBeCalledWith(user.id, true); - }); - - it('should return user', async () => { - const { user } = setup(); - - const result = await service.getUserWithPermissions(user.id); - - expect(result).toBe(user); - }); - }); - }); }); diff --git a/apps/server/src/modules/authorization/reference.loader.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.ts similarity index 88% rename from apps/server/src/modules/authorization/reference.loader.ts rename to apps/server/src/modules/authorization/domain/service/reference.loader.ts index 9afe013fd24..5c38963c6f5 100644 --- a/apps/server/src/modules/authorization/reference.loader.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.ts @@ -1,5 +1,5 @@ import { Injectable, NotImplementedException } from '@nestjs/common'; -import { BaseDO, EntityId, User } from '@shared/domain'; +import { BaseDO, EntityId } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; import { CourseGroupRepo, @@ -12,11 +12,10 @@ import { TeamsRepo, UserRepo, } from '@shared/repo'; -import { BoardDoAuthorizableService } from '@src/modules/board/service'; +import { BoardDoAuthorizableService } from '@src/modules/board'; import { ContextExternalToolAuthorizableService } from '@src/modules/tool/context-external-tool/service'; -import { AuthorizableReferenceType } from './types'; +import { AuthorizableReferenceType } from '../type'; -// replace later with general "base" do-repo type RepoType = | TaskRepo | CourseRepo @@ -55,7 +54,7 @@ export class ReferenceLoader { this.repos.set(AuthorizableReferenceType.Task, { repo: this.taskRepo }); this.repos.set(AuthorizableReferenceType.Course, { repo: this.courseRepo }); this.repos.set(AuthorizableReferenceType.CourseGroup, { repo: this.courseGroupRepo }); - this.repos.set(AuthorizableReferenceType.User, { repo: this.userRepo, populate: true }); + this.repos.set(AuthorizableReferenceType.User, { repo: this.userRepo }); this.repos.set(AuthorizableReferenceType.School, { repo: this.schoolRepo }); this.repos.set(AuthorizableReferenceType.Lesson, { repo: this.lessonRepo }); this.repos.set(AuthorizableReferenceType.Team, { repo: this.teamsRepo, populate: true }); @@ -90,10 +89,4 @@ export class ReferenceLoader { return object; } - - async getUserWithPermissions(userId: EntityId): Promise { - const user = await this.userRepo.findById(userId, true); - - return user; - } } diff --git a/apps/server/src/modules/authorization/rule-manager.spec.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts similarity index 97% rename from apps/server/src/modules/authorization/rule-manager.spec.ts rename to apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts index 0a2b90c7639..78ef313ade1 100644 --- a/apps/server/src/modules/authorization/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts @@ -1,6 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '../mapper'; import { BoardDoRule, ContextExternalToolRule, @@ -13,10 +15,8 @@ import { TaskRule, TeamRule, UserRule, -} from '@shared/domain/rules'; -import { UserLoginMigrationRule } from '@shared/domain/rules/user-login-migration.rule'; -import { courseFactory, setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationContextBuilder } from './authorization-context.builder'; + UserLoginMigrationRule, +} from '../rules'; import { RuleManager } from './rule-manager'; describe('RuleManager', () => { diff --git a/apps/server/src/modules/authorization/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts similarity index 88% rename from apps/server/src/modules/authorization/rule-manager.ts rename to apps/server/src/modules/authorization/domain/service/rule-manager.ts index 3aece68402a..77d09f284c2 100644 --- a/apps/server/src/modules/authorization/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -1,21 +1,21 @@ import { Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { BaseDO, User } from '@shared/domain'; import { AuthorizableObject } from '@shared/domain/domain-object'; // fix import when it is avaible +import type { AuthorizationContext, Rule } from '../type'; import { BoardDoRule, + ContextExternalToolRule, CourseGroupRule, CourseRule, + LegacySchoolRule, LessonRule, SchoolExternalToolRule, - LegacySchoolRule, SubmissionRule, TaskRule, TeamRule, + UserLoginMigrationRule, UserRule, -} from '@shared/domain/rules'; -import { ContextExternalToolRule } from '@shared/domain/rules/context-external-tool.rule'; -import { UserLoginMigrationRule } from '@shared/domain/rules/user-login-migration.rule'; -import { AuthorizationContext, Rule } from './types'; +} from '../rules'; @Injectable() export class RuleManager { diff --git a/apps/server/src/modules/authorization/types/action.enum.ts b/apps/server/src/modules/authorization/domain/type/action.enum.ts similarity index 100% rename from apps/server/src/modules/authorization/types/action.enum.ts rename to apps/server/src/modules/authorization/domain/type/action.enum.ts diff --git a/apps/server/src/modules/authorization/types/allowed-authorization-object-type.enum.ts b/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts similarity index 100% rename from apps/server/src/modules/authorization/types/allowed-authorization-object-type.enum.ts rename to apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts diff --git a/apps/server/src/modules/authorization/types/authorization-context.interface.ts b/apps/server/src/modules/authorization/domain/type/authorization-context.interface.ts similarity index 100% rename from apps/server/src/modules/authorization/types/authorization-context.interface.ts rename to apps/server/src/modules/authorization/domain/type/authorization-context.interface.ts diff --git a/apps/server/src/modules/authorization/types/authorization-loader-service.ts b/apps/server/src/modules/authorization/domain/type/authorization-loader-service.ts similarity index 100% rename from apps/server/src/modules/authorization/types/authorization-loader-service.ts rename to apps/server/src/modules/authorization/domain/type/authorization-loader-service.ts diff --git a/apps/server/src/modules/authorization/types/index.ts b/apps/server/src/modules/authorization/domain/type/index.ts similarity index 100% rename from apps/server/src/modules/authorization/types/index.ts rename to apps/server/src/modules/authorization/domain/type/index.ts index 92e7b0c8bf5..b1942491098 100644 --- a/apps/server/src/modules/authorization/types/index.ts +++ b/apps/server/src/modules/authorization/domain/type/index.ts @@ -1,5 +1,5 @@ export * from './action.enum'; export * from './authorization-context.interface'; export * from './rule.interface'; -export * from './allowed-authorization-object-type.enum'; export * from './authorization-loader-service'; +export * from './allowed-authorization-object-type.enum'; diff --git a/apps/server/src/modules/authorization/types/rule.interface.ts b/apps/server/src/modules/authorization/domain/type/rule.interface.ts similarity index 100% rename from apps/server/src/modules/authorization/types/rule.interface.ts rename to apps/server/src/modules/authorization/domain/type/rule.interface.ts diff --git a/apps/server/src/modules/authorization/index.ts b/apps/server/src/modules/authorization/index.ts index bee8b7d4bb1..e129df2cd11 100644 --- a/apps/server/src/modules/authorization/index.ts +++ b/apps/server/src/modules/authorization/index.ts @@ -1,5 +1,15 @@ -export * from './authorization.module'; -export * from './authorization.service'; -export * from './authorization-context.builder'; -export * from './types'; -export * from './feathers'; +export { AuthorizationModule } from './authorization.module'; +export { + AuthorizationService, + AuthorizationHelper, + AuthorizationContextBuilder, + ForbiddenLoggableException, + Rule, + AuthorizationContext, + // Action should not be exported, but hard to solve for now. The AuthorizationContextBuilder is the prefared way + Action, + AuthorizationLoaderService, + AuthorizationLoaderServiceGeneric, +} from './domain'; +// Should not used anymore +export { FeathersAuthorizationService } from './feathers'; diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index 7c3194916ac..3e39fd32de3 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -9,8 +9,8 @@ import { EntityId, } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization/authorization.service'; -import { Action } from '@src/modules/authorization/types/action.enum'; +import { AuthorizationService } from '@src/modules/authorization/domain'; +import { Action } from '@src/modules/authorization'; import { CardService, ColumnBoardService, ColumnService } from '../service'; import { BoardDoAuthorizableService } from '../service/board-do-authorizable.service'; diff --git a/apps/server/src/modules/board/uc/card.uc.ts b/apps/server/src/modules/board/uc/card.uc.ts index 577f3a8b963..170469f0cc4 100644 --- a/apps/server/src/modules/board/uc/card.uc.ts +++ b/apps/server/src/modules/board/uc/card.uc.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; import { AnyBoardDo, AnyContentElementDo, Card, ContentElementType, EntityId } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization/authorization.service'; -import { Action } from '@src/modules/authorization/types/action.enum'; +import { AuthorizationService, Action } from '@src/modules/authorization'; import { BoardDoAuthorizableService, CardService, ContentElementService } from '../service'; @Injectable() diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index e5dc039168c..08357b01798 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -8,8 +8,7 @@ import { UserRoleEnum, } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; -import { Action } from '@src/modules/authorization/types/action.enum'; +import { AuthorizationService, Action } from '@src/modules/authorization'; import { AnyElementContentBody } from '../controller/dto'; import { BoardDoAuthorizableService, ContentElementService } from '../service'; import { SubmissionItemService } from '../service/submission-item.service'; diff --git a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts index 33bc8468fc9..5d06172acee 100644 --- a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts +++ b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts @@ -9,8 +9,7 @@ import { userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; -import { Action } from '@src/modules/authorization/types/action.enum'; +import { AuthorizationService, Action } from '@src/modules/authorization'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; import { SubmissionItemUc } from './submission-item.uc'; diff --git a/apps/server/src/modules/board/uc/submission-item.uc.ts b/apps/server/src/modules/board/uc/submission-item.uc.ts index e59afa4b49b..67e7951673f 100644 --- a/apps/server/src/modules/board/uc/submission-item.uc.ts +++ b/apps/server/src/modules/board/uc/submission-item.uc.ts @@ -9,8 +9,7 @@ import { UserRoleEnum, } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; -import { Action } from '@src/modules/authorization/types/action.enum'; +import { AuthorizationService, Action } from '@src/modules/authorization'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; @Injectable() diff --git a/apps/server/src/modules/files-storage/README.md b/apps/server/src/modules/files-storage/README.md index 4bb6fb4ccb8..f44be536309 100644 --- a/apps/server/src/modules/files-storage/README.md +++ b/apps/server/src/modules/files-storage/README.md @@ -88,7 +88,7 @@ folder structure in S3 > schoolId/fileRecordId > .trash/schoolId/fileRecordId (see: ## Goals and Ideas > ### Deleting Files) -### Authorisation Module +### Authorization Module The authorisation is solved by parents. Therefore it is required that the parent types must be known to the authentication service. diff --git a/apps/server/src/modules/files-storage/files-storage-api.module.ts b/apps/server/src/modules/files-storage/files-storage-api.module.ts index 9d5283b47b7..aab383a158f 100644 --- a/apps/server/src/modules/files-storage/files-storage-api.module.ts +++ b/apps/server/src/modules/files-storage/files-storage-api.module.ts @@ -2,13 +2,13 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { CoreModule } from '@src/core'; import { AuthenticationModule } from '@src/modules/authentication/authentication.module'; -import { AuthorizationModule } from '@src/modules/authorization'; +import { AuthorizationReferenceModule } from '@src/modules/authorization/authorization-reference.module'; import { FileSecurityController, FilesStorageController } from './controller'; import { FilesStorageModule } from './files-storage.module'; import { FilesStorageUC } from './uc'; @Module({ - imports: [AuthorizationModule, FilesStorageModule, AuthenticationModule, CoreModule, HttpModule], + imports: [AuthorizationReferenceModule, FilesStorageModule, AuthenticationModule, CoreModule, HttpModule], controllers: [FilesStorageController, FileSecurityController], providers: [FilesStorageUC], }) diff --git a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts index 3165ec49021..a26103ae983 100644 --- a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts +++ b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts @@ -1,6 +1,6 @@ import { NotImplementedException } from '@nestjs/common'; import { fileRecordFactory, setupEntities } from '@shared/testing'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { DownloadFileParams, FileRecordListResponse, diff --git a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts index 3d298cd3b2e..9b30acd4ada 100644 --- a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts +++ b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts @@ -1,5 +1,5 @@ import { NotImplementedException, StreamableFile } from '@nestjs/common'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { plainToClass } from 'class-transformer'; import { DownloadFileParams, diff --git a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts index 5d4ab900549..612558e80c1 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts @@ -8,7 +8,8 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { Action, AuthorizationService } from '@src/modules/authorization'; +import { Action } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { CopyFileResponseBuilder } from '../mapper'; @@ -68,7 +69,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeEach(() => { jest.resetAllMocks(); @@ -97,8 +98,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -112,7 +113,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); }); @@ -134,7 +135,7 @@ describe('FilesStorageUC', () => { const fileResponse = CopyFileResponseBuilder.build(targetFile.id, sourceFile.id, targetFile.name); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copyFilesOfParent.mockResolvedValueOnce([[fileResponse], 1]); return { sourceParams, targetParams, userId, fileResponse }; @@ -145,7 +146,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyFilesOfParent(userId, sourceParams, targetParams); - expect(authorizationService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( 1, userId, sourceParams.parentType, @@ -159,7 +160,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyFilesOfParent(userId, sourceParams, targetParams); - expect(authorizationService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( 2, userId, targetParams.target.parentType, @@ -191,7 +192,7 @@ describe('FilesStorageUC', () => { const targetParams = createTargetParams(); const error = new ForbiddenException(); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error).mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error).mockResolvedValueOnce(); return { sourceParams, targetParams, userId, error }; }; @@ -210,7 +211,7 @@ describe('FilesStorageUC', () => { const targetParams = createTargetParams(); const error = new ForbiddenException(); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce().mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockRejectedValueOnce(error); return { sourceParams, targetParams, userId, error }; }; @@ -229,7 +230,9 @@ describe('FilesStorageUC', () => { const targetParams = createTargetParams(); const error = new ForbiddenException(); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error).mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error); return { sourceParams, targetParams, userId, error }; }; @@ -249,7 +252,7 @@ describe('FilesStorageUC', () => { const error = new Error('test'); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copyFilesOfParent.mockRejectedValueOnce(error); return { sourceParams, targetParams, userId, error }; @@ -289,7 +292,7 @@ describe('FilesStorageUC', () => { ); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copy.mockResolvedValueOnce([fileResponse]); return { singleFileParams, copyFileParams, userId, fileResponse, fileRecord }; @@ -308,7 +311,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyOneFile(userId, singleFileParams, copyFileParams); - expect(authorizationService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( 1, userId, fileRecord.parentType, @@ -322,7 +325,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.copyOneFile(userId, singleFileParams, copyFileParams); - expect(authorizationService.checkPermissionByReferences).toHaveBeenNthCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( 2, userId, copyFileParams.target.parentType, @@ -355,7 +358,7 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error).mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error).mockResolvedValueOnce(); return { singleFileParams, copyFileParams, userId, fileRecord, error }; }; @@ -375,7 +378,7 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce().mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockRejectedValueOnce(error); return { singleFileParams, copyFileParams, userId, fileRecord, error }; }; @@ -395,7 +398,9 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error).mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error); return { singleFileParams, copyFileParams, userId, fileRecord, error }; }; @@ -434,7 +439,7 @@ describe('FilesStorageUC', () => { const error = new Error('test'); filesStorageService.getFileRecord.mockResolvedValue(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce().mockResolvedValueOnce(); filesStorageService.copy.mockRejectedValueOnce(error); return { singleFileParams, copyFileParams, userId, fileRecord, error }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts index eb13f830be6..fc461a50106 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts @@ -8,7 +8,7 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -57,7 +57,7 @@ describe('FilesStorageUC delete methods', () => { let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; let previewService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -82,8 +82,8 @@ describe('FilesStorageUC delete methods', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -97,7 +97,7 @@ describe('FilesStorageUC delete methods', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); previewService = module.get(PreviewService); }); @@ -122,7 +122,7 @@ describe('FilesStorageUC delete methods', () => { const fileRecord = fileRecords[0]; const mockedResult = [[fileRecord], 0] as Counted; - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.deleteFilesOfParent.mockResolvedValueOnce(mockedResult); return { params, userId, mockedResult, requestParams, fileRecord }; @@ -134,7 +134,7 @@ describe('FilesStorageUC delete methods', () => { await filesStorageUC.deleteFilesOfParent(userId, requestParams); - expect(authorizationService.checkPermissionByReferences).toBeCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( userId, allowedType, requestParams.parentId, @@ -171,7 +171,7 @@ describe('FilesStorageUC delete methods', () => { const setup = () => { const { requestParams, userId } = createParams(); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); return { requestParams, userId }; }; @@ -192,7 +192,7 @@ describe('FilesStorageUC delete methods', () => { const { requestParams, userId } = createParams(); const error = new Error('test'); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.deleteFilesOfParent.mockRejectedValueOnce(error); return { requestParams, userId, error }; @@ -214,7 +214,7 @@ describe('FilesStorageUC delete methods', () => { const requestParams = { fileRecordId: fileRecord.id, parentType: fileRecord.parentType }; filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.delete.mockResolvedValueOnce(); return { requestParams, userId, fileRecord }; @@ -227,7 +227,7 @@ describe('FilesStorageUC delete methods', () => { const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(requestParams.parentType); - expect(authorizationService.checkPermissionByReferences).toBeCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( userId, allowedType, fileRecord.parentId, @@ -301,7 +301,7 @@ describe('FilesStorageUC delete methods', () => { const requestParams = { fileRecordId: fileRecord.id, parentType: fileRecord.parentType }; filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); return { requestParams, userId }; }; @@ -322,7 +322,7 @@ describe('FilesStorageUC delete methods', () => { const error = new Error('test'); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.delete.mockRejectedValueOnce(error); return { requestParams, userId, error }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts index 3a2f6f1ac21..795939e5cb2 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -7,7 +7,7 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -43,7 +43,7 @@ describe('FilesStorageUC', () => { let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; let previewService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -72,8 +72,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -83,7 +83,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); previewService = module.get(PreviewService); }); @@ -143,7 +143,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.downloadPreview(userId, fileDownloadParams, previewParams); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(fileRecord.parentType); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, allowedType, fileRecord.parentId, @@ -190,7 +190,7 @@ describe('FilesStorageUC', () => { filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); const error = new ForbiddenException(); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); return { fileDownloadParams, userId, fileRecord, previewParams, error }; }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts index b1aa6d4b437..3e7fa61fd7f 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts @@ -7,7 +7,7 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -34,7 +34,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -59,8 +59,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -74,7 +74,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); }); @@ -99,7 +99,7 @@ describe('FilesStorageUC', () => { const fileResponse = createMock(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValue(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValue(); filesStorageService.download.mockResolvedValueOnce(fileResponse); return { fileDownloadParams, userId, fileRecord, fileResponse }; @@ -121,7 +121,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.download(userId, fileDownloadParams); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(fileRecord.parentType); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, allowedType, fileRecord.parentId, @@ -171,7 +171,7 @@ describe('FilesStorageUC', () => { const error = new ForbiddenException(); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); return { fileDownloadParams, userId, fileRecord }; }; @@ -190,7 +190,7 @@ describe('FilesStorageUC', () => { const error = new Error('test'); filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValue(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValue(); filesStorageService.download.mockRejectedValueOnce(error); return { fileDownloadParams, userId, error }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts index 02cdb82ded6..7f372a1fe80 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts @@ -6,7 +6,7 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -37,7 +37,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -62,8 +62,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -77,7 +77,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); }); @@ -100,7 +100,7 @@ describe('FilesStorageUC', () => { const { fileRecords, params } = buildFileRecordsWithParams(); filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); return { userId, params, fileRecords }; }; @@ -110,7 +110,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.getFileRecordsOfParent(userId, params); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, params.parentType, params.parentId, @@ -141,7 +141,7 @@ describe('FilesStorageUC', () => { const { fileRecords, params } = buildFileRecordsWithParams(); filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(new Error('Bla')); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new Error('Bla')); return { userId, params, fileRecords }; }; @@ -160,7 +160,7 @@ describe('FilesStorageUC', () => { const fileRecords = []; filesStorageService.getFileRecordsOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); return { userId, params, fileRecords }; }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts index be8a6d32561..e01e3116b79 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts @@ -7,7 +7,7 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { FileRecordParams, SingleFileParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -52,7 +52,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -77,8 +77,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -92,7 +92,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); }); @@ -113,7 +113,7 @@ describe('FilesStorageUC', () => { const setup = () => { const { params, userId, fileRecords } = buildFileRecordsWithParams(); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.restoreFilesOfParent.mockResolvedValueOnce([fileRecords, fileRecords.length]); return { params, userId, fileRecords }; @@ -125,7 +125,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.restoreFilesOfParent(userId, params); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, allowedType, params.parentId, @@ -153,7 +153,7 @@ describe('FilesStorageUC', () => { describe('WHEN user is not authorised ', () => { const setup = () => { const { params, userId } = buildFileRecordsWithParams(); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); return { params, userId }; }; @@ -189,7 +189,7 @@ describe('FilesStorageUC', () => { const { params, userId, fileRecord } = buildFileRecordWithParams(); filesStorageService.getFileRecordMarkedForDelete.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.restore.mockResolvedValueOnce(); return { params, userId, fileRecord }; @@ -209,7 +209,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.restoreOneFile(userId, params); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, allowedType, fileRecord.parentId, @@ -239,7 +239,7 @@ describe('FilesStorageUC', () => { const { params, userId, fileRecord } = buildFileRecordWithParams(); filesStorageService.getFileRecordMarkedForDelete.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); return { params, userId }; }; @@ -276,7 +276,7 @@ describe('FilesStorageUC', () => { const error = new Error('test'); filesStorageService.getFileRecordMarkedForDelete.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.restore.mockRejectedValueOnce(error); return { params, userId, error }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts index 57ec96cff61..19d9984eea8 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts @@ -6,7 +6,7 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { RenameFileParams, ScanResultParams, SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -31,7 +31,7 @@ describe('FilesStorageUC', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; beforeAll(async () => { await setupEntities([FileRecord]); @@ -56,8 +56,8 @@ describe('FilesStorageUC', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -71,7 +71,7 @@ describe('FilesStorageUC', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); filesStorageService = module.get(FilesStorageService); }); @@ -137,7 +137,7 @@ describe('FilesStorageUC', () => { const data: RenameFileParams = { fileName: 'test_new_name.txt' }; filesStorageService.getFileRecord.mockResolvedValueOnce(fileRecord); - authorizationService.checkPermissionByReferences.mockResolvedValueOnce(); + authorizationReferenceService.checkPermissionByReferences.mockResolvedValueOnce(); filesStorageService.patchFilename.mockResolvedValueOnce(fileRecord); return { userId, params, fileRecord, data }; @@ -155,7 +155,7 @@ describe('FilesStorageUC', () => { await filesStorageUC.patchFilename(userId, params, data); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, fileRecord.parentType, fileRecord.parentId, diff --git a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts index 903a2f2a6a6..ed7defb54fb 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts @@ -8,7 +8,8 @@ import { AntivirusService } from '@shared/infra/antivirus'; import { S3ClientAdapter } from '@shared/infra/s3-client'; import { AxiosHeadersKeyValue, axiosResponseFactory, fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { Action, AuthorizationService } from '@src/modules/authorization'; +import { Action } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { Request } from 'express'; import { of } from 'rxjs'; @@ -72,7 +73,7 @@ describe('FilesStorageUC upload methods', () => { let module: TestingModule; let filesStorageUC: FilesStorageUC; let filesStorageService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationReferenceService: DeepMocked; let httpService: DeepMocked; beforeAll(async () => { @@ -98,8 +99,8 @@ describe('FilesStorageUC upload methods', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: HttpService, @@ -113,7 +114,7 @@ describe('FilesStorageUC upload methods', () => { }).compile(); filesStorageUC = module.get(FilesStorageUC); - authorizationService = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); httpService = module.get(HttpService); filesStorageService = module.get(FilesStorageService); }); @@ -171,7 +172,7 @@ describe('FilesStorageUC upload methods', () => { await filesStorageUC.uploadFromUrl(userId, uploadFromUrlParams); - expect(authorizationService.checkPermissionByReferences).toBeCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( userId, uploadFromUrlParams.parentType, uploadFromUrlParams.parentId, @@ -218,7 +219,7 @@ describe('FilesStorageUC upload methods', () => { const setup = () => { const { userId, uploadFromUrlParams } = createUploadFromUrlParams(); const error = new Error('test'); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); return { uploadFromUrlParams, userId, error }; }; @@ -300,7 +301,7 @@ describe('FilesStorageUC upload methods', () => { await filesStorageUC.upload(userId, params, request); const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(params.parentType); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( userId, allowedType, params.parentId, @@ -365,7 +366,7 @@ describe('FilesStorageUC upload methods', () => { const request = createRequest(); const error = new ForbiddenException(); - authorizationService.checkPermissionByReferences.mockRejectedValueOnce(error); + authorizationReferenceService.checkPermissionByReferences.mockRejectedValueOnce(error); return { params, userId, request, error }; }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage.uc.ts b/apps/server/src/modules/files-storage/uc/files-storage.uc.ts index fa6a27202de..f5e6d372a6b 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage.uc.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage.uc.ts @@ -2,7 +2,8 @@ import { HttpService } from '@nestjs/axios'; import { Injectable, NotFoundException } from '@nestjs/common'; import { Counted, EntityId } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationContext } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import busboy from 'busboy'; import { Request } from 'express'; @@ -32,7 +33,7 @@ import { PreviewService } from '../service/preview.service'; export class FilesStorageUC { constructor( private logger: LegacyLogger, - private readonly authorizationService: AuthorizationService, + private readonly authorizationReferenceService: AuthorizationReferenceService, private readonly httpService: HttpService, private readonly filesStorageService: FilesStorageService, private readonly previewService: PreviewService @@ -47,7 +48,7 @@ export class FilesStorageUC { context: AuthorizationContext ) { const allowedType = FilesStorageMapper.mapToAllowedAuthorizationEntityType(parentType); - await this.authorizationService.checkPermissionByReferences(userId, allowedType, parentId, context); + await this.authorizationReferenceService.checkPermissionByReferences(userId, allowedType, parentId, context); } // upload diff --git a/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts index ed62a2b6ade..3b9f04e07db 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/rooms-copy-timeout.api.spec.ts @@ -1,5 +1,4 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { EntityManager } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -16,11 +15,15 @@ import { import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; import request from 'supertest'; +import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; +import { createMock } from '@golevelup/ts-jest'; +// config must be set outside before the server module is importat, otherwise the configuration is already set +const configBefore = Configuration.toObject({ plainSecrets: true }); Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); Configuration.set('INCOMING_REQUEST_TIMEOUT_COPY_API', 1); // eslint-disable-next-line import/first -import { ServerTestModule } from '@src/modules/server/server.module'; +import { ServerTestModule } from '@src/modules/server'; // This needs to be in a separate test file because of the above configuration. // When we find a way to mock the config, it should be moved alongside the other API tests. @@ -28,10 +31,8 @@ describe('Rooms copy (API)', () => { let app: INestApplication; let em: EntityManager; let currentUser: ICurrentUser; - let configBefore: IConfig; beforeAll(async () => { - configBefore = Configuration.toObject({ plainSecrets: true }); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], }) @@ -43,6 +44,8 @@ describe('Rooms copy (API)', () => { return true; }, }) + .overrideProvider(FilesStorageClientAdapterService) + .useValue(createMock()) .compile(); app = moduleFixture.createNestApplication(); diff --git a/apps/server/src/modules/learnroom/index.ts b/apps/server/src/modules/learnroom/index.ts index f2dc136ce5e..e4d907784d5 100644 --- a/apps/server/src/modules/learnroom/index.ts +++ b/apps/server/src/modules/learnroom/index.ts @@ -1,2 +1,3 @@ export * from './learnroom.module'; export * from './service/course-copy.service'; +export { CourseService } from './service'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index b72db2d7f59..81a514a0a7b 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { BoardRepo, CourseRepo, DashboardModelMapper, DashboardRepo, LessonRepo, UserRepo } from '@shared/repo'; import { AuthorizationModule } from '@src/modules/authorization'; +import { AuthorizationReferenceModule } from '@src/modules/authorization/authorization-reference.module'; import { CopyHelperModule } from '@src/modules/copy-helper'; import { LessonModule } from '@src/modules/lesson'; import { CourseController } from './controller/course.controller'; @@ -20,7 +21,7 @@ import { } from './uc'; @Module({ - imports: [AuthorizationModule, LessonModule, CopyHelperModule, LearnroomModule], + imports: [AuthorizationModule, LessonModule, CopyHelperModule, LearnroomModule, AuthorizationReferenceModule], controllers: [DashboardController, CourseController, RoomsController], providers: [ DashboardUc, diff --git a/apps/server/src/modules/learnroom/uc/course-copy.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-copy.uc.spec.ts index 2e7e9f739ad..33beee8c4db 100644 --- a/apps/server/src/modules/learnroom/uc/course-copy.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-copy.uc.spec.ts @@ -3,9 +3,9 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { ForbiddenException, InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain'; -import { boardFactory, courseFactory, setupEntities, userFactory } from '@shared/testing'; -import { Action, AuthorizableReferenceType } from '@src/modules/authorization'; -import { AuthorizationService } from '@src/modules/authorization/authorization.service'; +import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; +import { AuthorizationReferenceService, AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { CopyElementType, CopyStatusEnum } from '@src/modules/copy-helper'; import { CourseCopyService } from '../service'; import { CourseCopyUC } from './course-copy.uc'; @@ -13,7 +13,7 @@ import { CourseCopyUC } from './course-copy.uc'; describe('course copy uc', () => { let module: TestingModule; let uc: CourseCopyUC; - let authorization: DeepMocked; + let authorization: DeepMocked; let courseCopyService: DeepMocked; beforeAll(async () => { @@ -22,8 +22,8 @@ describe('course copy uc', () => { providers: [ CourseCopyUC, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: CourseCopyService, @@ -33,7 +33,7 @@ describe('course copy uc', () => { }).compile(); uc = module.get(CourseCopyUC); - authorization = module.get(AuthorizationService); + authorization = module.get(AuthorizationReferenceService); courseCopyService = module.get(CourseCopyService); }); @@ -41,91 +41,99 @@ describe('course copy uc', () => { await module.close(); }); - beforeEach(() => { - Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); - }); + // Please be careful the Configuration.set is effects all tests !!! describe('copy course', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const allCourses = courseFactory.buildList(3, { teachers: [user] }); - const course = allCourses[0]; - const originalBoard = boardFactory.build({ course }); - const courseCopy = courseFactory.buildWithId({ teachers: [user] }); - const boardCopy = boardFactory.build({ course: courseCopy }); - - authorization.getUserWithPermissions.mockResolvedValue(user); - const status = { - title: 'courseCopy', - type: CopyElementType.COURSE, - status: CopyStatusEnum.SUCCESS, - copyEntity: courseCopy, + describe('when authorization to course resolve with void and feature is deactivated', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', false); + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId({ teachers: [user] }); + + return { + userId: user.id, + courseId: course.id, + }; }; - courseCopyService.copyCourse.mockResolvedValue(status); + it('should throw if copy feature is deactivated', async () => { + const { courseId, userId } = setup(); + + await expect(uc.copyCourse(userId, courseId)).rejects.toThrowError( + new InternalServerErrorException('Copy Feature not enabled') + ); + }); + }); - return { - user, - course, - originalBoard, - courseCopy, - boardCopy, - allCourses, - status, + describe('when authorization to course resolve with void and feature is activated', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId({ teachers: [user] }); + const courseCopy = courseFactory.buildWithId({ teachers: [user] }); + + const status = { + title: 'courseCopy', + type: CopyElementType.COURSE, + status: CopyStatusEnum.SUCCESS, + copyEntity: courseCopy, + }; + + authorization.checkPermissionByReferences.mockResolvedValueOnce(); + courseCopyService.copyCourse.mockResolvedValueOnce(status); + + return { + userId: user.id, + courseId: course.id, + status, + }; }; - }; - it('should throw if copy feature is deactivated', async () => { - Configuration.set('FEATURE_COPY_SERVICE_ENABLED', false); - const { course, user } = setup(); - await expect(uc.copyCourse(user.id, course.id)).rejects.toThrowError(InternalServerErrorException); - }); + it('should check permission to create a course', async () => { + const { courseId, userId } = setup(); - it('should check permission to create a course', async () => { - const { course, user } = setup(); - await uc.copyCourse(user.id, course.id); - expect(authorization.checkPermissionByReferences).toBeCalledWith( - user.id, - AuthorizableReferenceType.Course, - course.id, - { - action: Action.write, - requiredPermissions: [Permission.COURSE_CREATE], - } - ); - }); + await uc.copyCourse(userId, courseId); - it('should call course copy service', async () => { - const { course, user } = setup(); - await uc.copyCourse(user.id, course.id); - expect(courseCopyService.copyCourse).toBeCalledWith({ userId: user.id, courseId: course.id }); - }); + const context = AuthorizationContextBuilder.write([Permission.COURSE_CREATE]); + expect(authorization.checkPermissionByReferences).toBeCalledWith( + userId, + AuthorizableReferenceType.Course, + courseId, + context + ); + }); + + it('should call course copy service', async () => { + const { courseId, userId } = setup(); + + await uc.copyCourse(userId, courseId); + + expect(courseCopyService.copyCourse).toBeCalledWith({ userId, courseId }); + }); + + it('should return status', async () => { + const { courseId, userId, status } = setup(); + + const result = await uc.copyCourse(userId, courseId); - it('should return status', async () => { - const { course, user, status } = setup(); - const result = await uc.copyCourse(user.id, course.id); - expect(result).toEqual(status); + expect(result).toEqual(status); + }); }); - describe('when access to course is forbidden', () => { + describe('when authorization to course throw a forbidden exception', () => { const setupWithCourseForbidden = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); const user = userFactory.buildWithId(); const course = courseFactory.buildWithId(); - authorization.checkPermissionByReferences.mockImplementation(() => { - throw new ForbiddenException(); - }); + authorization.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + return { user, course }; }; it('should throw ForbiddenException', async () => { const { course, user } = setupWithCourseForbidden(); - try { - await uc.copyCourse(user.id, course.id); - throw new Error('should have failed'); - } catch (err) { - expect(err).toBeInstanceOf(ForbiddenException); - } + await expect(uc.copyCourse(user.id, course.id)).rejects.toThrowError(new ForbiddenException()); }); }); }); diff --git a/apps/server/src/modules/learnroom/uc/course-copy.uc.ts b/apps/server/src/modules/learnroom/uc/course-copy.uc.ts index 0d806c36263..0f700d57f17 100644 --- a/apps/server/src/modules/learnroom/uc/course-copy.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-copy.uc.ts @@ -1,24 +1,23 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId, Permission } from '@shared/domain'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; +import { AuthorizationReferenceService, AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { CopyStatus } from '@src/modules/copy-helper'; import { CourseCopyService } from '../service'; @Injectable() export class CourseCopyUC { constructor( - private readonly authorization: AuthorizationService, + private readonly authorization: AuthorizationReferenceService, private readonly courseCopyService: CourseCopyService ) {} async copyCourse(userId: EntityId, courseId: EntityId): Promise { this.checkFeatureEnabled(); - await this.authorization.checkPermissionByReferences(userId, AuthorizableReferenceType.Course, courseId, { - action: Action.write, - requiredPermissions: [Permission.COURSE_CREATE], - }); + const context = AuthorizationContextBuilder.write([Permission.COURSE_CREATE]); + await this.authorization.checkPermissionByReferences(userId, AuthorizableReferenceType.Course, courseId, context); const result = await this.courseCopyService.copyCourse({ userId, courseId }); @@ -26,6 +25,7 @@ export class CourseCopyUC { } private checkFeatureEnabled() { + // @hpi-schul-cloud/commons is deprecated way to get envirements const enabled = Configuration.get('FEATURE_COPY_SERVICE_ENABLED') as boolean; if (!enabled) { throw new InternalServerErrorException('Copy Feature not enabled'); diff --git a/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts index 3d93827f06d..04e3d0de480 100644 --- a/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts @@ -1,7 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { CommonCartridgeExportService } from '@src/modules/learnroom/service/common-cartridge-export.service'; -import { AuthorizationService } from '@src/modules'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; +import { ObjectId } from 'bson'; +import { ForbiddenException } from '@nestjs/common'; import { CourseExportUc } from './course-export.uc'; import { CommonCartridgeVersion } from '../common-cartridge'; @@ -9,7 +11,7 @@ describe('CourseExportUc', () => { let module: TestingModule; let courseExportUc: CourseExportUc; let courseExportServiceMock: DeepMocked; - let authorizationServiceMock: DeepMocked; + let authorizationServiceMock: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -20,33 +22,86 @@ describe('CourseExportUc', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, ], }).compile(); courseExportUc = module.get(CourseExportUc); courseExportServiceMock = module.get(CommonCartridgeExportService); - authorizationServiceMock = module.get(AuthorizationService); + authorizationServiceMock = module.get(AuthorizationReferenceService); }); afterAll(async () => { await module.close(); }); + afterEach(() => { + // is needed to solve buffer test isolation + jest.resetAllMocks(); + }); + describe('exportCourse', () => { - const version: CommonCartridgeVersion = CommonCartridgeVersion.V_1_1_0; - it('should check for permissions', async () => { - authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); - await expect(courseExportUc.exportCourse('', '', version)).resolves.not.toThrow(); - expect(authorizationServiceMock.checkPermissionByReferences).toBeCalledTimes(1); + const setupParams = () => { + const courseId = new ObjectId().toHexString(); + const userId = new ObjectId().toHexString(); + const version: CommonCartridgeVersion = CommonCartridgeVersion.V_1_1_0; + + return { version, userId, courseId }; + }; + + describe('when authorization throw a error', () => { + const setup = () => { + authorizationServiceMock.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); + courseExportServiceMock.exportCourse.mockResolvedValueOnce(Buffer.from('')); + + return setupParams(); + }; + + it('should pass this error', async () => { + const { courseId, userId, version } = setup(); + + await expect(courseExportUc.exportCourse(courseId, userId, version)).rejects.toThrowError( + new ForbiddenException() + ); + }); + }); + + describe('when course export service throw a error', () => { + const setup = () => { + authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); + courseExportServiceMock.exportCourse.mockRejectedValueOnce(new Error()); + + return setupParams(); + }; + + it('should pass this error', async () => { + const { courseId, userId, version } = setup(); + + await expect(courseExportUc.exportCourse(courseId, userId, version)).rejects.toThrowError(new Error()); + }); }); - it('should return a binary file as buffer', async () => { - courseExportServiceMock.exportCourse.mockResolvedValueOnce(Buffer.from('')); - authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); + describe('when authorization resolve', () => { + const setup = () => { + authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); + courseExportServiceMock.exportCourse.mockResolvedValueOnce(Buffer.from('')); + + return setupParams(); + }; + + it('should check for permissions', async () => { + const { courseId, userId, version } = setup(); + + await expect(courseExportUc.exportCourse(courseId, userId, version)).resolves.not.toThrow(); + expect(authorizationServiceMock.checkPermissionByReferences).toBeCalledTimes(1); + }); + + it('should return a binary file as buffer', async () => { + const { courseId, userId, version } = setup(); - await expect(courseExportUc.exportCourse('', '', version)).resolves.toBeInstanceOf(Buffer); + await expect(courseExportUc.exportCourse(courseId, userId, version)).resolves.toBeInstanceOf(Buffer); + }); }); }); }); diff --git a/apps/server/src/modules/learnroom/uc/course-export.uc.ts b/apps/server/src/modules/learnroom/uc/course-export.uc.ts index 418812e0cd8..07e427c8fa8 100644 --- a/apps/server/src/modules/learnroom/uc/course-export.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-export.uc.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { EntityId, Permission } from '@shared/domain'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationContextBuilder } from '@src/modules/authorization'; +import { AuthorizationReferenceService, AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { CommonCartridgeVersion } from '../common-cartridge'; import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; @@ -8,14 +9,18 @@ import { CommonCartridgeExportService } from '../service/common-cartridge-export export class CourseExportUc { constructor( private readonly courseExportService: CommonCartridgeExportService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationReferenceService ) {} async exportCourse(courseId: EntityId, userId: EntityId, version: CommonCartridgeVersion): Promise { - await this.authorizationService.checkPermissionByReferences(userId, AuthorizableReferenceType.Course, courseId, { - action: Action.read, - requiredPermissions: [Permission.COURSE_EDIT], - }); + const context = AuthorizationContextBuilder.read([Permission.COURSE_EDIT]); + await this.authorizationService.checkPermissionByReferences( + userId, + AuthorizableReferenceType.Course, + courseId, + context + ); + return this.courseExportService.exportCourse(courseId, userId, version); } } diff --git a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts index a00e0be6c26..34d73449b4c 100644 --- a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.spec.ts @@ -3,13 +3,12 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException, InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BaseDO, Permission, User } from '@shared/domain'; +import { Permission } from '@shared/domain'; import { CourseRepo, LessonRepo, UserRepo } from '@shared/repo'; import { courseFactory, lessonFactory, setupEntities, userFactory } from '@shared/testing'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@src/modules/copy-helper'; import { EtherpadService, LessonCopyService } from '@src/modules/lesson/service'; -import { AuthorizableObject } from '@shared/domain/domain-object'; import { LessonCopyUC } from './lesson-copy.uc'; describe('lesson copy uc', () => { @@ -71,193 +70,286 @@ describe('lesson copy uc', () => { copyHelperService = module.get(CopyHelperService); }); - beforeEach(() => { - Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + afterEach(() => { jest.resetAllMocks(); }); + // Please be careful the Configuration.set is effects all tests !!! + describe('copy lesson', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const course = courseFactory.buildWithId({ teachers: [user] }); - const allLessons = lessonFactory.buildList(3, { course }); - const lesson = allLessons[0]; - - authorisation.getUserWithPermissions.mockResolvedValue(user); - lessonRepo.findById.mockResolvedValue(lesson); - lessonRepo.findAllByCourseIds.mockResolvedValue([allLessons, allLessons.length]); - lessonRepo.save.mockResolvedValue(undefined); - - courseRepo.findById.mockResolvedValue(course); - authorisation.hasPermission.mockReturnValue(true); - const copy = lessonFactory.buildWithId({ course }); - const status = { - title: 'lessonCopy', - type: CopyElementType.LESSON, - status: CopyStatusEnum.SUCCESS, - copyEntity: copy, - }; - lessonCopyService.copyLesson.mockResolvedValue(status); - lessonCopyService.updateCopiedEmbeddedTasks.mockReturnValue(status); - const lessonCopyName = 'Copy'; - copyHelperService.deriveCopyName.mockReturnValue(lessonCopyName); - - return { - user, - course, - lesson, - copy, - status, - lessonCopyName, - allLessons, - userId: user.id, + // missing tests + // when course repo is throw a error + // when lesson repo is throw a error + describe('when feature flag is disabled', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', false); + + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId({ teachers: [user] }); + const lesson = lessonFactory.build({ course }); + + const parentParams = { courseId: course.id, userId: user.id }; + + return { + userId: user.id, + lessonId: lesson.id, + parentParams, + }; }; - }; - - it('should throw if copy feature is deactivated', async () => { - Configuration.set('FEATURE_COPY_SERVICE_ENABLED', false); - const { course, user, lesson, userId } = setup(); - await expect(uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId })).rejects.toThrowError( - InternalServerErrorException - ); - }); - it('should fetch correct user', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(authorisation.getUserWithPermissions).toBeCalledWith(user.id); - }); + it('should throw if copy feature is deactivated', async () => { + const { userId, lessonId, parentParams } = setup(); - it('should fetch correct lesson', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(lessonRepo.findById).toBeCalledWith(lesson.id); + await expect(uc.copyLesson(userId, lessonId, parentParams)).rejects.toThrowError( + new InternalServerErrorException('Copy Feature not enabled') + ); + }); }); - it('should fetch destination course', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(courseRepo.findById).toBeCalledWith(course.id); - }); + describe('when authorization resolve and no destination course is passed', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); - it('should pass without destination course', async () => { - const { user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { userId }); - expect(courseRepo.findById).not.toHaveBeenCalled(); - }); + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId({ teachers: [user] }); + const allLessons = lessonFactory.buildList(3, { course }); + const copy = lessonFactory.buildWithId({ course }); + + const lesson = allLessons[0]; + const status = { + title: 'lessonCopy', + type: CopyElementType.LESSON, + status: CopyStatusEnum.SUCCESS, + copyEntity: copy, + }; + const lessonCopyName = 'Copy'; + const parentParams = { userId: user.id }; + + authorisation.getUserWithPermissions.mockResolvedValueOnce(user); + authorisation.hasPermission.mockReturnValue(true); + + lessonRepo.findById.mockResolvedValueOnce(lesson); + lessonRepo.findAllByCourseIds.mockResolvedValueOnce([allLessons, allLessons.length]); + courseRepo.findById.mockResolvedValueOnce(course); + + lessonCopyService.copyLesson.mockResolvedValueOnce(status); + copyHelperService.deriveCopyName.mockReturnValueOnce(lessonCopyName); + + return { + user, + userId: user.id, + course, + courseId: course.id, + lessonId: lesson.id, + parentParams, + }; + }; + + it('should pass without destination course', async () => { + const { lessonId, userId, parentParams } = setup(); - it('should check authorisation for lesson', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(authorisation.hasPermission).toBeCalledWith(user, lesson, { - action: Action.read, - requiredPermissions: [Permission.TOPIC_CREATE], + await uc.copyLesson(userId, lessonId, parentParams); + + expect(courseRepo.findById).not.toHaveBeenCalled(); }); - }); - it('should check authorisation for destination course', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(authorisation.checkPermissionByReferences).toBeCalledWith( - user.id, - AuthorizableReferenceType.Course, - course.id, - { - action: Action.write, - requiredPermissions: [], - } - ); - }); + it('should pass authorisation check without destination course', async () => { + const { course, user, lessonId, userId, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); - it('should pass authorisation check without destination course', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { userId }); - expect(authorisation.hasPermission).not.toBeCalledWith(user, course, { - action: Action.write, - requiredPermissions: [], + const context = AuthorizationContextBuilder.write([]); + expect(authorisation.hasPermission).not.toBeCalledWith(user, course, context); }); }); - it('should call copy service', async () => { - const { course, user, lesson, lessonCopyName, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(lessonCopyService.copyLesson).toBeCalledWith({ - originalLessonId: lesson.id, - destinationCourse: course, - user, - copyName: lessonCopyName, + describe('when authorization resolve', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId({ teachers: [user] }); + const allLessons = lessonFactory.buildList(3, { course }); + const copy = lessonFactory.buildWithId({ course }); + + const lesson = allLessons[0]; + const status = { + title: 'lessonCopy', + type: CopyElementType.LESSON, + status: CopyStatusEnum.SUCCESS, + copyEntity: copy, + }; + const lessonCopyName = 'Copy'; + const parentParams = { courseId: course.id, userId: user.id }; + + authorisation.getUserWithPermissions.mockResolvedValueOnce(user); + authorisation.hasPermission.mockReturnValue(true); + + lessonRepo.findById.mockResolvedValueOnce(lesson); + lessonRepo.findAllByCourseIds.mockResolvedValueOnce([allLessons, allLessons.length]); + courseRepo.findById.mockResolvedValueOnce(course); + + lessonCopyService.copyLesson.mockResolvedValueOnce(status); + // lessonCopyService.updateCopiedEmbeddedTasks.mockReturnValue(status); + copyHelperService.deriveCopyName.mockReturnValueOnce(lessonCopyName); + + return { + user, + userId: user.id, + course, + courseId: course.id, + lesson, + lessonId: lesson.id, + parentParams, + copy, + status, + lessonCopyName, + allLessons, + }; + }; + + it('should fetch correct user', async () => { + const { lessonId, userId, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + expect(authorisation.getUserWithPermissions).toBeCalledWith(userId); }); - }); - it('should return status', async () => { - const { course, user, lesson, status, userId } = setup(); - const result = await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(result).toEqual(status); - }); + it('should fetch correct lesson', async () => { + const { lessonId, userId, parentParams } = setup(); - it('should use copyHelperService', async () => { - const { course, user, lesson, allLessons, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - const existingNames = allLessons.map((l) => l.name); - expect(copyHelperService.deriveCopyName).toHaveBeenCalledWith(lesson.name, existingNames); - }); + await uc.copyLesson(userId, lessonId, parentParams); + + expect(lessonRepo.findById).toBeCalledWith(lessonId); + }); + + it('should fetch destination course', async () => { + const { course, lessonId, userId, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + expect(courseRepo.findById).toBeCalledWith(course.id); + }); + + it('should check authorisation for lesson', async () => { + const { lessonId, userId, user, lesson, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + const context = AuthorizationContextBuilder.read([Permission.TOPIC_CREATE]); + expect(authorisation.hasPermission).toBeCalledWith(user, lesson, context); + }); + + it('should check authorisation for destination course', async () => { + const { course, user, lessonId, userId, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + const context = AuthorizationContextBuilder.write([]); + expect(authorisation.checkPermission).toBeCalledWith(user, course, context); + }); + + it('should call copy service', async () => { + const { course, user, lessonId, lessonCopyName, userId, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + expect(lessonCopyService.copyLesson).toBeCalledWith({ + originalLessonId: lessonId, + destinationCourse: course, + user, + copyName: lessonCopyName, + }); + }); - it('should use findAllByCourseIds to determine existing lesson names', async () => { - const { course, user, lesson, userId } = setup(); - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId }); - expect(lessonRepo.findAllByCourseIds).toHaveBeenCalledWith([course.id]); + it('should return status', async () => { + const { lessonId, status, userId, parentParams } = setup(); + + const result = await uc.copyLesson(userId, lessonId, parentParams); + + expect(result).toEqual(status); + }); + + it('should use copyHelperService', async () => { + const { lessonId, allLessons, userId, lesson, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + const existingNames = allLessons.map((l) => l.name); + expect(copyHelperService.deriveCopyName).toHaveBeenCalledWith(lesson.name, existingNames); + }); + + it('should use findAllByCourseIds to determine existing lesson names', async () => { + const { courseId, userId, lessonId, parentParams } = setup(); + + await uc.copyLesson(userId, lessonId, parentParams); + + expect(lessonRepo.findAllByCourseIds).toHaveBeenCalledWith([courseId]); + }); }); - describe('when access to lesson is forbidden', () => { - const setupWithLessonForbidden = () => { + describe('when authorization of lesson throw forbidden exception', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + const user = userFactory.buildWithId(); const course = courseFactory.buildWithId(); const lesson = lessonFactory.buildWithId(); - userRepo.findById.mockResolvedValue(user); - lessonRepo.findById.mockResolvedValue(lesson); - // authorisation should not be mocked - authorisation.hasPermission.mockImplementation((u: User, e: AuthorizableObject | BaseDO) => e !== lesson); - return { user, course, lesson }; + const parentParams = { courseId: course.id, userId: new ObjectId().toHexString() }; + + userRepo.findById.mockResolvedValueOnce(user); + lessonRepo.findById.mockResolvedValueOnce(lesson); + courseRepo.findById.mockResolvedValueOnce(course); + authorisation.hasPermission.mockReturnValueOnce(false); + + return { + userId: user.id, + lessonId: lesson.id, + parentParams, + }; }; - it('should throw NotFoundException', async () => { - const { course, user, lesson } = setupWithLessonForbidden(); + it('should throw ForbiddenException', async () => { + const { parentParams, userId, lessonId } = setup(); - try { - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId: new ObjectId().toHexString() }); - throw new Error('should have failed'); - } catch (err) { - expect(err).toBeInstanceOf(ForbiddenException); - } + await expect(uc.copyLesson(userId, lessonId, parentParams)).rejects.toThrowError( + new ForbiddenException('could not find lesson to copy') + ); }); }); - describe('when access to course is forbidden', () => { - const setupWithCourseForbidden = () => { + describe('when authorization of course throw with forbidden exception', () => { + const setup = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + const user = userFactory.buildWithId(); const course = courseFactory.buildWithId(); const lesson = lessonFactory.buildWithId(); - userRepo.findById.mockResolvedValue(user); - lessonRepo.findById.mockResolvedValue(lesson); - courseRepo.findById.mockResolvedValue(course); - authorisation.hasPermission.mockReturnValue(true); - authorisation.checkPermissionByReferences.mockImplementation(() => { + + const parentParams = { courseId: course.id, userId: new ObjectId().toHexString() }; + + userRepo.findById.mockResolvedValueOnce(user); + lessonRepo.findById.mockResolvedValueOnce(lesson); + courseRepo.findById.mockResolvedValueOnce(course); + authorisation.checkPermission.mockImplementationOnce(() => { throw new ForbiddenException(); }); - return { user, course, lesson }; + authorisation.hasPermission.mockReturnValueOnce(true); + + return { + userId: user.id, + lessonId: lesson.id, + parentParams, + }; }; - it('should throw Forbidden Exception', async () => { - const { course, user, lesson } = setupWithCourseForbidden(); + it('should pass the forbidden exception', async () => { + const { parentParams, userId, lessonId } = setup(); - try { - await uc.copyLesson(user.id, lesson.id, { courseId: course.id, userId: new ObjectId().toHexString() }); - throw new Error('should have failed'); - } catch (err) { - expect(err).toBeInstanceOf(ForbiddenException); - } + await expect(uc.copyLesson(userId, lessonId, parentParams)).rejects.toThrowError(new ForbiddenException()); }); }); }); diff --git a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts index 753200a5718..7ec51f5ef1c 100644 --- a/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts +++ b/apps/server/src/modules/learnroom/uc/lesson-copy.uc.ts @@ -1,14 +1,9 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; -import { EntityId } from '@shared/domain'; +import { Course, EntityId, LessonEntity, User } from '@shared/domain'; import { Permission } from '@shared/domain/interface/permission.enum'; import { CourseRepo, LessonRepo } from '@shared/repo'; -import { - Action, - AuthorizableReferenceType, - AuthorizationContextBuilder, - AuthorizationService, -} from '@src/modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { CopyHelperService, CopyStatus } from '@src/modules/copy-helper'; import { LessonCopyParentParams } from '@src/modules/lesson'; import { LessonCopyService } from '@src/modules/lesson/service'; @@ -24,27 +19,24 @@ export class LessonCopyUC { ) {} async copyLesson(userId: EntityId, lessonId: EntityId, parentParams: LessonCopyParentParams): Promise { - this.featureEnabled(); - const user = await this.authorisation.getUserWithPermissions(userId); - const originalLesson = await this.lessonRepo.findById(lessonId); - const context = AuthorizationContextBuilder.read([Permission.TOPIC_CREATE]); - if (!this.authorisation.hasPermission(user, originalLesson, context)) { - throw new ForbiddenException('could not find lesson to copy'); - } + this.checkFeatureEnabled(); + + const [user, originalLesson]: [User, LessonEntity] = await Promise.all([ + this.authorisation.getUserWithPermissions(userId), + this.lessonRepo.findById(lessonId), + ]); + this.checkOriginalLessonAuthorization(user, originalLesson); + + // should be a seperate private method const destinationCourse = parentParams.courseId ? await this.courseRepo.findById(parentParams.courseId) : originalLesson.course; - await this.authorisation.checkPermissionByReferences( - userId, - AuthorizableReferenceType.Course, - destinationCourse.id, - { - action: Action.write, - requiredPermissions: [], - } - ); + // --- + + this.checkDestinationCourseAuthorization(user, destinationCourse); + // should be a seperate private method const [existingLessons] = await this.lessonRepo.findAllByCourseIds([originalLesson.course.id]); const existingNames = existingLessons.map((l) => l.name); const copyName = this.copyHelperService.deriveCopyName(originalLesson.name, existingNames); @@ -55,11 +47,25 @@ export class LessonCopyUC { user, copyName, }); + // --- return copyStatus; } - private featureEnabled() { + private checkOriginalLessonAuthorization(user: User, originalLesson: LessonEntity): void { + const contextReadWithTopicCreate = AuthorizationContextBuilder.read([Permission.TOPIC_CREATE]); + if (!this.authorisation.hasPermission(user, originalLesson, contextReadWithTopicCreate)) { + // error message is not correct, switch to authorisation.checkPermission() makse sense for me + throw new ForbiddenException('could not find lesson to copy'); + } + } + + private checkDestinationCourseAuthorization(user: User, destinationCourse: Course): void { + const contextCanWrite = AuthorizationContextBuilder.write([]); + this.authorisation.checkPermission(user, destinationCourse, contextCanWrite); + } + + private checkFeatureEnabled() { const enabled = Configuration.get('FEATURE_COPY_SERVICE_ENABLED') as boolean; if (!enabled) { throw new InternalServerErrorException('Copy Feature not enabled'); diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts index 98a0957f3d3..cafa02e4d20 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts @@ -14,8 +14,7 @@ import { TaskWithStatusVo, User, } from '@shared/domain'; -import { AuthorizationService } from '@src/modules/authorization/authorization.service'; -import { Action } from '@src/modules/authorization/types/action.enum'; +import { AuthorizationService, Action } from '@src/modules/authorization'; import { ColumnBoardMetaData, LessonMetaData, diff --git a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts b/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts index 17dc2de5fd0..8747a07ada6 100644 --- a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts +++ b/apps/server/src/modules/legacy-school/uc/legacy-school.uc.spec.ts @@ -66,6 +66,9 @@ describe('LegacySchoolUc', () => { jest.resetAllMocks(); }); + // Tests with case of authService.checkPermission.mockImplementation(() => throw new ForbiddenException()); + // are missed for both methodes + describe('setMigration is called', () => { describe('when first starting the migration', () => { const setup = () => { @@ -77,7 +80,7 @@ describe('LegacySchoolUc', () => { }); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); schoolService.getSchoolById.mockResolvedValue(school); userLoginMigrationService.setMigration.mockResolvedValue(userLoginMigration); }; @@ -107,7 +110,7 @@ describe('LegacySchoolUc', () => { }); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); schoolService.getSchoolById.mockResolvedValue(school); userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(true); @@ -138,7 +141,7 @@ describe('LegacySchoolUc', () => { }); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); schoolService.getSchoolById.mockResolvedValue(school); userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); schoolMigrationService.hasSchoolMigratedUser.mockResolvedValue(false); @@ -177,7 +180,7 @@ describe('LegacySchoolUc', () => { }); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); schoolService.getSchoolById.mockResolvedValue(school); userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); }; @@ -208,7 +211,7 @@ describe('LegacySchoolUc', () => { }); userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); schoolService.getSchoolById.mockResolvedValue(school); userLoginMigrationService.setMigration.mockResolvedValue(updatedUserLoginMigration); schoolMigrationService.validateGracePeriod.mockImplementation(() => { @@ -241,7 +244,7 @@ describe('LegacySchoolUc', () => { userLoginMigrationService.findMigrationBySchool.mockResolvedValue(userLoginMigration); schoolService.getSchoolById.mockResolvedValue(school); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); }; it('should return a migration', async () => { @@ -265,7 +268,7 @@ describe('LegacySchoolUc', () => { userLoginMigrationService.findMigrationBySchool.mockResolvedValue(null); schoolService.getSchoolById.mockResolvedValue(school); - authService.checkPermissionByReferences.mockImplementation(() => Promise.resolve()); + authService.checkPermission.mockReturnValueOnce(); }; it('should return no migration information', async () => { diff --git a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts b/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts index 2ccc9dc5698..d1d13ffb037 100644 --- a/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts +++ b/apps/server/src/modules/legacy-school/uc/legacy-school.uc.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { Permission, LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { Permission, LegacySchoolDo, UserLoginMigrationDO, User } from '@shared/domain'; import { SchoolMigrationService, UserLoginMigrationRevertService, @@ -30,10 +30,12 @@ export class LegacySchoolUc { oauthMigrationFinished: boolean, userId: string ): Promise { - await this.authService.checkPermissionByReferences(userId, AuthorizableReferenceType.School, schoolId, { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_EDIT], - }); + const [authorizableUser, school]: [User, LegacySchoolDo] = await Promise.all([ + this.authService.getUserWithPermissions(userId), + this.schoolService.getSchoolById(schoolId), + ]); + + this.checkSchoolAuthorization(authorizableUser, school); const existingUserLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool(schoolId); @@ -61,8 +63,6 @@ export class LegacySchoolUc { await this.schoolMigrationService.unmarkOutdatedUsers(schoolId); } - const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); - const migrationDto: OauthMigrationDto = new OauthMigrationDto({ oauthMigrationPossible: !updatedUserLoginMigration.closedAt ? updatedUserLoginMigration.startedAt : undefined, oauthMigrationMandatory: updatedUserLoginMigration.mandatorySince, @@ -75,17 +75,17 @@ export class LegacySchoolUc { } async getMigration(schoolId: string, userId: string): Promise { - await this.authService.checkPermissionByReferences(userId, AuthorizableReferenceType.School, schoolId, { - action: Action.read, - requiredPermissions: [Permission.SCHOOL_EDIT], - }); + const [authorizableUser, school]: [User, LegacySchoolDo] = await Promise.all([ + this.authService.getUserWithPermissions(userId), + this.schoolService.getSchoolById(schoolId), + ]); + + this.checkSchoolAuthorization(authorizableUser, school); const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( schoolId ); - const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); - const migrationDto: OauthMigrationDto = new OauthMigrationDto({ oauthMigrationPossible: userLoginMigration && !userLoginMigration.closedAt ? userLoginMigration.startedAt : undefined, @@ -97,4 +97,9 @@ export class LegacySchoolUc { return migrationDto; } + + private checkSchoolAuthorization(authorizableUser: User, school: LegacySchoolDo): void { + const context = AuthorizationContextBuilder.read([Permission.SCHOOL_EDIT]); + this.authService.checkPermission(authorizableUser, school, context); + } } diff --git a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts index b344f1b6cdc..d0480398ec8 100644 --- a/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts +++ b/apps/server/src/modules/oauth-provider/uc/oauth-provider.client-crud.uc.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { OauthProviderService } from '@shared/infra/oauth-provider/index'; import { Permission, User } from '@shared/domain/index'; -import { AuthorizationService } from '@src/modules/authorization/authorization.service'; +import { AuthorizationService } from '@src/modules/authorization'; import { ProviderOauthClient } from '@shared/infra/oauth-provider/dto'; import { ICurrentUser } from '@src/modules/authentication'; diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts index 68c3a141f9b..2abd2019fbf 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts @@ -1,8 +1,8 @@ import { Request } from 'express'; import request from 'supertest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { Permission } from '@shared/domain'; @@ -101,27 +101,27 @@ describe(`share token creation (api)`, () => { const response = await api.post({ parentId: course.id, parentType: ShareTokenParentType.Course }); - expect(response.status).toEqual(500); + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); }); }); - describe('with ivalid request data', () => { + describe('with invalid request data', () => { it('should return status 400 on empty parent id', async () => { const response = await api.post({ parentId: '', parentType: ShareTokenParentType.Course, }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); - it('should return status 403 when parent id is not found', async () => { + it('should return status 404 when parent id is not found', async () => { const response = await api.post({ - parentId: '000011112222333344445555', + parentId: new ObjectId().toHexString(), parentType: ShareTokenParentType.Course, }); - expect(response.status).toEqual(403); + expect(response.status).toEqual(HttpStatus.NOT_FOUND); }); it('should return status 400 on invalid parent id', async () => { @@ -130,7 +130,7 @@ describe(`share token creation (api)`, () => { parentType: ShareTokenParentType.Course, }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); it('should return status 400 on invalid parent type', async () => { @@ -142,7 +142,7 @@ describe(`share token creation (api)`, () => { parentType: 'invalid', }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); it('should return status 400 when expiresInDays is invalid integer', async () => { @@ -155,7 +155,7 @@ describe(`share token creation (api)`, () => { expiresInDays: 'foo', }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); it('should return status 400 when expiresInDays is negative', async () => { @@ -167,7 +167,7 @@ describe(`share token creation (api)`, () => { expiresInDays: -10, }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); it('should return status 400 when expiresInDays is not an integer', async () => { @@ -179,7 +179,7 @@ describe(`share token creation (api)`, () => { expiresInDays: 2.5, }); - expect(response.status).toEqual(400); + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); }); }); @@ -189,7 +189,7 @@ describe(`share token creation (api)`, () => { const response = await api.post({ parentId: course.id, parentType: ShareTokenParentType.Course }); - expect(response.status).toEqual(201); + expect(response.status).toEqual(HttpStatus.CREATED); }); it('should return a valid result', async () => { @@ -216,7 +216,7 @@ describe(`share token creation (api)`, () => { schoolExclusive: true, }); - expect(response.status).toEqual(201); + expect(response.status).toEqual(HttpStatus.CREATED); }); it('should return a valid result', async () => { @@ -248,7 +248,7 @@ describe(`share token creation (api)`, () => { expiresInDays: 5, }); - expect(response.status).toEqual(201); + expect(response.status).toEqual(HttpStatus.CREATED); }); it('should return a valid result containg the expiration timestamp', async () => { diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts index e58940addda..737a24b022e 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts @@ -1,6 +1,6 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { Permission } from '@shared/domain'; @@ -118,16 +118,17 @@ describe(`share token import (api)`, () => { const response = await api.post({ token }, { newName: 'NewName' }); - expect(response.status).toEqual(500); + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); }); }); describe('with a valid token', () => { it('should return status 201', async () => { const { token } = await setup(); + const response = await api.post({ token }, { newName: 'NewName' }); - expect(response.status).toEqual(201); + expect(response.status).toEqual(HttpStatus.CREATED); }); it('should return a valid result', async () => { @@ -149,23 +150,53 @@ describe(`share token import (api)`, () => { describe('with invalid token', () => { it('should return status 404', async () => { await setup(); + const response = await api.post({ token: 'invalid_token' }, { newName: 'NewName' }); - expect(response.status).toEqual(404); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); }); }); describe('with invalid context', () => { - it('should return status 403', async () => { + const setup2 = async () => { + const school = schoolFactory.build(); const otherSchool = schoolFactory.build(); - await em.persistAndFlush(otherSchool); - em.clear(); + const roles = roleFactory.buildList(1, { + permissions: [Permission.COURSE_CREATE], + }); - const { token } = await setup({ + const user = userFactory.build({ school, roles }); + const course = courseFactory.build({ teachers: [user] }); + await em.persistAndFlush([user, course, otherSchool]); + + const context = { contextType: ShareTokenContextType.School, contextId: otherSchool.id, - }); - const response = await api.post({ token }, { newName: 'NewName' }); - expect(response.status).toEqual(403); + }; + + const shareToken = await shareTokenService.createToken( + { + parentType: ShareTokenParentType.Course, + parentId: course.id, + }, + { context } + ); + + em.clear(); + + currentUser = mapUserToCurrentUser(user); + + return { + shareTokenFromDifferentCourse: shareToken.token, + }; + }; + + it('should return status 403', async () => { + const { shareTokenFromDifferentCourse } = await setup2(); + + const response = await api.post({ token: shareTokenFromDifferentCourse }, { newName: 'NewName' }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); }); }); @@ -175,7 +206,7 @@ describe(`share token import (api)`, () => { // @ts-expect-error invalid new name const response = await api.post({ token }, { newName: 42 }); - expect(response.status).toEqual(501); + expect(response.status).toEqual(HttpStatus.NOT_IMPLEMENTED); }); }); }); diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts index a5c1304a730..7065c06a026 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts @@ -1,167 +1,210 @@ -import { Request } from 'express'; -import request from 'supertest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { Configuration } from '@hpi-schul-cloud/commons'; import { EntityManager } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ApiValidationError } from '@shared/common'; import { Permission } from '@shared/domain'; -import { ICurrentUser } from '@src/modules/authentication'; -import { - cleanupCollections, - courseFactory, - mapUserToCurrentUser, - roleFactory, - schoolFactory, - userFactory, -} from '@shared/testing'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { TestApiClient, UserAndAccountTestFactory, courseFactory, schoolFactory } from '@shared/testing'; import { ServerTestModule } from '@src/modules/server'; import { ShareTokenService } from '../../service'; -import { ShareTokenInfoResponse, ShareTokenResponse, ShareTokenUrlParams } from '../dto'; -import { ShareTokenContext, ShareTokenContextType, ShareTokenParentType } from '../../domainobject/share-token.do'; - -const baseRouteName = '/sharetoken'; - -class API { - app: INestApplication; - - constructor(app: INestApplication) { - this.app = app; - } - - async get(urlParams: ShareTokenUrlParams) { - const response = await request(this.app.getHttpServer()) - .get(`${baseRouteName}/${urlParams.token}`) - .set('Accept', 'application/json'); - - return { - result: response.body as ShareTokenResponse, - error: response.body as ApiValidationError, - status: response.status, - }; - } -} +import { ShareTokenInfoResponse } from '../dto'; +import { ShareTokenContextType, ShareTokenParentType } from '../../domainobject/share-token.do'; describe(`share token lookup (api)`, () => { let app: INestApplication; let em: EntityManager; - let currentUser: ICurrentUser; let shareTokenService: ShareTokenService; - let api: API; + let testApiClient: TestApiClient; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = module.createNestApplication(); await app.init(); em = module.get(EntityManager); shareTokenService = module.get(ShareTokenService); - api = new API(app); + testApiClient = new TestApiClient(app, 'sharetoken'); }); afterAll(async () => { await app.close(); }); - beforeEach(() => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); - }); + describe('with the feature disabled', () => { + const setup = async () => { + Configuration.set('FEATURE_COURSE_SHARE_NEW', false); - const setup = async (context?: ShareTokenContext) => { - await cleanupCollections(em); - const school = schoolFactory.build(); - const roles = roleFactory.buildList(1, { - permissions: [Permission.COURSE_CREATE], - }); - const user = userFactory.build({ school, roles }); - const course = courseFactory.build({ teachers: [user] }); - await em.persistAndFlush([user, course]); - - const shareToken = await shareTokenService.createToken( - { - parentType: ShareTokenParentType.Course, - parentId: course.id, - }, - { context } - ); - - em.clear(); - - currentUser = mapUserToCurrentUser(user); - - return { - parentType: ShareTokenParentType.Course, - parentName: course.getMetadata().title, - token: shareToken.token, + const parentType = ShareTokenParentType.Course; + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); + const course = courseFactory.build({ teachers: [teacherUser] }); + + await em.persistAndFlush([course, teacherAccount, teacherUser]); + em.clear(); + + const shareToken = await shareTokenService.createToken( + { + parentType, + parentId: course.id, + }, + undefined + ); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + token: shareToken.token, + loggedInClient, + }; }; - }; - describe('with the feature disabled', () => { it('should return status 500', async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); - const { token } = await setup(); + const { token, loggedInClient } = await setup(); - const response = await api.get({ token }); + const response = await loggedInClient.get(token); - expect(response.status).toEqual(500); + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(response.body).toEqual({ + code: 500, + message: 'Import Course Feature not enabled', + title: 'Internal Server Error', + type: 'INTERNAL_SERVER_ERROR', + }); }); }); + // test and setup for other feature flags are missed + describe('with a valid token', () => { - it('should return status 200', async () => { - const { token } = await setup(); - const response = await api.get({ token }); + const setup = async () => { + Configuration.set('FEATURE_COURSE_SHARE_NEW', true); - expect(response.status).toEqual(200); - }); + const parentType = ShareTokenParentType.Course; + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); + const course = courseFactory.build({ teachers: [teacherUser] }); - it('should return a valid result', async () => { - const { parentType, parentName, token } = await setup(); - const response = await api.get({ token }); + await em.persistAndFlush([course, teacherAccount, teacherUser]); + em.clear(); + + const shareToken = await shareTokenService.createToken( + { + parentType, + parentId: course.id, + }, + undefined + ); + + const loggedInClient = await testApiClient.login(teacherAccount); const expectedResult: ShareTokenInfoResponse = { - token, + token: shareToken.token, parentType, - parentName, + parentName: course.getMetadata().title, }; - expect(response.result).toEqual(expectedResult); + return { + expectedResult, + token: shareToken.token, + loggedInClient, + }; + }; + + it('should return status 200 with correct formated body', async () => { + const { token, loggedInClient, expectedResult } = await setup(); + + const response = await loggedInClient.get(token); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResult); }); }); describe('with invalid token', () => { + const setup = async () => { + Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); + const course = courseFactory.build({ teachers: [teacherUser] }); + + await em.persistAndFlush([course, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + invalidToken: 'invalid_token', + loggedInClient, + }; + }; + it('should return status 404', async () => { - await setup(); - const response = await api.get({ token: 'invalid_token' }); - expect(response.status).toEqual(404); + const { invalidToken, loggedInClient } = await setup(); + + const response = await loggedInClient.get(invalidToken); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + expect(response.body).toEqual({ + code: 404, + message: 'The requested ShareToken: [object Object] has not been found.', + title: 'Not Found', + type: 'NOT_FOUND', + }); }); }); describe('with invalid context', () => { - it('should return status 403', async () => { + const setup = async () => { + Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + + const parentType = ShareTokenParentType.Course; + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); const otherSchool = schoolFactory.build(); - await em.persistAndFlush(otherSchool); + const course = courseFactory.build({ teachers: [teacherUser] }); + + await em.persistAndFlush([course, teacherAccount, teacherUser, otherSchool]); em.clear(); - const { token } = await setup({ + const context = { contextType: ShareTokenContextType.School, contextId: otherSchool.id, + }; + + const shareToken = await shareTokenService.createToken( + { + parentType, + parentId: course.id, + }, + { context } + ); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const expectedResult: ShareTokenInfoResponse = { + token: shareToken.token, + parentType, + parentName: course.getMetadata().title, + }; + + return { + expectedResult, + token: shareToken.token, + loggedInClient, + }; + }; + + it('should return status 403', async () => { + const { token, loggedInClient } = await setup(); + + const response = await loggedInClient.get(token); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: 403, + message: 'Forbidden', + title: 'Forbidden', + type: 'FORBIDDEN', }); - const response = await api.get({ token }); - expect(response.status).toEqual(403); }); }); }); diff --git a/apps/server/src/modules/sharing/mapper/context-type.mapper.spec.ts b/apps/server/src/modules/sharing/mapper/context-type.mapper.spec.ts index 9684a1dbb58..68c0ccbd972 100644 --- a/apps/server/src/modules/sharing/mapper/context-type.mapper.spec.ts +++ b/apps/server/src/modules/sharing/mapper/context-type.mapper.spec.ts @@ -1,5 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { ShareTokenContextType } from '../domainobject/share-token.do'; import { ShareTokenContextTypeMapper } from './context-type.mapper'; diff --git a/apps/server/src/modules/sharing/mapper/context-type.mapper.ts b/apps/server/src/modules/sharing/mapper/context-type.mapper.ts index 7c9b4c8bb1d..05ed42843c7 100644 --- a/apps/server/src/modules/sharing/mapper/context-type.mapper.ts +++ b/apps/server/src/modules/sharing/mapper/context-type.mapper.ts @@ -1,5 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { ShareTokenContextType } from '../domainobject/share-token.do'; export class ShareTokenContextTypeMapper { @@ -12,6 +12,7 @@ export class ShareTokenContextTypeMapper { if (!res) { throw new NotImplementedException(); } + return res; } } diff --git a/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts b/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts index c6d8669bc70..4f8750245b4 100644 --- a/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts +++ b/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts @@ -1,5 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { ShareTokenParentType } from '../domainobject/share-token.do'; import { ShareTokenParentTypeMapper } from './parent-type.mapper'; diff --git a/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts b/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts index 54d8ceb0470..2ea01ea39f9 100644 --- a/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts +++ b/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts @@ -1,5 +1,5 @@ import { NotImplementedException } from '@nestjs/common'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { ShareTokenParentType } from '../domainobject/share-token.do'; export class ShareTokenParentTypeMapper { diff --git a/apps/server/src/modules/sharing/sharing.module.ts b/apps/server/src/modules/sharing/sharing.module.ts index f09214e9cf8..519033065b5 100644 --- a/apps/server/src/modules/sharing/sharing.module.ts +++ b/apps/server/src/modules/sharing/sharing.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@src/modules/authorization'; +import { AuthorizationReferenceModule } from '@src/modules/authorization/authorization-reference.module'; import { ShareTokenController } from './controller/share-token.controller'; import { ShareTokenUC } from './uc'; import { ShareTokenService, TokenGenerator } from './service'; @@ -10,7 +11,7 @@ import { LearnroomModule } from '../learnroom'; import { TaskModule } from '../task'; @Module({ - imports: [AuthorizationModule, LoggerModule, LearnroomModule, LessonModule, TaskModule], + imports: [AuthorizationModule, AuthorizationReferenceModule, LoggerModule, LearnroomModule, LessonModule, TaskModule], controllers: [], providers: [ShareTokenService, TokenGenerator, ShareTokenRepo], exports: [ShareTokenService], @@ -18,7 +19,15 @@ import { TaskModule } from '../task'; export class SharingModule {} @Module({ - imports: [SharingModule, AuthorizationModule, LearnroomModule, LessonModule, TaskModule, LoggerModule], + imports: [ + SharingModule, + AuthorizationModule, + AuthorizationReferenceModule, + LearnroomModule, + LessonModule, + TaskModule, + LoggerModule, + ], controllers: [ShareTokenController], providers: [ShareTokenUC], }) diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts index 73234960794..8f33076cff2 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts @@ -15,7 +15,8 @@ import { userFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { Action, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizableReferenceType, AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { CourseCopyService } from '@src/modules/learnroom'; import { CourseService } from '@src/modules/learnroom/service'; @@ -33,6 +34,7 @@ describe('ShareTokenUC', () => { let lessonCopyService: DeepMocked; let taskCopyService: DeepMocked; let authorization: DeepMocked; + let authorizationReferenceService: DeepMocked; let courseService: DeepMocked; let lessonRepo: DeepMocked; @@ -48,6 +50,10 @@ describe('ShareTokenUC', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: AuthorizationReferenceService, + useValue: createMock(), + }, { provide: CourseCopyService, useValue: createMock(), @@ -81,8 +87,10 @@ describe('ShareTokenUC', () => { lessonCopyService = module.get(LessonCopyService); taskCopyService = module.get(TaskCopyService); authorization = module.get(AuthorizationService); + authorizationReferenceService = module.get(AuthorizationReferenceService); courseService = module.get(CourseService); lessonRepo = module.get(LessonRepo); + await setupEntities(); }); @@ -93,6 +101,7 @@ describe('ShareTokenUC', () => { beforeEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); + // configuration sets must be part of the setup functions and part of the describe when ...and feature x is activated Configuration.set('FEATURE_COURSE_SHARE_NEW', true); Configuration.set('FEATURE_LESSON_SHARE', true); Configuration.set('FEATURE_TASK_SHARE', true); @@ -129,7 +138,7 @@ describe('ShareTokenUC', () => { parentType: ShareTokenParentType.Course, }); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.Course, course.id, @@ -148,7 +157,7 @@ describe('ShareTokenUC', () => { parentType: ShareTokenParentType.Course, }); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledTimes(1); + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledTimes(1); }); it('should call the service', async () => { @@ -190,7 +199,7 @@ describe('ShareTokenUC', () => { parentType: ShareTokenParentType.Lesson, }); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.Lesson, lesson.id, @@ -209,7 +218,7 @@ describe('ShareTokenUC', () => { parentType: ShareTokenParentType.Lesson, }); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledTimes(1); + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledTimes(1); }); it('should call the service', async () => { @@ -251,7 +260,7 @@ describe('ShareTokenUC', () => { parentType: ShareTokenParentType.Task, }); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.Task, task.id, @@ -270,7 +279,7 @@ describe('ShareTokenUC', () => { parentType: ShareTokenParentType.Task, }); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledTimes(1); + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledTimes(1); }); it('should call the service', async () => { @@ -309,7 +318,7 @@ describe('ShareTokenUC', () => { } ); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.Course, course.id, @@ -337,7 +346,7 @@ describe('ShareTokenUC', () => { } ); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.School, school.id, @@ -574,7 +583,7 @@ describe('ShareTokenUC', () => { await uc.lookupShareToken(user.id, shareToken.token); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.School, school.id, @@ -601,7 +610,7 @@ describe('ShareTokenUC', () => { await uc.lookupShareToken(user.id, shareToken.token); - expect(authorization.checkPermissionByReferences).not.toHaveBeenCalled(); + expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); }); }); }); @@ -686,7 +695,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.School, school.id, @@ -706,7 +715,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkPermissionByReferences).not.toHaveBeenCalled(); + expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); }); }); }); @@ -803,7 +812,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.School, school.id, @@ -823,7 +832,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkPermissionByReferences).not.toHaveBeenCalled(); + expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); }); }); }); @@ -919,7 +928,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkPermissionByReferences).toHaveBeenCalledWith( + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( user.id, AuthorizableReferenceType.School, school.id, @@ -939,7 +948,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkPermissionByReferences).not.toHaveBeenCalled(); + expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); }); }); }); @@ -962,6 +971,7 @@ describe('ShareTokenUC', () => { service.lookupToken.mockResolvedValue(shareToken); jest.spyOn(ShareTokenUC.prototype as any, 'checkFeatureEnabled').mockReturnValue(undefined); jest.spyOn(ShareTokenUC.prototype as any, 'checkCreatePermission').mockReturnValue(undefined); + await expect(uc.importShareToken('userId', shareToken.token, 'NewName')).rejects.toThrowError( NotImplementedException ); diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.ts b/apps/server/src/modules/sharing/uc/share-token.uc.ts index 0b72be7ce31..b2bbc635403 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.ts @@ -2,7 +2,8 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { BadRequestException, Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { EntityId, Permission } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; -import { Action, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { CopyStatus } from '@src/modules/copy-helper'; import { CourseCopyService } from '@src/modules/learnroom'; import { CourseService } from '@src/modules/learnroom/service'; @@ -24,6 +25,7 @@ export class ShareTokenUC { constructor( private readonly shareTokenService: ShareTokenService, private readonly authorizationService: AuthorizationService, + private readonly authorizationReferenceService: AuthorizationReferenceService, private readonly courseCopyService: CourseCopyService, private readonly lessonCopyService: LessonCopyService, private readonly courseService: CourseService, @@ -177,18 +179,26 @@ export class ShareTokenUC { requiredPermissions = [Permission.HOMEWORK_CREATE]; } - await this.authorizationService.checkPermissionByReferences(userId, allowedParentType, payload.parentId, { - action: Action.write, - requiredPermissions, - }); + const authorizationContext = AuthorizationContextBuilder.write(requiredPermissions); + + await this.authorizationReferenceService.checkPermissionByReferences( + userId, + allowedParentType, + payload.parentId, + authorizationContext + ); } private async checkContextReadPermission(userId: EntityId, context: ShareTokenContext) { const allowedContextType = ShareTokenContextTypeMapper.mapToAllowedAuthorizationEntityType(context.contextType); - await this.authorizationService.checkPermissionByReferences(userId, allowedContextType, context.contextId, { - action: Action.read, - requiredPermissions: [], - }); + const authorizationContext = AuthorizationContextBuilder.read([]); + + await this.authorizationReferenceService.checkPermissionByReferences( + userId, + allowedContextType, + context.contextId, + authorizationContext + ); } private async checkCreatePermission(userId: EntityId, parentType: ShareTokenParentType) { @@ -221,16 +231,19 @@ export class ShareTokenUC { private checkFeatureEnabled(parentType: ShareTokenParentType) { switch (parentType) { case ShareTokenParentType.Course: + // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_COURSE_SHARE_NEW') as boolean)) { throw new InternalServerErrorException('Import Course Feature not enabled'); } break; case ShareTokenParentType.Lesson: + // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_LESSON_SHARE') as boolean)) { throw new InternalServerErrorException('Import Lesson Feature not enabled'); } break; case ShareTokenParentType.Task: + // Configuration.get is the deprecated way to read envirment variables if (!(Configuration.get('FEATURE_TASK_SHARE') as boolean)) { throw new InternalServerErrorException('Import Task Feature not enabled'); } diff --git a/apps/server/src/modules/task/uc/task-copy.uc.spec.ts b/apps/server/src/modules/task/uc/task-copy.uc.spec.ts index a7666d479a9..2ad21c68dc2 100644 --- a/apps/server/src/modules/task/uc/task-copy.uc.spec.ts +++ b/apps/server/src/modules/task/uc/task-copy.uc.spec.ts @@ -3,15 +3,14 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { ObjectId } from '@mikro-orm/mongodb'; import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BaseDO, User } from '@shared/domain'; import { CourseRepo, LessonRepo, TaskRepo, UserRepo } from '@shared/repo'; import { courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { Action, AuthorizableReferenceType, AuthorizationService } from '@src/modules/authorization'; +import { Action, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@src/modules/copy-helper'; import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; -import { AuthorizableObject } from '@shared/domain/domain-object'; import { TaskCopyService } from '../service'; import { TaskCopyUC } from './task-copy.uc'; +import { TaskCopyParentParams } from '../types'; describe('task copy uc', () => { let uc: TaskCopyUC; @@ -92,14 +91,8 @@ describe('task copy uc', () => { const lesson = lessonFactory.buildWithId({ course }); const allTasks = taskFactory.buildList(3, { course }); const task = allTasks[0]; - authorisation.getUserWithPermissions.mockResolvedValue(user); - taskRepo.findById.mockResolvedValue(task); - lessonRepo.findById.mockResolvedValue(lesson); - taskRepo.findBySingleParent.mockResolvedValue([allTasks, allTasks.length]); - courseRepo.findById.mockResolvedValue(course); - authorisation.hasPermission.mockReturnValue(true); const copyName = 'name of the copy'; - copyHelperService.deriveCopyName.mockReturnValue(copyName); + const copy = taskFactory.buildWithId({ creator: user, course }); const status = { title: 'taskCopy', @@ -108,9 +101,16 @@ describe('task copy uc', () => { copyEntity: copy, originalEntity: task, }; - taskCopyService.copyTask.mockResolvedValue(status); - taskRepo.save.mockResolvedValue(undefined); - const userId = user.id; + + authorisation.getUserWithPermissions.mockResolvedValueOnce(user); + taskRepo.findById.mockResolvedValueOnce(task); + lessonRepo.findById.mockResolvedValueOnce(lesson); + taskRepo.findBySingleParent.mockResolvedValueOnce([allTasks, allTasks.length]); + courseRepo.findById.mockResolvedValueOnce(course); + authorisation.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(true); + copyHelperService.deriveCopyName.mockReturnValueOnce(copyName); + taskCopyService.copyTask.mockResolvedValueOnce(status); + taskRepo.save.mockResolvedValueOnce(); return { user, @@ -121,15 +121,16 @@ describe('task copy uc', () => { copy, allTasks, status, - userId, + userId: user.id, }; }; describe('feature is deactivated', () => { it('should throw InternalServerErrorException', async () => { + const { course, user, task, userId } = setup(); Configuration.set('FEATURE_COPY_SERVICE_ENABLED', false); - await expect(uc.copyTask('user.id', 'task.id', { courseId: 'course.id', userId: 'test' })).rejects.toThrowError( + await expect(uc.copyTask(user.id, task.id, { courseId: course.id, userId })).rejects.toThrowError( InternalServerErrorException ); }); @@ -214,15 +215,9 @@ describe('task copy uc', () => { const { course, user, task, userId } = setup(); await uc.copyTask(user.id, task.id, { courseId: course.id, userId }); - expect(authorisation.checkPermissionByReferences).toBeCalledWith( - user.id, - AuthorizableReferenceType.Course, - course.id, - { - action: Action.write, - requiredPermissions: [], - } - ); + + const context = AuthorizationContextBuilder.write([]); + expect(authorisation.checkPermission).toBeCalledWith(user, course, context); }); it('should pass authorisation check without destination course', async () => { @@ -230,10 +225,8 @@ describe('task copy uc', () => { await uc.copyTask(user.id, task.id, { userId }); - expect(authorisation.hasPermission).not.toBeCalledWith(user, course, { - action: Action.write, - requiredPermissions: [], - }); + const context = AuthorizationContextBuilder.write([]); + expect(authorisation.hasPermission).not.toBeCalledWith(user, course, context); }); it('should check authorisation for destination lesson', async () => { @@ -260,57 +253,64 @@ describe('task copy uc', () => { describe('when access to task is forbidden', () => { const setupWithTaskForbidden = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + const user = userFactory.buildWithId(); const course = courseFactory.buildWithId(); const lesson = lessonFactory.buildWithId({ course }); const task = taskFactory.buildWithId(); - userRepo.findById.mockResolvedValue(user); - taskRepo.findById.mockResolvedValue(task); - // authorisation should not be mocked - authorisation.hasPermission.mockImplementation((u: User, e: AuthorizableObject | BaseDO) => e !== task); - return { user, course, lesson, task }; + + userRepo.findById.mockResolvedValueOnce(user); + taskRepo.findById.mockResolvedValueOnce(task); + authorisation.hasPermission.mockReturnValueOnce(false); + + const parentParams: TaskCopyParentParams = { + courseId: course.id, + lessonId: lesson.id, + userId: new ObjectId().toHexString(), + }; + + return { user, course, lesson, task, parentParams }; }; it('should throw NotFoundException', async () => { - const { course, lesson, user, task } = setupWithTaskForbidden(); - - try { - await uc.copyTask(user.id, task.id, { - courseId: course.id, - lessonId: lesson.id, - userId: new ObjectId().toHexString(), - }); - throw new Error('should have failed'); - } catch (err) { - expect(err).toBeInstanceOf(NotFoundException); - } + const { user, task, parentParams } = setupWithTaskForbidden(); + + await expect(uc.copyTask(user.id, task.id, parentParams)).rejects.toThrowError( + new NotFoundException('could not find task to copy') + ); }); }); describe('when access to course is forbidden', () => { const setupWithCourseForbidden = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + const user = userFactory.buildWithId(); const course = courseFactory.buildWithId(); const task = taskFactory.buildWithId(); - userRepo.findById.mockResolvedValue(user); - taskRepo.findById.mockResolvedValue(task); - // authorisation should not be mocked - authorisation.hasPermission.mockImplementation((u: User, e: AuthorizableObject | BaseDO) => e !== course); - authorisation.checkPermissionByReferences.mockImplementation(() => { + + userRepo.findById.mockResolvedValueOnce(user); + taskRepo.findById.mockResolvedValueOnce(task); + courseRepo.findById.mockResolvedValueOnce(course); + authorisation.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(true); + authorisation.checkPermission.mockImplementationOnce(() => { throw new ForbiddenException(); }); - return { user, course, task }; + + const parentParams: TaskCopyParentParams = { courseId: course.id, userId: new ObjectId().toHexString() }; + + return { + userId: user.id, + taskId: task.id, + parentParams, + }; }; it('should throw Forbidden Exception', async () => { - const { course, user, task } = setupWithCourseForbidden(); - - try { - await uc.copyTask(user.id, task.id, { courseId: course.id, userId: new ObjectId().toHexString() }); - throw new Error('should have failed'); - } catch (err) { - expect(err).toBeInstanceOf(ForbiddenException); - } + const { userId, taskId, parentParams } = setupWithCourseForbidden(); + + await expect(uc.copyTask(userId, taskId, parentParams)).rejects.toThrowError(new ForbiddenException()); }); }); }); @@ -355,32 +355,35 @@ describe('task copy uc', () => { describe('when access to lesson is forbidden', () => { const setupWithLessonForbidden = () => { + Configuration.set('FEATURE_COPY_SERVICE_ENABLED', true); + const user = userFactory.buildWithId(); const course = courseFactory.buildWithId(); const lesson = lessonFactory.buildWithId({ course }); const task = taskFactory.buildWithId(); - userRepo.findById.mockResolvedValue(user); - taskRepo.findById.mockResolvedValue(task); - courseRepo.findById.mockResolvedValue(course); - lessonRepo.findById.mockResolvedValue(lesson); - // Authorisation should not be mocked - authorisation.hasPermission.mockImplementation((u: User, e: AuthorizableObject | BaseDO) => { - if (e === lesson) return false; - return true; - }); - return { user, lesson, task }; + userRepo.findById.mockResolvedValueOnce(user); + taskRepo.findById.mockResolvedValueOnce(task); + courseRepo.findById.mockResolvedValueOnce(course); + lessonRepo.findById.mockResolvedValueOnce(lesson); + // first canReadTask > second canWriteLesson + authorisation.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(false); + + const parentParams: TaskCopyParentParams = { lessonId: lesson.id, userId: new ObjectId().toHexString() }; + + return { + userId: user.id, + taskId: task.id, + parentParams, + }; }; it('should throw Forbidden Exception', async () => { - const { lesson, user, task } = setupWithLessonForbidden(); - - try { - await uc.copyTask(user.id, task.id, { lessonId: lesson.id, userId: new ObjectId().toHexString() }); - throw new Error('should have failed'); - } catch (err) { - expect(err).toBeInstanceOf(ForbiddenException); - } + const { userId, taskId, parentParams } = setupWithLessonForbidden(); + + await expect(uc.copyTask(userId, taskId, parentParams)).rejects.toThrowError( + new ForbiddenException('you dont have permission to add to this lesson') + ); }); }); }); diff --git a/apps/server/src/modules/task/uc/task-copy.uc.ts b/apps/server/src/modules/task/uc/task-copy.uc.ts index b1cdd212919..94b0a5a3acb 100644 --- a/apps/server/src/modules/task/uc/task-copy.uc.ts +++ b/apps/server/src/modules/task/uc/task-copy.uc.ts @@ -1,13 +1,8 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Course, EntityId, LessonEntity, User } from '@shared/domain'; +import { Course, EntityId, Task, LessonEntity, User } from '@shared/domain'; import { CourseRepo, LessonRepo, TaskRepo } from '@shared/repo'; -import { - Action, - AuthorizableReferenceType, - AuthorizationContextBuilder, - AuthorizationService, -} from '@src/modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { CopyHelperService, CopyStatus } from '@src/modules/copy-helper'; import { TaskCopyService } from '../service'; import { TaskCopyParentParams } from '../types'; @@ -24,46 +19,73 @@ export class TaskCopyUC { ) {} async copyTask(userId: EntityId, taskId: EntityId, parentParams: TaskCopyParentParams): Promise { - this.featureEnabled(); - const user = await this.authorisation.getUserWithPermissions(userId); - const originalTask = await this.taskRepo.findById(taskId); - if (!this.authorisation.hasPermission(user, originalTask, AuthorizationContextBuilder.read([]))) { - throw new NotFoundException('could not find task to copy'); - } + this.checkFeatureEnabled(); + + // i put it to promise all, it do not look like any more information can be expose over errors if it is called between the authorizations + // TODO: Add try catch around it with throw BadRequest invalid data + const [authorizableUser, originalTask, destinationCourse]: [User, Task, Course | undefined] = await Promise.all([ + this.authorisation.getUserWithPermissions(userId), + this.taskRepo.findById(taskId), + this.getDestinationCourse(parentParams.courseId), + ]); - const destinationCourse = await this.getDestinationCourse(parentParams.courseId); - if (parentParams.courseId) { - await this.authorisation.checkPermissionByReferences( - userId, - AuthorizableReferenceType.Course, - parentParams.courseId, - { - action: Action.write, - requiredPermissions: [], - } - ); + this.checkOriginalTaskAuthorization(authorizableUser, originalTask); + + if (destinationCourse) { + this.checkDestinationCourseAuthorisation(authorizableUser, destinationCourse); } - const destinationLesson = await this.getDestinationLesson(parentParams.lessonId, user); - const copyName = await this.getCopyName(originalTask.name, parentParams.courseId); + // i think getDestinationLesson can also to a promise.all on top + // then getCopyName can be put into if (destinationCourse) { + // but then the test need to cleanup + const [destinationLesson, copyName]: [LessonEntity | undefined, string | undefined] = await Promise.all([ + this.getDestinationLesson(parentParams.lessonId), + this.getCopyName(originalTask.name, parentParams.courseId), + ]); + + if (destinationLesson) { + this.checkDestinationLessonAuthorization(authorizableUser, destinationLesson); + } const status = await this.taskCopyService.copyTask({ originalTaskId: originalTask.id, destinationCourse, destinationLesson, - user, + user: authorizableUser, copyName, }); return status; } + private checkOriginalTaskAuthorization(authorizableUser: User, originalTask: Task): void { + const context = AuthorizationContextBuilder.read([]); + if (!this.authorisation.hasPermission(authorizableUser, originalTask, context)) { + // error message and erorr type are not correct + throw new NotFoundException('could not find task to copy'); + } + } + + private checkDestinationCourseAuthorisation(authorizableUser: User, destinationCourse: Course): void { + const context = AuthorizationContextBuilder.write([]); + this.authorisation.checkPermission(authorizableUser, destinationCourse, context); + } + + private checkDestinationLessonAuthorization(authorizableUser: User, destinationLesson: LessonEntity): void { + const context = AuthorizationContextBuilder.write([]); + if (!this.authorisation.hasPermission(authorizableUser, destinationLesson, context)) { + throw new ForbiddenException('you dont have permission to add to this lesson'); + } + } + private async getCopyName(originalTaskName: string, parentCourseId: EntityId | undefined) { let existingNames: string[] = []; if (parentCourseId) { + // It should really get an task where the creatorId === '' ? const [existingTasks] = await this.taskRepo.findBySingleParent('', parentCourseId); existingNames = existingTasks.map((t) => t.name); } + return this.copyHelperService.deriveCopyName(originalTaskName, existingNames); } @@ -77,19 +99,18 @@ export class TaskCopyUC { return destinationCourse; } - private async getDestinationLesson(lessonId: string | undefined, user: User): Promise { + private async getDestinationLesson(lessonId: string | undefined): Promise { if (lessonId === undefined) { return undefined; } const destinationLesson = await this.lessonRepo.findById(lessonId); - if (!this.authorisation.hasPermission(user, destinationLesson, AuthorizationContextBuilder.write([]))) { - throw new ForbiddenException('you dont have permission to add to this lesson'); - } + return destinationLesson; } - private featureEnabled() { + private checkFeatureEnabled() { + // This is the deprecated way to read envirement variables const enabled = Configuration.get('FEATURE_COPY_SERVICE_ENABLED') as boolean; if (!enabled) { throw new InternalServerErrorException('Copy Feature not enabled'); diff --git a/apps/server/src/modules/tool/common/common-tool.module.ts b/apps/server/src/modules/tool/common/common-tool.module.ts index cc7f5f86f00..0bd9ba5385b 100644 --- a/apps/server/src/modules/tool/common/common-tool.module.ts +++ b/apps/server/src/modules/tool/common/common-tool.module.ts @@ -3,11 +3,12 @@ import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@src/modules/authorization'; import { LegacySchoolModule } from '@src/modules/legacy-school'; +import { LearnroomModule } from '@src/modules/learnroom'; import { CommonToolService, CommonToolValidationService } from './service'; import { ToolPermissionHelper } from './uc/tool-permission-helper'; @Module({ - imports: [LoggerModule, forwardRef(() => AuthorizationModule), LegacySchoolModule], + imports: [LoggerModule, forwardRef(() => AuthorizationModule), LegacySchoolModule, LearnroomModule], // TODO: make deletion of entities cascading, adjust ExternalToolService.deleteExternalTool and remove the repos from here providers: [ CommonToolService, diff --git a/apps/server/src/modules/tool/common/mapper/context-type.mapper.spec.ts b/apps/server/src/modules/tool/common/mapper/context-type.mapper.spec.ts new file mode 100644 index 00000000000..78d79f45a1b --- /dev/null +++ b/apps/server/src/modules/tool/common/mapper/context-type.mapper.spec.ts @@ -0,0 +1,11 @@ +import { AuthorizableReferenceType } from '@src/modules/authorization/domain'; +import { ToolContextType } from '../enum'; +import { ContextTypeMapper } from './context-type.mapper'; + +describe('context-type.mapper', () => { + it('should map ToolContextType.COURSE to AuthorizableReferenceType.Course', () => { + const mappedCourse = ContextTypeMapper.mapContextTypeToAllowedAuthorizationEntityType(ToolContextType.COURSE); + + expect(mappedCourse).toEqual(AuthorizableReferenceType.Course); + }); +}); diff --git a/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts b/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts index bf8fb537924..883400f4258 100644 --- a/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts +++ b/apps/server/src/modules/tool/common/mapper/context-type.mapper.ts @@ -1,4 +1,4 @@ -import { AuthorizableReferenceType } from '@src/modules/authorization/types'; +import { AuthorizableReferenceType } from '@src/modules/authorization/domain/'; import { ToolContextType } from '../enum'; const typeMapping: Record = { diff --git a/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts b/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts index dc4f339b4ab..7f0cb68ade8 100644 --- a/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts +++ b/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts @@ -1,16 +1,21 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { EntityId, LegacySchoolDo, User } from '@shared/domain'; -import { AuthorizableReferenceType, AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; +import { Course, EntityId, LegacySchoolDo, User } from '@shared/domain'; +import { AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; import { LegacySchoolService } from '@src/modules/legacy-school'; +import { CourseService } from '@src/modules/learnroom'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { ContextTypeMapper } from '../mapper'; +// import { ContextTypeMapper } from '../mapper'; @Injectable() export class ToolPermissionHelper { constructor( - @Inject(forwardRef(() => AuthorizationService)) private authorizationService: AuthorizationService, - private readonly schoolService: LegacySchoolService + @Inject(forwardRef(() => AuthorizationService)) private readonly authorizationService: AuthorizationService, + private readonly schoolService: LegacySchoolService, + // invalid dependency on this place it is in UC layer in a other module + // loading of ressources should be part of service layer + // if it must resolve different loadings based on the request it can be added in own service and use in UC + private readonly courseService: CourseService ) {} // TODO build interface to get contextDO by contextType @@ -19,21 +24,19 @@ export class ToolPermissionHelper { contextExternalTool: ContextExternalTool, context: AuthorizationContext ): Promise { + // loading of ressources should be part of the UC -> unnessasary awaits + const [authorizableUser, course]: [User, Course] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.courseService.findById(contextExternalTool.contextRef.id), + ]); + if (contextExternalTool.id) { - await this.authorizationService.checkPermissionByReferences( - userId, - AuthorizableReferenceType.ContextExternalToolEntity, - contextExternalTool.id, - context - ); + this.authorizationService.checkPermission(authorizableUser, contextExternalTool, context); } - await this.authorizationService.checkPermissionByReferences( - userId, - ContextTypeMapper.mapContextTypeToAllowedAuthorizationEntityType(contextExternalTool.contextRef.type), - contextExternalTool.contextRef.id, - context - ); + // const type = ContextTypeMapper.mapContextTypeToAllowedAuthorizationEntityType(contextExternalTool.contextRef.type); + // no different types possible until it is fixed. + this.authorizationService.checkPermission(authorizableUser, course, context); } public async ensureSchoolPermissions( @@ -41,8 +44,12 @@ export class ToolPermissionHelper { schoolExternalTool: SchoolExternalTool, context: AuthorizationContext ): Promise { - const user: User = await this.authorizationService.getUserWithPermissions(userId); - const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + // loading of ressources should be part of the UC -> unnessasary awaits + const [user, school]: [User, LegacySchoolDo] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.schoolService.getSchoolById(schoolExternalTool.schoolId), + ]); + this.authorizationService.checkPermission(user, school, context); } } diff --git a/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts b/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts index b1567693130..0f2a1192d57 100644 --- a/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts +++ b/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts @@ -2,13 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { contextExternalToolFactory, + courseFactory, legacySchoolDoFactory, schoolExternalToolFactory, setupEntities, + userFactory, } from '@shared/testing'; import { Permission, LegacySchoolDo } from '@shared/domain'; import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { LegacySchoolService } from '@src/modules/legacy-school'; +import { ForbiddenException } from '@nestjs/common'; +import { CourseService } from '@src/modules/learnroom'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { ToolPermissionHelper } from './tool-permission-helper'; import { SchoolExternalTool } from '../../school-external-tool/domain'; @@ -18,6 +22,7 @@ describe('ToolPermissionHelper', () => { let helper: ToolPermissionHelper; let authorizationService: DeepMocked; + let courseService: DeepMocked; let schoolService: DeepMocked; beforeAll(async () => { @@ -29,6 +34,10 @@ describe('ToolPermissionHelper', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: CourseService, + useValue: createMock(), + }, { provide: LegacySchoolService, useValue: createMock(), @@ -38,6 +47,7 @@ describe('ToolPermissionHelper', () => { helper = module.get(ToolPermissionHelper); authorizationService = module.get(AuthorizationService); + courseService = module.get(CourseService); schoolService = module.get(LegacySchoolService); }); @@ -50,29 +60,98 @@ describe('ToolPermissionHelper', () => { }); describe('ensureContextPermissions', () => { - describe('when context external tool is given', () => { + describe('when context external tool with id is given', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + courseService.findById.mockResolvedValueOnce(course); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkPermission.mockReturnValueOnce().mockReturnValueOnce(); + + return { + user, + course, + contextExternalTool, + context, + }; + }; + + it('should check permission for context external tool', async () => { + const { user, course, contextExternalTool, context } = setup(); + + await helper.ensureContextPermissions(user.id, contextExternalTool, context); + + expect(authorizationService.checkPermission).toHaveBeenCalledTimes(2); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(1, user, contextExternalTool, context); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(2, user, course, context); + }); + + it('should return undefined', async () => { + const { user, contextExternalTool, context } = setup(); + + const result = await helper.ensureContextPermissions(user.id, contextExternalTool, context); + + expect(result).toBeUndefined(); + }); + }); + + describe('when context external tool without id is given', () => { const setup = () => { - const userId = 'userId'; + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + courseService.findById.mockResolvedValueOnce(course); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + return { - userId, + user, + course, contextExternalTool, context, }; }; it('should check permission for context external tool', async () => { - const { userId, contextExternalTool, context } = setup(); + const { user, course, contextExternalTool, context } = setup(); + + await helper.ensureContextPermissions(user.id, contextExternalTool, context); - await helper.ensureContextPermissions(userId, contextExternalTool, context); + expect(authorizationService.checkPermission).toHaveBeenCalledTimes(1); + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, course, context); + }); + }); - expect(authorizationService.checkPermissionByReferences).toHaveBeenCalledWith( - userId, - 'courses', - contextExternalTool.contextRef.id, - context + describe('when user is unauthorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + courseService.findById.mockResolvedValueOnce(course); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new ForbiddenException(); + }); + + return { + user, + course, + contextExternalTool, + context, + }; + }; + + it('should check permission for context external tool', async () => { + const { user, contextExternalTool, context } = setup(); + + await expect(helper.ensureContextPermissions(user.id, contextExternalTool, context)).rejects.toThrowError( + new ForbiddenException() ); }); }); @@ -81,15 +160,16 @@ describe('ToolPermissionHelper', () => { describe('ensureSchoolPermissions', () => { describe('when school external tool is given', () => { const setup = () => { - const userId = 'userId'; + const user = userFactory.buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: schoolExternalTool.schoolId }); schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { - userId, + user, schoolExternalTool, school, context, @@ -97,11 +177,20 @@ describe('ToolPermissionHelper', () => { }; it('should check permission for school external tool', async () => { - const { userId, schoolExternalTool, context, school } = setup(); + const { user, schoolExternalTool, context, school } = setup(); + + await helper.ensureSchoolPermissions(user.id, schoolExternalTool, context); + + expect(authorizationService.checkPermission).toHaveBeenCalledTimes(1); + expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, school, context); + }); + + it('should return undefined', async () => { + const { user, schoolExternalTool, context } = setup(); - await helper.ensureSchoolPermissions(userId, schoolExternalTool, context); + const result = await helper.ensureSchoolPermissions(user.id, schoolExternalTool, context); - expect(authorizationService.checkPermission).toHaveBeenCalledWith(userId, school, context); + expect(result).toBeUndefined(); }); }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts index 6e8ca18253f..b5584426763 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts @@ -114,29 +114,29 @@ describe('ToolContextController (API)', () => { }); }); - describe('when creation of contextExternalTool failed', () => { + describe('when user is not authorized for the requested context', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - - const course: Course = courseFactory.buildWithId({ teachers: [teacherUser] }); - - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const school = schoolFactory.build(); + const course = courseFactory.build({ teachers: [teacherUser] }); + const otherCourse = courseFactory.build(); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.build({ schoolParameters: [], toolVersion: 1, + school, }); - const randomTestId = new ObjectId().toString(); + await em.persistAndFlush([course, otherCourse, school, teacherUser, teacherAccount, schoolExternalToolEntity]); + em.clear(); + const postParams: ContextExternalToolPostParams = { - schoolToolId: randomTestId, - contextId: randomTestId, + schoolToolId: school.id, + contextId: otherCourse.id, contextType: ToolContextType.COURSE, parameters: [], toolVersion: 1, }; - await em.persistAndFlush([course, teacherUser, teacherAccount, schoolExternalToolEntity]); - em.clear(); - const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); return { @@ -145,12 +145,13 @@ describe('ToolContextController (API)', () => { }; }; - it('when user is not authorized, it should return forbidden', async () => { + it('it should return forbidden', async () => { const { postParams, loggedInClient } = await setup(); const response = await loggedInClient.post().send(postParams); expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + // expected body is missed }); }); }); @@ -204,23 +205,26 @@ describe('ToolContextController (API)', () => { describe('when deletion of contextExternalTool failed', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - - const course: Course = courseFactory.buildWithId({ teachers: [teacherUser] }); - - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const course = courseFactory.buildWithId({ teachers: [teacherUser] }); + const schoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ toolVersion: 1, }); - - const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + const contextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ schoolTool: schoolExternalToolEntity, + contextId: course.id, toolVersion: 1, }); - em.persist([course, teacherUser, teacherAccount, schoolExternalToolEntity, contextExternalToolEntity]); - await em.flush(); + await em.persistAndFlush([ + course, + teacherUser, + teacherAccount, + schoolExternalToolEntity, + contextExternalToolEntity, + ]); em.clear(); - const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + const loggedInClient = await testApiClient.login(teacherAccount); return { contextExternalToolEntity, @@ -234,6 +238,7 @@ describe('ToolContextController (API)', () => { const result = await loggedInClient.delete(`${contextExternalToolEntity.id}`); expect(result.statusCode).toEqual(HttpStatus.FORBIDDEN); + // result.body is missed }); }); }); @@ -543,37 +548,29 @@ describe('ToolContextController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); - + const school = schoolFactory.build(); const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); - - const course: Course = courseFactory.buildWithId({ + const course = courseFactory.build({ teachers: [studentUser], school, }); - - const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const externalTool: ExternalToolEntity = externalToolEntityFactory.build(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.build({ school, tool: externalTool, toolVersion: 1, }); - const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + + await em.persistAndFlush([school, course, externalTool, schoolExternalTool, studentAccount, studentUser]); + + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.build({ contextId: course.id, schoolTool: schoolExternalTool, toolVersion: 1, contextType: ContextExternalToolType.COURSE, }); - await em.persistAndFlush([ - school, - course, - externalTool, - schoolExternalTool, - contextExternalTool, - studentAccount, - studentUser, - ]); + await em.persistAndFlush([contextExternalTool]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); @@ -591,6 +588,7 @@ describe('ToolContextController (API)', () => { const response = await loggedInClient.get(`${contextExternalTool.id}`); expect(response.status).toEqual(HttpStatus.FORBIDDEN); + // body check }); }); }); @@ -688,34 +686,37 @@ describe('ToolContextController (API)', () => { describe('when the user is not authorized', () => { const setup = async () => { - const roleWithoutPermission = roleFactory.buildWithId(); - const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher(); - + const roleWithoutPermission = roleFactory.build(); teacherUser.roles.set([roleWithoutPermission]); - const school: SchoolEntity = schoolFactory.buildWithId(); - - const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - + const school = schoolFactory.build(); + const course = courseFactory.build({ teachers: [teacherUser], school }); const contextParameter = customParameterEntityFactory.build({ scope: CustomParameterScope.CONTEXT, regex: 'testValue123', }); - - const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.build({ parameters: [contextParameter], version: 2, }); - - const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.build({ tool: externalToolEntity, school, schoolParameters: [], toolVersion: 2, }); - const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + await em.persistAndFlush([ + course, + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + ]); + + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.build({ schoolTool: schoolExternalToolEntity, contextId: course.id, contextType: ContextExternalToolType.COURSE, @@ -724,6 +725,10 @@ describe('ToolContextController (API)', () => { toolVersion: 1, }); + await em.persistAndFlush([contextExternalToolEntity]); + + em.clear(); + const postParams: ContextExternalToolPostParams = { schoolToolId: schoolExternalToolEntity.id, contextId: course.id, @@ -738,17 +743,6 @@ describe('ToolContextController (API)', () => { toolVersion: 2, }; - await em.persistAndFlush([ - course, - school, - teacherUser, - teacherAccount, - externalToolEntity, - schoolExternalToolEntity, - contextExternalToolEntity, - ]); - em.clear(); - const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); return { loggedInClient, postParams, contextExternalToolEntity }; @@ -760,6 +754,7 @@ describe('ToolContextController (API)', () => { const response = await loggedInClient.put(`${contextExternalToolEntity.id}`).send(postParams); expect(response.status).toEqual(HttpStatus.FORBIDDEN); + // body missed }); }); diff --git a/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts b/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts index 491e7d9f2d6..3e7d8199fa2 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/tool-context.controller.ts @@ -58,6 +58,7 @@ export class ToolContextController { ContextExternalToolResponseMapper.mapContextExternalToolResponse(createdTool); this.logger.debug(`ContextExternalTool with id ${response.id} was created by user with id ${currentUser.userId}`); + return response; } diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index 801765f80e5..8be134b80f2 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -5,8 +5,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityId, Permission, User } from '@shared/domain'; import { contextExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { Action, AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; -import { ForbiddenLoggableException } from '@src/modules/authorization/errors/forbidden.loggable-exception'; +import { + Action, + AuthorizationContextBuilder, + AuthorizationService, + ForbiddenLoggableException, +} from '@src/modules/authorization'; import { ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../domain'; diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index 04002cf9fc6..9b6e3e3fe66 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -61,18 +61,20 @@ export class ContextExternalToolUc { return saved; } - async deleteContextExternalTool(userId: EntityId, contextExternalToolId: EntityId): Promise { + public async deleteContextExternalTool(userId: EntityId, contextExternalToolId: EntityId): Promise { const tool: ContextExternalTool = await this.contextExternalToolService.findById(contextExternalToolId); - const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + const context = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); - const promise: Promise = this.contextExternalToolService.deleteContextExternalTool(tool); - - return promise; + await this.contextExternalToolService.deleteContextExternalTool(tool); } - async getContextExternalToolsForContext(userId: EntityId, contextType: ToolContextType, contextId: string) { + public async getContextExternalToolsForContext( + userId: EntityId, + contextType: ToolContextType, + contextId: string + ): Promise { const tools: ContextExternalTool[] = await this.contextExternalToolService.findAllByContext( new ContextRef({ id: contextId, type: contextType }) ); diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts index 70ec7d4be67..d0030bf7b46 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts @@ -33,7 +33,6 @@ describe('ToolConfigurationController (API)', () => { let app: INestApplication; let em: EntityManager; let orm: MikroORM; - let testApiClient: TestApiClient; beforeAll(async () => { @@ -346,22 +345,19 @@ describe('ToolConfigurationController (API)', () => { describe('GET tools/school-external-tools/:schoolExternalToolId/configuration-template', () => { describe('when the user is not authorized', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); - - const user: User = userFactory.buildWithId({ school, roles: [] }); - const account: Account = accountFactory.buildWithId({ userId: user.id }); - - const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - - const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const school = schoolFactory.build(); + // not on same school like the tool + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({}, []); + const externalTool: ExternalToolEntity = externalToolEntityFactory.build(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.build({ school, tool: externalTool, }); - await em.persistAndFlush([user, account, school, externalTool, schoolExternalTool]); + await em.persistAndFlush([adminAccount, adminUser, school, externalTool, schoolExternalTool]); em.clear(); - const loggedInClient: TestApiClient = await testApiClient.login(account); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); return { loggedInClient, @@ -477,51 +473,43 @@ describe('ToolConfigurationController (API)', () => { describe('GET tools/context-external-tools/:contextExternalToolId/configuration-template', () => { describe('when the user is not authorized', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); - - const course: Course = courseFactory.buildWithId(); - - const user: User = userFactory.buildWithId({ school, roles: [] }); - const account: Account = accountFactory.buildWithId({ userId: user.id }); - - const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - - const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const school = schoolFactory.build(); + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.SCHOOL_TOOL_ADMIN]); + // user is not part of the course + const course = courseFactory.build(); + const externalTool: ExternalToolEntity = externalToolEntityFactory.build(); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.build({ school, tool: externalTool, }); - const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + await em.persistAndFlush([course, adminUser, adminAccount, school, externalTool, schoolExternalTool]); + + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.build({ schoolTool: schoolExternalTool, + contextId: course.id, }); - await em.persistAndFlush([ - user, - account, - school, - externalTool, - schoolExternalTool, - contextExternalTool, - course, - ]); + await em.persistAndFlush([contextExternalTool]); em.clear(); - const loggedInClient: TestApiClient = await testApiClient.login(account); + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); return { loggedInClient, - contextExternalTool, + contextExternalToolId: contextExternalTool.id, }; }; it('should return a forbidden status', async () => { - const { loggedInClient, contextExternalTool } = await setup(); + const { loggedInClient, contextExternalToolId } = await setup(); const response: Response = await loggedInClient.get( - `context-external-tools/${contextExternalTool.id}/configuration-template` + `context-external-tools/${contextExternalToolId}/configuration-template` ); expect(response.status).toEqual(HttpStatus.FORBIDDEN); + // body }); }); @@ -607,36 +595,26 @@ describe('ToolConfigurationController (API)', () => { describe('when tool is hidden', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); - + const school = schoolFactory.build(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); - - const course: Course = courseFactory.buildWithId({ school, teachers: [teacherUser] }); - - const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ isHidden: true }); - - const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + const course = courseFactory.build({ school, teachers: [teacherUser] }); + const externalTool: ExternalToolEntity = externalToolEntityFactory.build({ isHidden: true }); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.build({ school, tool: externalTool, }); - const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ + await em.persistAndFlush([teacherUser, school, teacherAccount, externalTool, schoolExternalTool, course]); + + const contextExternalTool: ContextExternalToolEntity = contextExternalToolEntityFactory.build({ schoolTool: schoolExternalTool, contextType: ContextExternalToolType.COURSE, contextId: course.id, }); - await em.persistAndFlush([ - teacherUser, - school, - teacherAccount, - externalTool, - schoolExternalTool, - contextExternalTool, - course, - ]); + await em.persistAndFlush([contextExternalTool]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts index 16dd9626d0c..1e91214ccda 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts @@ -140,8 +140,8 @@ export class ExternalToolConfigurationUc { const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findById( contextExternalToolId ); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( diff --git a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts index 6799a50bca2..95d8a42ce1f 100644 --- a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts +++ b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { LearnroomModule } from '@src/modules/learnroom'; import { LegacySchoolModule } from '@src/modules/legacy-school'; import { PseudonymModule } from '@src/modules/pseudonym'; @@ -18,7 +18,7 @@ import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrat ContextExternalToolModule, LegacySchoolModule, UserModule, - PseudonymModule, + forwardRef(() => PseudonymModule), // i do not like this solution, the root problem is on other place but not detectable for me LearnroomModule, ], providers: [ diff --git a/apps/server/src/modules/video-conference/mapper/video-conference.mapper.ts b/apps/server/src/modules/video-conference/mapper/video-conference.mapper.ts index 727cfdfce7d..1f51a8cb3ff 100644 --- a/apps/server/src/modules/video-conference/mapper/video-conference.mapper.ts +++ b/apps/server/src/modules/video-conference/mapper/video-conference.mapper.ts @@ -1,5 +1,4 @@ -import { Permission, VideoConferenceScope } from '@shared/domain'; -import { AuthorizableReferenceType } from '@src/modules/authorization'; +import { Permission } from '@shared/domain'; import { BBBRole } from '../bbb'; import { VideoConferenceCreateParams, @@ -16,11 +15,6 @@ export const PermissionMapping = { [BBBRole.VIEWER]: Permission.JOIN_MEETING, }; -export const PermissionScopeMapping = { - [VideoConferenceScope.COURSE]: AuthorizableReferenceType.Course, - [VideoConferenceScope.EVENT]: AuthorizableReferenceType.Team, -}; - const stateMapping = { [VideoConferenceState.NOT_STARTED]: VideoConferenceStateResponse.NOT_STARTED, [VideoConferenceState.RUNNING]: VideoConferenceStateResponse.RUNNING, diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts index b0c28b6a3c9..280a11976d7 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts @@ -14,14 +14,10 @@ import { } from '@shared/domain'; import { CalendarEventDto, CalendarService } from '@shared/infra/calendar'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; -import { - AuthorizableReferenceType, - AuthorizationContextBuilder, - AuthorizationService, -} from '@src/modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { LegacySchoolService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; -import { courseFactory, roleFactory, setupEntities, userDoFactory } from '@shared/testing'; +import { courseFactory, roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; import { ObjectId } from 'bson'; import { teamFactory } from '@shared/testing/factory/team.factory'; @@ -332,64 +328,160 @@ describe('VideoConferenceService', () => { }); describe('checkPermission', () => { - const setup = () => { - const userId = 'user-id'; - const conferenceScope = VideoConferenceScope.COURSE; - const entityId = 'entity-id'; + describe('when user has START_MEETING permission and is in course scope', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const entity = courseFactory.buildWithId(); + const conferenceScope = VideoConferenceScope.COURSE; - return { - userId, - conferenceScope, - entityId, + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(false); + courseService.findById.mockResolvedValueOnce(entity); + + return { + user, + userId: user.id, + entity, + entityId: entity.id, + conferenceScope, + }; }; - }; - describe('when user has START_MEETING permission', () => { + it('should call the correct authorization order', async () => { + const { user, entity, userId, conferenceScope, entityId } = setup(); + + await service.determineBbbRole(userId, entityId, conferenceScope); + + expect(authorizationService.hasPermission).toHaveBeenCalledWith( + user, + entity, + AuthorizationContextBuilder.read([Permission.START_MEETING]) + ); + }); + it('should return BBBRole.MODERATOR', async () => { const { userId, conferenceScope, entityId } = setup(); - authorizationService.hasPermissionByReferences.mockResolvedValueOnce(true); - authorizationService.hasPermissionByReferences.mockResolvedValueOnce(false); - - const result: BBBRole = await service.determineBbbRole(userId, entityId, conferenceScope); + const result = await service.determineBbbRole(userId, entityId, conferenceScope); expect(result).toBe(BBBRole.MODERATOR); - expect(authorizationService.hasPermissionByReferences).toHaveBeenCalledWith( - userId, - AuthorizableReferenceType.Course, - entityId, + }); + }); + + // can be removed when team / course / user is passed from UC + // missing when course / team loading throw an error, but also not nessasary if it is passed to UC. + describe('when user has START_MEETING permission and is in team(event) scope', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const entity = teamFactory.buildWithId(); + const conferenceScope = VideoConferenceScope.EVENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(false); + teamsRepo.findById.mockResolvedValueOnce(entity); + + return { + user, + userId: user.id, + entity, + entityId: entity.id, + conferenceScope, + }; + }; + + it('should call the correct authorization order', async () => { + const { user, entity, userId, conferenceScope, entityId } = setup(); + + await service.determineBbbRole(userId, entityId, conferenceScope); + + expect(authorizationService.hasPermission).toHaveBeenCalledWith( + user, + entity, AuthorizationContextBuilder.read([Permission.START_MEETING]) ); }); + + it('should return BBBRole.MODERATOR', async () => { + const { userId, conferenceScope, entityId } = setup(); + + const result = await service.determineBbbRole(userId, entityId, conferenceScope); + + expect(result).toBe(BBBRole.MODERATOR); + }); }); describe('when user has JOIN_MEETING permission', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const entity = courseFactory.buildWithId(); + const conferenceScope = VideoConferenceScope.COURSE; + + authorizationService.hasPermission.mockReturnValueOnce(false).mockReturnValueOnce(true); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + courseService.findById.mockResolvedValueOnce(entity); + + return { + user, + userId: user.id, + entity, + entityId: entity.id, + conferenceScope, + }; + }; + + it('should call the correct authorization order', async () => { + const { user, entity, userId, conferenceScope, entityId } = setup(); + + await service.determineBbbRole(userId, entityId, conferenceScope); + + expect(authorizationService.hasPermission).toHaveBeenNthCalledWith( + 1, + user, + entity, + AuthorizationContextBuilder.read([Permission.START_MEETING]) + ); + expect(authorizationService.hasPermission).toHaveBeenNthCalledWith( + 2, + user, + entity, + AuthorizationContextBuilder.read([Permission.JOIN_MEETING]) + ); + }); + it('should return BBBRole.VIEWER', async () => { const { userId, conferenceScope, entityId } = setup(); - authorizationService.hasPermissionByReferences.mockResolvedValueOnce(false); - authorizationService.hasPermissionByReferences.mockResolvedValueOnce(true); - const result: BBBRole = await service.determineBbbRole(userId, entityId, conferenceScope); + const result = await service.determineBbbRole(userId, entityId, conferenceScope); expect(result).toBe(BBBRole.VIEWER); - expect(authorizationService.hasPermissionByReferences).toHaveBeenCalledWith( - userId, - AuthorizableReferenceType.Course, - entityId, - AuthorizationContextBuilder.read([Permission.JOIN_MEETING]) - ); }); }); describe('when user has neither START_MEETING nor JOIN_MEETING permission', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const entity = courseFactory.buildWithId(); + const conferenceScope = VideoConferenceScope.COURSE; + + authorizationService.hasPermission.mockReturnValueOnce(false).mockReturnValueOnce(false); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + courseService.findById.mockResolvedValueOnce(entity); + + return { + user, + userId: user.id, + entity, + entityId: entity.id, + conferenceScope, + }; + }; + it('should throw a ForbiddenException', async () => { const { userId, conferenceScope, entityId } = setup(); - authorizationService.hasPermissionByReferences.mockResolvedValueOnce(false); - authorizationService.hasPermissionByReferences.mockResolvedValueOnce(false); - const func = () => service.determineBbbRole(userId, entityId, conferenceScope); + const callDetermineBbbRole = () => service.determineBbbRole(userId, entityId, conferenceScope); - await expect(func).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); }); }); }); diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.ts b/apps/server/src/modules/video-conference/service/video-conference.service.ts index 0201b150f33..69a1a7fd74f 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.ts @@ -8,6 +8,7 @@ import { SchoolFeatures, TeamEntity, TeamUserEntity, + User, UserDO, VideoConferenceDO, VideoConferenceOptionsDO, @@ -15,19 +16,13 @@ import { } from '@shared/domain'; import { CalendarEventDto, CalendarService } from '@shared/infra/calendar'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; -import { - Action, - AuthorizableReferenceType, - AuthorizationContextBuilder, - AuthorizationService, -} from '@src/modules/authorization'; -import { CourseService } from '@src/modules/learnroom/service'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { CourseService } from '@src/modules/learnroom'; import { LegacySchoolService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; import { IVideoConferenceSettings, VideoConferenceOptions, VideoConferenceSettings } from '../interface'; -import { PermissionScopeMapping } from '../mapper/video-conference.mapper'; import { IScopeInfo, VideoConferenceState } from '../uc/dto'; @Injectable() @@ -97,39 +92,59 @@ export class VideoConferenceService { return isExpert; } - async determineBbbRole(userId: EntityId, scopeId: EntityId, scope: VideoConferenceScope): Promise { - const permissionMap: Map> = this.hasPermissions( - userId, - PermissionScopeMapping[scope], - scopeId, - [Permission.START_MEETING, Permission.JOIN_MEETING], - Action.read - ); - - if (await permissionMap.get(Permission.START_MEETING)) { - return BBBRole.MODERATOR; - } - if (await permissionMap.get(Permission.JOIN_MEETING)) { - return BBBRole.VIEWER; + // should be public to expose ressources to UC for passing it to authrisation and improve performance + private async loadScopeRessources( + scopeId: EntityId, + scope: VideoConferenceScope + ): Promise { + let scopeRessource: Course | TeamEntity | null = null; + + if (scope === VideoConferenceScope.COURSE) { + scopeRessource = await this.courseService.findById(scopeId); + } else if (scope === VideoConferenceScope.EVENT) { + scopeRessource = await this.teamsRepo.findById(scopeId); + } else { + // Need to be solve the null with throw by it self. } - throw new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION); + + return scopeRessource; + } + + private isNullOrUndefined(value: unknown): value is null { + return !value; } - private hasPermissions( - userId: EntityId, - entityName: AuthorizableReferenceType, - entityId: EntityId, - permissions: Permission[], - action: Action - ): Map> { - const returnMap: Map> = new Map(); - permissions.forEach((perm) => { - const context = - action === Action.read ? AuthorizationContextBuilder.read([perm]) : AuthorizationContextBuilder.write([perm]); - const ret = this.authorizationService.hasPermissionByReferences(userId, entityName, entityId, context); - returnMap.set(perm, ret); - }); - return returnMap; + private hasStartMeetingAndCanRead(authorizableUser: User, entity: Course | TeamEntity): boolean { + const context = AuthorizationContextBuilder.read([Permission.START_MEETING]); + const hasPermission = this.authorizationService.hasPermission(authorizableUser, entity, context); + + return hasPermission; + } + + private hasJoinMeetingAndCanRead(authorizableUser: User, entity: Course | TeamEntity): boolean { + const context = AuthorizationContextBuilder.read([Permission.JOIN_MEETING]); + const hasPermission = this.authorizationService.hasPermission(authorizableUser, entity, context); + + return hasPermission; + } + + async determineBbbRole(userId: EntityId, scopeId: EntityId, scope: VideoConferenceScope): Promise { + // ressource loading need to be move to uc + const [authorizableUser, scopeRessource]: [User, TeamEntity | Course | null] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.loadScopeRessources(scopeId, scope), + ]); + + if (!this.isNullOrUndefined(scopeRessource)) { + if (this.hasStartMeetingAndCanRead(authorizableUser, scopeRessource)) { + return BBBRole.MODERATOR; + } + if (this.hasJoinMeetingAndCanRead(authorizableUser, scopeRessource)) { + return BBBRole.VIEWER; + } + } + + throw new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION); } async throwOnFeaturesDisabled(schoolId: EntityId): Promise { diff --git a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts index e7a28c9a7ca..853bf0f13bc 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts @@ -38,6 +38,12 @@ export class VideoConferenceCreateUc { } private async create(currentUserId: EntityId, scope: ScopeRef, options: VideoConferenceOptions): Promise { + /* need to be replace with + const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.videoConferenceService.loadScopeRessources(scopeId, scope), + ]); + */ const user: UserDO = await this.userService.findById(currentUserId); await this.verifyFeaturesEnabled(user.schoolId); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts index 3680c4519da..4ff494a4cc0 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts @@ -19,9 +19,10 @@ import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto' import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; import { roleFactory, setupEntities, userDoFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; -import { AuthorizationService, LegacySchoolService, UserService } from '@src/modules'; +import { LegacySchoolService, UserService } from '@src/modules'; +import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { ICurrentUser } from '@src/modules/authentication'; -import { CourseService } from '@src/modules/learnroom/service'; +import { CourseService } from '@src/modules/learnroom'; import { IScopeInfo, VideoConference, VideoConferenceJoin, VideoConferenceState } from './dto'; import { VideoConferenceDeprecatedUc } from './video-conference-deprecated.uc'; import { @@ -63,7 +64,7 @@ describe('VideoConferenceUc', () => { let useCase: VideoConferenceDeprecatedUcSpec; let bbbService: DeepMocked; - let authorizationService: DeepMocked; + let authorizationService: DeepMocked; let videoConferenceRepo: DeepMocked; let teamsRepo: DeepMocked; let courseService: DeepMocked; @@ -118,8 +119,8 @@ describe('VideoConferenceUc', () => { useValue: createMock(), }, { - provide: AuthorizationService, - useValue: createMock(), + provide: AuthorizationReferenceService, + useValue: createMock(), }, { provide: VideoConferenceRepo, @@ -149,7 +150,7 @@ describe('VideoConferenceUc', () => { }).compile(); useCase = module.get(VideoConferenceDeprecatedUcSpec); schoolService = module.get(LegacySchoolService); - authorizationService = module.get(AuthorizationService); + authorizationService = module.get(AuthorizationReferenceService); courseService = module.get(CourseService); calendarService = module.get(CalendarService); videoConferenceRepo = module.get(VideoConferenceRepo); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts index 7bce5b2f5a4..e6b1f11ed50 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts @@ -18,14 +18,10 @@ import { CalendarEventDto } from '@shared/infra/calendar/dto/calendar-event.dto' import { TeamsRepo } from '@shared/repo'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { ICurrentUser } from '@src/modules/authentication'; -import { - Action, - AuthorizableReferenceType, - AuthorizationContextBuilder, - AuthorizationService, -} from '@src/modules/authorization'; -import { CourseService } from '@src/modules/learnroom/service'; +import { Action, AuthorizationContextBuilder } from '@src/modules/authorization'; +import { AuthorizationReferenceService, AuthorizableReferenceType } from '@src/modules/authorization/domain'; import { LegacySchoolService } from '@src/modules/legacy-school'; +import { CourseService } from '@src/modules/learnroom'; import { UserService } from '@src/modules/user'; import { BBBBaseMeetingConfig, @@ -62,7 +58,7 @@ export class VideoConferenceDeprecatedUc { constructor( private readonly bbbService: BBBService, - private readonly authorizationService: AuthorizationService, + private readonly authorizationReferenceService: AuthorizationReferenceService, private readonly videoConferenceRepo: VideoConferenceRepo, private readonly teamsRepo: TeamsRepo, private readonly courseService: CourseService, @@ -413,7 +409,7 @@ export class VideoConferenceDeprecatedUc { permissions.forEach((perm) => { const context = action === Action.read ? AuthorizationContextBuilder.read([perm]) : AuthorizationContextBuilder.write([perm]); - const ret = this.authorizationService.hasPermissionByReferences(userId, entityName, entityId, context); + const ret = this.authorizationReferenceService.hasPermissionByReferences(userId, entityName, entityId, context); returnMap.set(perm, ret); }); return returnMap; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts index a9799f67a89..50318c001c0 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts @@ -16,6 +16,12 @@ export class VideoConferenceEndUc { ) {} async end(currentUserId: EntityId, scope: ScopeRef): Promise> { + /* need to be replace with + const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.videoConferenceService.loadScopeRessources(scopeId, scope), + ]); + */ const user: UserDO = await this.userService.findById(currentUserId); const userId: string = user.id as string; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts index 91ebb23ea2b..79a1f95b8d1 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts @@ -17,6 +17,12 @@ export class VideoConferenceInfoUc { ) {} async getMeetingInfo(currentUserId: EntityId, scope: ScopeRef): Promise { + /* need to be replace with + const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.videoConferenceService.loadScopeRessources(scopeId, scope), + ]); + */ const user: UserDO = await this.userService.findById(currentUserId); await this.videoConferenceService.throwOnFeaturesDisabled(user.schoolId); diff --git a/apps/server/src/modules/video-conference/video-conference.module.ts b/apps/server/src/modules/video-conference/video-conference.module.ts index 70a999437c0..6277f0dde0a 100644 --- a/apps/server/src/modules/video-conference/video-conference.module.ts +++ b/apps/server/src/modules/video-conference/video-conference.module.ts @@ -3,6 +3,7 @@ import { HttpModule } from '@nestjs/axios'; import { CalendarModule } from '@shared/infra/calendar'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { AuthorizationModule } from '@src/modules/authorization'; +import { AuthorizationReferenceModule } from '@src/modules/authorization/authorization-reference.module'; import { TeamsRepo } from '@shared/repo'; import { LegacySchoolModule } from '@src/modules/legacy-school'; import { LoggerModule } from '@src/core/logger'; @@ -19,6 +20,7 @@ import { LearnroomModule } from '../learnroom'; @Module({ imports: [ AuthorizationModule, + AuthorizationReferenceModule, // can be removed wenn video-conference-deprecated is removed CalendarModule, HttpModule, LegacySchoolModule, diff --git a/apps/server/src/shared/domain/rules/index.ts b/apps/server/src/shared/domain/rules/index.ts deleted file mode 100644 index 888b2ee8501..00000000000 --- a/apps/server/src/shared/domain/rules/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 { LessonRule } from './lesson.rule'; -import { SchoolExternalToolRule } from './school-external-tool.rule'; -import { LegacySchoolRule } from './legacy-school.rule'; -import { SubmissionRule } from './submission.rule'; -import { TaskRule } from './task.rule'; -import { TeamRule } from './team.rule'; -import { UserLoginMigrationRule } from './user-login-migration.rule'; -import { UserRule } from './user.rule'; - -export * from './board-do.rule'; -export * from './course-group.rule'; -export * from './course.rule'; -export * from './lesson.rule'; -export * from './school-external-tool.rule'; -export * from './legacy-school.rule'; -export * from './submission.rule'; -export * from './task.rule'; -export * from './team.rule'; -export * from './user.rule'; -export * from './context-external-tool.rule'; - -export const ALL_RULES = [ - LessonRule, - CourseRule, - CourseGroupRule, - LegacySchoolRule, - SubmissionRule, - TaskRule, - TeamRule, - UserRule, - SchoolExternalToolRule, - BoardDoRule, - ContextExternalToolRule, - UserLoginMigrationRule, -]; diff --git a/apps/server/src/shared/infra/antivirus/index.ts b/apps/server/src/shared/infra/antivirus/index.ts index 833c46d81a7..2816f95ee57 100644 --- a/apps/server/src/shared/infra/antivirus/index.ts +++ b/apps/server/src/shared/infra/antivirus/index.ts @@ -1,3 +1,3 @@ -export * from './interfaces'; -export * from './antivirus.module'; -export * from './antivirus.service'; +export * from './interfaces'; +export * from './antivirus.module'; +export * from './antivirus.service'; diff --git a/apps/server/src/shared/testing/test-api-client.ts b/apps/server/src/shared/testing/test-api-client.ts index 733dcdd5cfc..75be7b7dc5f 100644 --- a/apps/server/src/shared/testing/test-api-client.ts +++ b/apps/server/src/shared/testing/test-api-client.ts @@ -140,6 +140,10 @@ export class TestApiClient { } private getJwtFromResponse(response: Response): string { + if (response.error) { + const error = JSON.stringify(response.error); + throw new Error(error); + } if (!this.isAuthenticationResponse(response.body)) { const body = JSON.stringify(response.body); throw new Error(`${testReqestConst.errorMessage} ${body}`); From 7b42e1efc1c007dea7d3c745c42bbf694806483b Mon Sep 17 00:00:00 2001 From: Martin Schuhmacher <55735359+MartinSchuhmacher@users.noreply.github.com> Date: Fri, 20 Oct 2023 09:56:47 +0200 Subject: [PATCH 34/34] BC-4293 - Topic does not copy with empty content Learning material (#4485) * copy lernstore content without set resource --- .../service/lesson-copy.service.spec.ts | 49 +++++++++++++++++++ .../lesson/service/lesson-copy.service.ts | 39 ++++++++------- .../src/shared/domain/entity/lesson.entity.ts | 2 +- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts index 39f3f07882a..6097a3b87f6 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts @@ -531,6 +531,55 @@ describe('lesson copy service', () => { }); }); + describe('when lesson contains LernStore content element without set resource', () => { + const setup = () => { + const lernStoreContent: IComponentProperties = { + title: 'text component 1', + hidden: false, + component: ComponentType.LERNSTORE, + }; + const user = userFactory.build(); + const originalCourse = courseFactory.build({ school: user.school }); + const destinationCourse = courseFactory.build({ school: user.school, teachers: [user] }); + const originalLesson = lessonFactory.build({ + course: originalCourse, + contents: [lernStoreContent], + }); + lessonRepo.findById.mockResolvedValueOnce(originalLesson); + + return { user, originalCourse, destinationCourse, originalLesson, lernStoreContent }; + }; + + it('the content should be fully copied', async () => { + const { user, destinationCourse, originalLesson, lernStoreContent } = setup(); + + const status = await copyService.copyLesson({ + originalLessonId: originalLesson.id, + destinationCourse, + user, + }); + + const copiedLessonContents = (status.copyEntity as LessonEntity).contents as IComponentProperties[]; + expect(copiedLessonContents[0]).toEqual(lernStoreContent); + }); + + it('should set content type to LESSON_CONTENT_LERNSTORE', async () => { + const { user, destinationCourse, originalLesson } = setup(); + + const status = await copyService.copyLesson({ + originalLessonId: originalLesson.id, + destinationCourse, + user, + }); + const contentsStatus = status.elements?.find((el) => el.type === CopyElementType.LESSON_CONTENT_GROUP); + expect(contentsStatus).toBeDefined(); + if (contentsStatus?.elements) { + expect(contentsStatus.elements[0].type).toEqual(CopyElementType.LESSON_CONTENT_LERNSTORE); + expect(contentsStatus.elements[0].status).toEqual(CopyStatusEnum.SUCCESS); + } + }); + }); + describe('when lesson contains geoGebra content element', () => { const setup = () => { const geoGebraContent: IComponentProperties = { diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.ts index 4a6835b05f2..cd83681a2e7 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.ts @@ -266,29 +266,32 @@ export class LessonCopyService { } private copyLernStore(element: IComponentProperties): IComponentProperties { - const resources = ((element.content as IComponentLernstoreProperties).resources ?? []).map( - ({ client, description, merlinReference, title, url }) => { - const result = { - client, - description, - merlinReference, - title, - url, - }; - return result; - } - ); - - const lernstore = { + const lernstore: IComponentProperties = { title: element.title, hidden: element.hidden, component: ComponentType.LERNSTORE, user: element.user, // TODO should be params.user - but that made the server crash, but property is normally undefined - content: { - resources, - }, }; - return lernstore as IComponentProperties; + + if (element.content) { + const resources = ((element.content as IComponentLernstoreProperties).resources ?? []).map( + ({ client, description, merlinReference, title, url }) => { + const result = { + client, + description, + merlinReference, + title, + url, + }; + return result; + } + ); + + const lernstoreContent: IComponentLernstoreProperties = { resources }; + lernstore.content = lernstoreContent; + } + + return lernstore; } private static copyGeogebra(originalElement: IComponentProperties): IComponentProperties { diff --git a/apps/server/src/shared/domain/entity/lesson.entity.ts b/apps/server/src/shared/domain/entity/lesson.entity.ts index d83cd2f182a..a47aab2a8b3 100644 --- a/apps/server/src/shared/domain/entity/lesson.entity.ts +++ b/apps/server/src/shared/domain/entity/lesson.entity.ts @@ -73,7 +73,7 @@ export type IComponentProperties = { | { component: ComponentType.ETHERPAD; content: IComponentEtherpadProperties } | { component: ComponentType.GEOGEBRA; content: IComponentGeogebraProperties } | { component: ComponentType.INTERNAL; content: IComponentInternalProperties } - | { component: ComponentType.LERNSTORE; content: IComponentLernstoreProperties } + | { component: ComponentType.LERNSTORE; content?: IComponentLernstoreProperties } | { component: ComponentType.NEXBOARD; content: IComponentNexboardProperties } );