From 81f42f3765b11c39737c34d4f0de2e51a748fbcd Mon Sep 17 00:00:00 2001 From: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:26:50 +0100 Subject: [PATCH] N21-1298 tool context restrictions (#4579) * get tools/context-types * extend external tool data model by restrictToContexts * available tools filter for contexts * create context external tool validation for restricted contexts * add seed data --------- Co-authored-by: Malte Berg --- ...-token-creation-exception.loggable.spec.ts | 17 --- .../user-for-group-not-found.loggable.spec.ts | 19 --- ...many-pseudonyms.loggable-exception.spec.ts | 16 -- .../service/common-tool.service.spec.ts | 76 ++++++++- .../common/service/common-tool.service.ts | 11 +- .../api-test/tool-context.api.spec.ts | 110 +++++++++++++ .../api-test/tool-reference.api.spec.ts | 27 +++- .../context-external-tool.service.spec.ts | 144 +++++++++++++++++- .../service/context-external-tool.service.ts | 39 ++++- ...tricted-context-mismatch-loggabble.spec.ts | 30 ++++ .../restricted-context-mismatch-loggabble.ts | 24 +++ .../uc/context-external-tool.uc.spec.ts | 56 +++++++ .../uc/context-external-tool.uc.ts | 2 + .../api-test/tool-configuration.api.spec.ts | 112 +++++++++++++- .../request/external-tool-create.params.ts | 10 +- .../request/external-tool-update.params.ts | 10 +- .../dto/response/external-tool.response.ts | 5 + .../controller/dto/response/index.ts | 1 + .../tool-context-types-list.response.ts | 11 ++ .../tool-configuration.controller.ts | 20 +++ .../external-tool/domain/external-tool.do.ts | 7 +- .../entity/external-tool.entity.ts | 5 + ...go-fetch-failed-loggable-exception.spec.ts | 16 -- ...xternal-tool-logo-fetched-loggable.spec.ts | 16 -- ...-logo-not-found-loggable-exception.spec.ts | 16 -- ...o-size-exceeded-loggable-exception.spec.ts | 20 --- ...wrong-file-type-loggable-exception.spec.ts | 8 - .../mapper/external-tool-request.mapper.ts | 2 + .../mapper/external-tool-response.mapper.ts | 1 + .../mapper/tool-configuration.mapper.ts | 8 + ...xternal-tool-configuration.service.spec.ts | 102 ++++++++++++- .../external-tool-configuration.service.ts | 24 ++- .../uc/dto/external-tool.types.ts | 3 + .../uc/external-tool-configuration.uc.spec.ts | 101 +++++++++++- .../uc/external-tool-configuration.uc.ts | 22 ++- .../tool-launch.controller.api.spec.ts | 15 +- .../externaltool/external-tool.repo.mapper.ts | 2 + .../context-external-tool-entity.factory.ts | 2 +- .../school-external-tool-entity.factory.ts | 2 +- backup/setup/external-tools.json | 22 +++ backup/setup/school-external-tools.json | 23 +++ 41 files changed, 994 insertions(+), 163 deletions(-) create mode 100644 apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.spec.ts create mode 100644 apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.ts create mode 100644 apps/server/src/modules/tool/external-tool/controller/dto/response/tool-context-types-list.response.ts diff --git a/apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.spec.ts b/apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.spec.ts index 921049458e4..db9cd13b9a1 100644 --- a/apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.spec.ts +++ b/apps/server/src/modules/oauth-provider/error/id-token-creation-exception.loggable.spec.ts @@ -1,23 +1,6 @@ import { IdTokenCreationLoggableException } from './id-token-creation-exception.loggable'; describe('IdTokenCreationExceptionLoggable', () => { - describe('constructor', () => { - const setup = () => { - const clientId = 'clientId'; - const userId = 'userId'; - - return { clientId, userId }; - }; - - it('should create an instance of IdTokenCreationExceptionLoggable', () => { - const { clientId, userId } = setup(); - - const loggable = new IdTokenCreationLoggableException(clientId, userId); - - expect(loggable).toBeInstanceOf(IdTokenCreationLoggableException); - }); - }); - describe('getLogMessage', () => { const setup = () => { const clientId = 'clientId'; diff --git a/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts index 9bb8ee631bc..96095619d5e 100644 --- a/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts +++ b/apps/server/src/modules/provisioning/loggable/user-for-group-not-found.loggable.spec.ts @@ -3,25 +3,6 @@ import { ExternalGroupUserDto } from '../dto'; import { UserForGroupNotFoundLoggable } from './user-for-group-not-found.loggable'; describe('UserForGroupNotFoundLoggable', () => { - describe('constructor', () => { - const setup = () => { - const externalGroupUserDto: ExternalGroupUserDto = new ExternalGroupUserDto({ - externalUserId: 'externalUserId', - roleName: RoleName.TEACHER, - }); - - return { externalGroupUserDto }; - }; - - it('should create an instance of UserForGroupNotFoundLoggable', () => { - const { externalGroupUserDto } = setup(); - - const loggable = new UserForGroupNotFoundLoggable(externalGroupUserDto); - - expect(loggable).toBeInstanceOf(UserForGroupNotFoundLoggable); - }); - }); - describe('getLogMessage', () => { const setup = () => { const externalGroupUserDto: ExternalGroupUserDto = new ExternalGroupUserDto({ 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 index b03350c38ba..9d9cf50165e 100644 --- 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 @@ -1,22 +1,6 @@ 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'; diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts index 11677e6e916..7327fa65abf 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.spec.ts @@ -3,7 +3,7 @@ import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFact import { CommonToolService } from './common-tool.service'; import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { ToolConfigurationStatus } from '../enum'; +import { ToolConfigurationStatus, ToolContextType } from '../enum'; import { ContextExternalTool } from '../../context-external-tool/domain'; describe('CommonToolService', () => { @@ -205,4 +205,78 @@ describe('CommonToolService', () => { }); }); }); + + describe('isContextRestricted', () => { + describe('when tool is not restricted to context', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ restrictToContexts: [] }); + const context: ToolContextType = ToolContextType.COURSE; + + return { externalTool, context }; + }; + + it('should return false', () => { + const { externalTool, context } = setup(); + + const result = service.isContextRestricted(externalTool, context); + + expect(result).toBe(false); + }); + }); + + describe('when tool is restricted to all contexts', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ + restrictToContexts: [ToolContextType.COURSE, ToolContextType.BOARD_ELEMENT], + }); + const context: ToolContextType = ToolContextType.COURSE; + + return { externalTool, context }; + }; + + it('should return false', () => { + const { externalTool, context } = setup(); + + const result = service.isContextRestricted(externalTool, context); + + expect(result).toBe(false); + }); + }); + + describe('when tool is restricted to correct context', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ restrictToContexts: [ToolContextType.COURSE] }); + const context: ToolContextType = ToolContextType.COURSE; + + return { externalTool, context }; + }; + + it('should return false', () => { + const { externalTool, context } = setup(); + + const result = service.isContextRestricted(externalTool, context); + + expect(result).toBe(false); + }); + }); + + describe('when tool is restricted to wrong context', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ + restrictToContexts: [ToolContextType.BOARD_ELEMENT], + }); + const context: ToolContextType = ToolContextType.COURSE; + + return { externalTool, context }; + }; + + it('should return true', () => { + const { externalTool, context } = setup(); + + const result = service.isContextRestricted(externalTool, context); + + expect(result).toBe(true); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/common/service/common-tool.service.ts b/apps/server/src/modules/tool/common/service/common-tool.service.ts index 1dccc42ab1f..eea1706aaf1 100644 --- a/apps/server/src/modules/tool/common/service/common-tool.service.ts +++ b/apps/server/src/modules/tool/common/service/common-tool.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ContextExternalTool } from '../../context-external-tool/domain'; -import { ToolConfigurationStatus } from '../enum'; +import { ToolConfigurationStatus, ToolContextType } from '../enum'; import { ToolVersion } from '../interface'; // TODO N21-1337 remove class when tool versioning is removed @@ -11,7 +11,7 @@ export class CommonToolService { /** * @deprecated use ToolVersionService */ - determineToolConfigurationStatus( + public determineToolConfigurationStatus( externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, contextExternalTool: ContextExternalTool @@ -30,4 +30,11 @@ export class CommonToolService { private isLatest(tool1: ToolVersion, tool2: ToolVersion): boolean { return tool1.getVersion() >= tool2.getVersion(); } + + public isContextRestricted(externalTool: ExternalTool, context: ToolContextType): boolean { + if (externalTool.restrictToContexts?.length && !externalTool.restrictToContexts.includes(context)) { + return true; + } + return false; + } } 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 e40cc8dc4a3..6a645cc944d 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 @@ -81,6 +81,7 @@ describe('ToolContextController (API)', () => { isOptional: true, }), ], + restrictToContexts: [ToolContextType.COURSE], version: 1, }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ @@ -174,6 +175,115 @@ describe('ToolContextController (API)', () => { // expected body is missed }); }); + + describe('when external tool has no restrictions ', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [], + restrictToContexts: [], + version: 1, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + toolVersion: 1, + }); + + const postParams: ContextExternalToolPostParams = { + schoolToolId: schoolExternalToolEntity.id, + contextId: course.id, + displayName: course.name, + contextType: ToolContextType.COURSE, + parameters: [], + toolVersion: 1, + }; + + await em.persistAndFlush([teacherUser, teacherAccount, course, school, schoolExternalToolEntity]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + postParams, + }; + }; + + it('should create tool', async () => { + const { postParams, loggedInClient } = await setup(); + + const response = await loggedInClient.post().send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.CREATED); + expect(response.body).toEqual({ + id: expect.any(String), + schoolToolId: postParams.schoolToolId, + contextId: postParams.contextId, + displayName: postParams.displayName, + contextType: postParams.contextType, + parameters: [], + toolVersion: postParams.toolVersion, + }); + }); + }); + + describe('when external tool restricts to wrong context ', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_ADMIN, + ]); + + const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + parameters: [], + restrictToContexts: [ToolContextType.BOARD_ELEMENT], + version: 1, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + toolVersion: 1, + }); + + const postParams: ContextExternalToolPostParams = { + schoolToolId: schoolExternalToolEntity.id, + contextId: course.id, + displayName: course.name, + contextType: ToolContextType.COURSE, + parameters: [], + toolVersion: 1, + }; + + await em.persistAndFlush([teacherUser, teacherAccount, course, school, schoolExternalToolEntity]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + postParams, + }; + }; + + it('should return unprocessable entity', async () => { + const { postParams, loggedInClient } = await setup(); + + const response = await loggedInClient.post().send(postParams); + + expect(response.statusCode).toEqual(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); }); describe('[DELETE] tools/context-external-tools/:contextExternalToolId', () => { 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 index a0351af6c47..bc907e421eb 100644 --- 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 @@ -10,12 +10,13 @@ import { cleanupCollections, contextExternalToolEntityFactory, courseFactory, + customParameterFactory, externalToolEntityFactory, schoolExternalToolEntityFactory, schoolFactory, } from '@shared/testing'; import { Response } from 'supertest'; -import { ToolContextType } from '../../../common/enum'; +import { CustomParameterLocation, CustomParameterScope, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; @@ -115,6 +116,18 @@ describe('ToolReferenceController (API)', () => { const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ logoBase64: 'logoBase64', + parameters: [ + customParameterFactory.build({ + name: 'schoolMockParameter', + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.PATH, + }), + customParameterFactory.build({ + name: 'contextMockParameter', + scope: CustomParameterScope.CONTEXT, + location: CustomParameterLocation.PATH, + }), + ], }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, @@ -234,6 +247,18 @@ describe('ToolReferenceController (API)', () => { const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ logoBase64: 'logoBase64', + parameters: [ + customParameterFactory.build({ + name: 'schoolMockParameter', + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.PATH, + }), + customParameterFactory.build({ + name: 'contextMockParameter', + scope: CustomParameterScope.CONTEXT, + location: CustomParameterLocation.PATH, + }), + ], }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, 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 ba4c90a6cb0..a05a3bd7370 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 @@ -1,21 +1,32 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AuthorizationService } from '@modules/authorization'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ContextExternalToolRepo } from '@shared/repo'; import { contextExternalToolFactory, + externalToolFactory, legacySchoolDoFactory, schoolExternalToolFactory, } from '@shared/testing/factory/domainobject'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Permission } from '@shared/domain/interface'; import { ToolContextType } from '../../common/enum'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ContextExternalTool, ContextRef } from '../domain'; import { ContextExternalToolService } from './context-external-tool.service'; +import { ExternalTool } from '../../external-tool/domain'; +import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { RestrictedContextMismatchLoggable } from './restricted-context-mismatch-loggabble'; +import { CommonToolService } from '../../common/service'; describe('ContextExternalToolService', () => { let module: TestingModule; let service: ContextExternalToolService; + let externalToolService: DeepMocked; + let schoolExternalToolService: DeepMocked; + let commonToolService: DeepMocked; let contextExternalToolRepo: DeepMocked; @@ -31,11 +42,26 @@ describe('ContextExternalToolService', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: ExternalToolService, + useValue: createMock(), + }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, + { + provide: CommonToolService, + useValue: createMock(), + }, ], }).compile(); service = module.get(ContextExternalToolService); contextExternalToolRepo = module.get(ContextExternalToolRepo); + externalToolService = module.get(ExternalToolService); + schoolExternalToolService = module.get(SchoolExternalToolService); + commonToolService = module.get(CommonToolService); }); afterAll(async () => { @@ -258,4 +284,120 @@ describe('ContextExternalToolService', () => { }); }); }); + + describe('checkContextRestrictions', () => { + describe('when contexts are not restricted', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.build({ restrictToContexts: [] }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + commonToolService.isContextRestricted.mockReturnValueOnce(false); + + return { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + }; + + it('should find SchoolExternalTool', async () => { + const { contextExternalTool } = setup(); + + await service.checkContextRestrictions(contextExternalTool); + + expect(schoolExternalToolService.findById).toHaveBeenCalledWith(contextExternalTool.schoolToolRef.schoolToolId); + }); + + it('should find ExternalTool', async () => { + const { contextExternalTool, schoolExternalTool } = setup(); + + await service.checkContextRestrictions(contextExternalTool); + + expect(externalToolService.findById).toHaveBeenCalledWith(schoolExternalTool.toolId); + }); + + it('should check if context is restricted', async () => { + const { contextExternalTool, externalTool } = setup(); + + await service.checkContextRestrictions(contextExternalTool); + + expect(commonToolService.isContextRestricted).toHaveBeenCalledWith( + externalTool, + contextExternalTool.contextRef.type + ); + }); + + it('should not throw', async () => { + const { contextExternalTool } = setup(); + + await expect(service.checkContextRestrictions(contextExternalTool)).resolves.not.toThrow(); + }); + }); + + describe('when context is restricted to correct context type', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + + const externalTool: ExternalTool = externalToolFactory.build({ restrictToContexts: [ToolContextType.COURSE] }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + contextRef: { type: ToolContextType.COURSE }, + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + commonToolService.isContextRestricted.mockReturnValueOnce(false); + + return { + userId, + context, + contextExternalTool, + }; + }; + + it('should not throw', async () => { + const { contextExternalTool } = setup(); + + await expect(service.checkContextRestrictions(contextExternalTool)).resolves.not.toThrow(); + }); + }); + + describe('when context is restricted to wrong context type', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + + const externalTool: ExternalTool = externalToolFactory.build({ + restrictToContexts: [ToolContextType.BOARD_ELEMENT], + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + contextRef: { type: ToolContextType.COURSE }, + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + commonToolService.isContextRestricted.mockReturnValueOnce(true); + + return { + userId, + context, + contextExternalTool, + externalTool, + }; + }; + + it('should throw RestrictedContextMismatchLoggable', async () => { + const { contextExternalTool, externalTool } = setup(); + + await expect(service.checkContextRestrictions(contextExternalTool)).rejects.toThrow( + new RestrictedContextMismatchLoggable(externalTool.name, contextExternalTool.contextRef.type) + ); + }); + }); + }); }); 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 4bf429c85de..baf6bd82b06 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 @@ -3,36 +3,47 @@ import { EntityId } from '@shared/domain/types'; import { ContextExternalToolRepo } from '@shared/repo'; import { ContextExternalTool, ContextRef } from '../domain'; import { ContextExternalToolQuery } from '../uc/dto/context-external-tool.types'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ExternalTool } from '../../external-tool/domain'; +import { ExternalToolService } from '../../external-tool/service'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { RestrictedContextMismatchLoggable } from './restricted-context-mismatch-loggabble'; +import { CommonToolService } from '../../common/service'; @Injectable() export class ContextExternalToolService { - constructor(private readonly contextExternalToolRepo: ContextExternalToolRepo) {} + constructor( + private readonly contextExternalToolRepo: ContextExternalToolRepo, + private readonly externalToolService: ExternalToolService, + private readonly schoolExternalToolService: SchoolExternalToolService, + private readonly commonToolService: CommonToolService + ) {} - async findContextExternalTools(query: ContextExternalToolQuery): Promise { + public async findContextExternalTools(query: ContextExternalToolQuery): Promise { const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find(query); return contextExternalTools; } - async findByIdOrFail(contextExternalToolId: EntityId): Promise { + public async findByIdOrFail(contextExternalToolId: EntityId): Promise { const tool: ContextExternalTool = await this.contextExternalToolRepo.findById(contextExternalToolId); return tool; } - async findById(contextExternalToolId: EntityId): Promise { + public async findById(contextExternalToolId: EntityId): Promise { const tool: ContextExternalTool | null = await this.contextExternalToolRepo.findByIdOrNull(contextExternalToolId); return tool; } - async saveContextExternalTool(contextExternalTool: ContextExternalTool): Promise { + public async saveContextExternalTool(contextExternalTool: ContextExternalTool): Promise { const savedContextExternalTool: ContextExternalTool = await this.contextExternalToolRepo.save(contextExternalTool); return savedContextExternalTool; } - async deleteBySchoolExternalToolId(schoolExternalToolId: EntityId) { + public async deleteBySchoolExternalToolId(schoolExternalToolId: EntityId) { const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find({ schoolToolRef: { schoolToolId: schoolExternalToolId, @@ -42,15 +53,27 @@ export class ContextExternalToolService { await this.contextExternalToolRepo.delete(contextExternalTools); } - async deleteContextExternalTool(contextExternalTool: ContextExternalTool): Promise { + public async deleteContextExternalTool(contextExternalTool: ContextExternalTool): Promise { await this.contextExternalToolRepo.delete(contextExternalTool); } - async findAllByContext(contextRef: ContextRef): Promise { + public async findAllByContext(contextRef: ContextRef): Promise { const contextExternalTools: ContextExternalTool[] = await this.contextExternalToolRepo.find({ context: contextRef, }); return contextExternalTools; } + + public async checkContextRestrictions(contextExternalTool: ContextExternalTool): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( + contextExternalTool.schoolToolRef.schoolToolId + ); + + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); + + if (this.commonToolService.isContextRestricted(externalTool, contextExternalTool.contextRef.type)) { + throw new RestrictedContextMismatchLoggable(externalTool.name, contextExternalTool.contextRef.type); + } + } } diff --git a/apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.spec.ts b/apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.spec.ts new file mode 100644 index 00000000000..7e57cb79cc5 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.spec.ts @@ -0,0 +1,30 @@ +import { ToolContextType } from '../../common/enum'; +import { RestrictedContextMismatchLoggable } from './restricted-context-mismatch-loggabble'; + +describe('RestrictedContextMismatchLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const externalToolName = 'name'; + const context: ToolContextType = ToolContextType.COURSE; + const loggable = new RestrictedContextMismatchLoggable(externalToolName, context); + + return { loggable, externalToolName, context }; + }; + + it('should return a loggable message', () => { + const { loggable, externalToolName, context } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'UNPROCESSABLE_ENTITY_EXCEPTION', + message: `Could not create an instance of ${externalToolName} in context: ${context} because of the context restrictions of the tool.`, + stack: loggable.stack, + data: { + externalToolName, + context, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.ts b/apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.ts new file mode 100644 index 00000000000..8e6fc4e643a --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/service/restricted-context-mismatch-loggabble.ts @@ -0,0 +1,24 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ToolContextType } from '../../common/enum'; + +export class RestrictedContextMismatchLoggable extends UnprocessableEntityException implements Loggable { + constructor(private readonly externalToolName: string, private readonly context: ToolContextType) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const message: LogMessage | ErrorLogMessage | ValidationErrorLogMessage = { + type: 'UNPROCESSABLE_ENTITY_EXCEPTION', + message: `Could not create an instance of ${this.externalToolName} in context: ${this.context} because of the context restrictions of the tool.`, + stack: this.stack, + data: { + externalToolName: this.externalToolName, + context: this.context, + }, + }; + + return message; + } +} 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 d6f0c440c2d..5078aca1b14 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 @@ -2,6 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Action, + AuthorizationContext, AuthorizationContextBuilder, AuthorizationService, ForbiddenLoggableException, @@ -128,6 +129,14 @@ describe('ContextExternalToolUc', () => { ); }); + it('should check for context restrictions', async () => { + const { contextExternalTool, userId, schoolId } = setup(); + + await uc.createContextExternalTool(userId, schoolId, contextExternalTool); + + expect(contextExternalToolService.checkContextRestrictions).toHaveBeenCalledWith(contextExternalTool); + }); + it('should call contextExternalToolValidationService', async () => { const { contextExternalTool, userId, schoolId } = setup(); @@ -145,6 +154,53 @@ describe('ContextExternalToolUc', () => { }); }); + describe('when tool is restricted to a different context', () => { + const setup = () => { + const userId: EntityId = new ObjectId().toHexString(); + const schoolId: EntityId = new ObjectId().toHexString(); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + schoolId, + }); + + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ + displayName: 'Course', + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId, + }, + contextRef: { + id: 'contextId', + type: ToolContextType.COURSE, + }, + }); + + const context: AuthorizationContext = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); + + const error: Error = new Error(); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + contextExternalToolService.saveContextExternalTool.mockResolvedValue(contextExternalTool); + contextExternalToolService.checkContextRestrictions.mockRejectedValueOnce(error); + + return { + contextExternalTool, + userId, + schoolId, + context, + error, + }; + }; + + it('should throw an error and not save the contextExternalTool', async () => { + const { contextExternalTool, userId, error, schoolId } = setup(); + + await expect(uc.createContextExternalTool(userId, schoolId, contextExternalTool)).rejects.toThrow(error); + + expect(contextExternalToolService.saveContextExternalTool).not.toHaveBeenCalled(); + }); + }); + describe('when the user is from a different school than the school external tool', () => { const setup = () => { const userId: EntityId = '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 6e2c1712aba..590361b09cf 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 @@ -47,6 +47,8 @@ export class ContextExternalToolUc { await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); + await this.contextExternalToolService.checkContextRestrictions(contextExternalTool); + await this.contextExternalToolValidationService.validate(contextExternalTool); const createdTool: ContextExternalTool = await this.contextExternalToolService.saveContextExternalTool( 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 1bd9b027766..1207c326ea8 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 @@ -1,15 +1,15 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; -import { CustomParameterTypeParams } from '@modules/tool/common/enum'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, Course, SchoolEntity, User } from '@shared/domain/entity'; +import { Account, Board, Course, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { TestApiClient, UserAndAccountTestFactory, accountFactory, + boardFactory, contextExternalToolEntityFactory, courseFactory, customParameterFactory, @@ -19,7 +19,12 @@ import { userFactory, } from '@shared/testing'; import { Response } from 'supertest'; -import { CustomParameterLocationParams, CustomParameterScopeTypeParams } from '../../../common/enum'; +import { + CustomParameterLocationParams, + CustomParameterScopeTypeParams, + CustomParameterTypeParams, + ToolContextType, +} from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { ExternalToolEntity } from '../../entity'; @@ -28,6 +33,7 @@ import { ContextExternalToolConfigurationTemplateResponse, SchoolExternalToolConfigurationTemplateListResponse, SchoolExternalToolConfigurationTemplateResponse, + ToolContextTypesListResponse, } from '../dto'; describe('ToolConfigurationController (API)', () => { @@ -112,7 +118,7 @@ describe('ToolConfigurationController (API)', () => { }); }); - describe('when tools are available for a course', () => { + describe('when tools are available for a context', () => { const setup = async () => { const school: SchoolEntity = schoolFactory.buildWithId(); @@ -121,35 +127,67 @@ describe('ToolConfigurationController (API)', () => { ]); const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); + const board: Board = boardFactory.buildWithId({ course }); const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + restrictToContexts: [ToolContextType.COURSE], logoBase64: 'logo', parameters: [globalParameter, schoolParameter, contextParameter], }); externalTool.logoUrl = `http://localhost:3030/api/v3/tools/external-tools/${externalTool.id}/logo`; + const externalToolWithoutContextRestriction: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + restrictToContexts: [], + }); + const schoolExternalTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school, tool: externalTool, }); - await em.persistAndFlush([school, course, teacherUser, teacherAccount, externalTool, schoolExternalTool]); + const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + school, + tool: externalToolWithoutContextRestriction, + }); + + await em.persistAndFlush([ + school, + course, + board, + teacherUser, + teacherAccount, + externalTool, + externalToolWithoutContextRestriction, + schoolExternalTool, + schoolExternalTool2, + ]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); return { course, + board, externalTool, + externalToolWithoutContextRestriction, schoolExternalTool, + schoolExternalTool2, contextParameter, loggedInClient, }; }; it('should return an array of available tools with parameters of scope context', async () => { - const { course, externalTool, contextParameter, schoolExternalTool, loggedInClient } = await setup(); + const { + course, + externalTool, + externalToolWithoutContextRestriction, + contextParameter, + schoolExternalTool, + schoolExternalTool2, + loggedInClient, + } = await setup(); const response: Response = await loggedInClient.get(`course/${course.id}/available-tools`); @@ -176,6 +214,31 @@ describe('ToolConfigurationController (API)', () => { ], version: externalTool.version, }, + { + externalToolId: externalToolWithoutContextRestriction.id, + name: externalToolWithoutContextRestriction.name, + parameters: [], + schoolExternalToolId: schoolExternalTool2.id, + version: externalToolWithoutContextRestriction.version, + }, + ], + }); + }); + + it('should not return the context restricted tool', async () => { + const { board, loggedInClient, externalToolWithoutContextRestriction, schoolExternalTool2 } = await setup(); + + const response: Response = await loggedInClient.get(`board-element/${board.id}/available-tools`); + + expect(response.body).toEqual({ + data: [ + { + externalToolId: externalToolWithoutContextRestriction.id, + name: externalToolWithoutContextRestriction.name, + parameters: [], + schoolExternalToolId: schoolExternalTool2.id, + version: externalToolWithoutContextRestriction.version, + }, ], }); }); @@ -634,8 +697,45 @@ describe('ToolConfigurationController (API)', () => { const response: Response = await loggedInClient.get( `context-external-tools/${contextExternalTool.id}/configuration-template` ); + expect(response.status).toEqual(HttpStatus.NOT_FOUND); }); }); }); + + describe('GET tools/context-types', () => { + describe('when user is not authorized', () => { + it('should return unauthorized status', async () => { + const response = await testApiClient.get('context-types'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user is authorized', () => { + const setup = async () => { + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); + + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + const contextTypeList: ToolContextTypesListResponse = new ToolContextTypesListResponse([ + ToolContextType.COURSE, + ToolContextType.BOARD_ELEMENT, + ]); + + return { loggedInClient, contextTypeList }; + }; + + it('should return all context types', async () => { + const { loggedInClient, contextTypeList } = await setup(); + + const response = await loggedInClient.get('context-types'); + + expect(response.body).toEqual(contextTypeList); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts index b094bbe7f04..780535f3626 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts @@ -1,7 +1,7 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { ToolConfigType } from '../../../../common/enum'; +import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { ToolConfigType, ToolContextType } from '../../../../common/enum'; import { BasicToolConfigParams, ExternalToolConfigCreateParams, @@ -61,4 +61,10 @@ export class ExternalToolCreateParams { @IsBoolean() @ApiProperty() openNewTab!: boolean; + + @IsArray() + @IsOptional() + @IsEnum(ToolContextType, { each: true }) + @ApiPropertyOptional({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + restrictToContexts?: ToolContextType[]; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts index 6d34f738e5c..be19e9c0bde 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts @@ -1,7 +1,7 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { ToolConfigType } from '../../../../common/enum'; +import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { ToolConfigType, ToolContextType } from '../../../../common/enum'; import { BasicToolConfigParams, ExternalToolConfigCreateParams, @@ -65,4 +65,10 @@ export class ExternalToolUpdateParams { @IsBoolean() @ApiProperty() openNewTab!: boolean; + + @IsArray() + @IsOptional() + @IsEnum(ToolContextType, { each: true }) + @ApiPropertyOptional({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + restrictToContexts?: ToolContextType[]; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts index ef74247176d..dc20b85b520 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { BasicToolConfigResponse, Oauth2ToolConfigResponse, Lti11ToolConfigResponse } from './config'; import { CustomParameterResponse } from './custom-parameter.response'; +import { ToolContextType } from '../../../../common/enum'; export class ExternalToolResponse { @ApiProperty() @@ -30,6 +31,9 @@ export class ExternalToolResponse { @ApiProperty() version: number; + @ApiPropertyOptional({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + restrictToContexts?: ToolContextType[]; + constructor(response: ExternalToolResponse) { this.id = response.id; this.name = response.name; @@ -40,5 +44,6 @@ export class ExternalToolResponse { this.isHidden = response.isHidden; this.openNewTab = response.openNewTab; this.version = response.version; + this.restrictToContexts = response.restrictToContexts; } } 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 4f532238863..e621ade2020 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 @@ -7,3 +7,4 @@ export * from './context-external-tool-configuration-template-list.response'; export * from './school-external-tool-configuration-template.response'; export * from './school-external-tool-configuration-template-list.response'; export * from './external-tool-metadata.response'; +export * from './tool-context-types-list.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-context-types-list.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-context-types-list.response.ts new file mode 100644 index 00000000000..9d1b4518514 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/tool-context-types-list.response.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ToolContextType } from '../../../../common/enum'; + +export class ToolContextTypesListResponse { + @ApiProperty({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + data: ToolContextType[]; + + constructor(data: ToolContextType[]) { + this.data = data; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts b/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts index 5dbe2db8e9b..48a3ff0031d 100644 --- a/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/tool-configuration.controller.ts @@ -20,7 +20,9 @@ import { SchoolExternalToolConfigurationTemplateResponse, SchoolExternalToolIdParams, SchoolIdParams, + ToolContextTypesListResponse, } from './dto'; +import { ToolContextType } from '../../common/enum'; @ApiTags('Tool') @Authenticate('jwt') @@ -28,6 +30,24 @@ import { export class ToolConfigurationController { constructor(private readonly externalToolConfigurationUc: ExternalToolConfigurationUc) {} + @Get('context-types') + @ApiForbiddenResponse() + @ApiOperation({ summary: 'Lists all context types available in the SVS' }) + @ApiOkResponse({ + description: 'List of available context types', + type: ToolContextTypesListResponse, + }) + public async getToolContextTypes(@CurrentUser() currentUser: ICurrentUser): Promise { + const toolContextTypes: ToolContextType[] = await this.externalToolConfigurationUc.getToolContextTypes( + currentUser.userId + ); + + const mapped: ToolContextTypesListResponse = + ToolConfigurationMapper.mapToToolContextTypesListResponse(toolContextTypes); + + return mapped; + } + @Get('school/:schoolId/available-tools') @ApiForbiddenResponse() @ApiOperation({ summary: 'Lists all available tools that can be added for a given school' }) diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts index ba9f1d6e84d..28daa0e975f 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -2,7 +2,7 @@ import { BaseDO } from '@shared/domain/domainobject/base.do'; import { ToolVersion } from '../../common/interface'; import { Oauth2ToolConfig, BasicToolConfig, Lti11ToolConfig, ExternalToolConfig } from './config'; import { CustomParameter } from '../../common/domain'; -import { ToolConfigType } from '../../common/enum'; +import { ToolConfigType, ToolContextType } from '../../common/enum'; export interface ExternalToolProps { id?: string; @@ -24,6 +24,8 @@ export interface ExternalToolProps { openNewTab: boolean; version: number; + + restrictToContexts?: ToolContextType[]; } export class ExternalTool extends BaseDO implements ToolVersion { @@ -45,6 +47,8 @@ export class ExternalTool extends BaseDO implements ToolVersion { version: number; + restrictToContexts?: ToolContextType[]; + constructor(props: ExternalToolProps) { super(props.id); @@ -57,6 +61,7 @@ export class ExternalTool extends BaseDO implements ToolVersion { this.isHidden = props.isHidden; this.openNewTab = props.openNewTab; this.version = props.version; + this.restrictToContexts = props.restrictToContexts; } getVersion(): number { diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index 481ed3b7c2d..8a80404fe14 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -3,6 +3,7 @@ import { Embedded, Entity, Property, Unique } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { CustomParameterEntity } from './custom-parameter'; import { BasicToolConfigEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity } from './config'; +import { ToolContextType } from '../../common/enum'; export type IExternalToolProperties = Readonly>; @@ -36,6 +37,9 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { @Property() version: number; + @Property({ nullable: true }) + restrictToContexts?: ToolContextType[]; + constructor(props: IExternalToolProperties) { super(); this.name = props.name; @@ -47,5 +51,6 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { this.isHidden = props.isHidden; this.openNewTab = props.openNewTab; this.version = props.version; + this.restrictToContexts = props.restrictToContexts; } } diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts index 8674cba0412..e5537972eeb 100644 --- a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetch-failed-loggable-exception.spec.ts @@ -1,22 +1,6 @@ import { ExternalToolLogoFetchFailedLoggableException } from './external-tool-logo-fetch-failed-loggable-exception'; describe('ExternalToolLogoFetchFailedLoggableException', () => { - describe('constructor', () => { - const setup = () => { - const logoUrl = 'logoUrl'; - - return { logoUrl }; - }; - - it('should create an instance of ExternalToolLogoNotFoundLoggableException', () => { - const { logoUrl } = setup(); - - const loggable = new ExternalToolLogoFetchFailedLoggableException(logoUrl, undefined); - - expect(loggable).toBeInstanceOf(ExternalToolLogoFetchFailedLoggableException); - }); - }); - describe('getLogMessage', () => { const setup = () => { const logoUrl = 'logoUrl'; diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts index 56d32494f9d..ab3dd7c6cc2 100644 --- a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-fetched-loggable.spec.ts @@ -1,22 +1,6 @@ import { ExternalToolLogoFetchedLoggable } from './external-tool-logo-fetched-loggable'; describe('ExternalToolLogoFetchedLoggable', () => { - describe('constructor', () => { - const setup = () => { - const logoUrl = 'logoUrl'; - - return { logoUrl }; - }; - - it('should create an instance of ExternalToolLogoFetchedLoggable', () => { - const { logoUrl } = setup(); - - const loggable = new ExternalToolLogoFetchedLoggable(logoUrl); - - expect(loggable).toBeInstanceOf(ExternalToolLogoFetchedLoggable); - }); - }); - describe('getLogMessage', () => { const setup = () => { const logoUrl = 'logoUrl'; diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts index de6c298a3b2..aa8ae13cd28 100644 --- a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-not-found-loggable-exception.spec.ts @@ -1,22 +1,6 @@ import { ExternalToolLogoNotFoundLoggableException } from './external-tool-logo-not-found-loggable-exception'; describe('ExternalToolLogoNotFoundLoggableException', () => { - describe('constructor', () => { - const setup = () => { - const externalToolId = 'externalToolId'; - - return { externalToolId }; - }; - - it('should create an instance of ExternalToolLogoNotFoundLoggableException', () => { - const { externalToolId } = setup(); - - const loggable = new ExternalToolLogoNotFoundLoggableException(externalToolId); - - expect(loggable).toBeInstanceOf(ExternalToolLogoNotFoundLoggableException); - }); - }); - describe('getLogMessage', () => { const setup = () => { const externalToolId = 'externalToolId'; diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts index e22b66d49fd..36636722eba 100644 --- a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-size-exceeded-loggable-exception.spec.ts @@ -1,26 +1,6 @@ import { ExternalToolLogoSizeExceededLoggableException } from './external-tool-logo-size-exceeded-loggable-exception'; describe('ExternalToolLogoSizeExceededLoggableException', () => { - describe('constructor', () => { - const setup = () => { - const externalToolId = 'externalToolId'; - const maxExternalToolLogoSizeInBytes = 100; - - return { externalToolId, maxExternalToolLogoSizeInBytes }; - }; - - it('should create an instance of ExternalToolLogoSizeExceededLoggableException', () => { - const { externalToolId, maxExternalToolLogoSizeInBytes } = setup(); - - const loggable = new ExternalToolLogoSizeExceededLoggableException( - externalToolId, - maxExternalToolLogoSizeInBytes - ); - - expect(loggable).toBeInstanceOf(ExternalToolLogoSizeExceededLoggableException); - }); - }); - describe('getLogMessage', () => { const setup = () => { const externalToolId = 'externalToolId'; diff --git a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts index cd5e5fa87d3..822e61c613f 100644 --- a/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/external-tool/loggable/external-tool-logo-wrong-file-type-loggable-exception.spec.ts @@ -1,14 +1,6 @@ import { ExternalToolLogoWrongFileTypeLoggableException } from './external-tool-logo-wrong-file-type-loggable-exception'; describe('ExternalToolLogoWrongFileTypeLoggableException', () => { - describe('constructor', () => { - it('should create an instance of ExternalToolLogoSizeExceededLoggableException', () => { - const loggable = new ExternalToolLogoWrongFileTypeLoggableException(); - - expect(loggable).toBeInstanceOf(ExternalToolLogoWrongFileTypeLoggableException); - }); - }); - describe('getLogMessage', () => { const setup = () => { const loggable = new ExternalToolLogoWrongFileTypeLoggableException(); diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts index 4cfc3303d0a..916e1c734a8 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts @@ -81,6 +81,7 @@ export class ExternalToolRequestMapper { isHidden: externalToolUpdateParams.isHidden, openNewTab: externalToolUpdateParams.openNewTab, version, + restrictToContexts: externalToolUpdateParams.restrictToContexts, }; } @@ -107,6 +108,7 @@ export class ExternalToolRequestMapper { isHidden: externalToolCreateParams.isHidden, openNewTab: externalToolCreateParams.openNewTab, version, + restrictToContexts: externalToolCreateParams.restrictToContexts, }; } 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 b2035e66477..eadbc20c50a 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 @@ -65,6 +65,7 @@ export class ExternalToolResponseMapper { isHidden: externalTool.isHidden, openNewTab: externalTool.openNewTab, version: externalTool.version, + restrictToContexts: externalTool.restrictToContexts, }); } diff --git a/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts index a3aebbd6302..b1846d60ea0 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/tool-configuration.mapper.ts @@ -3,10 +3,12 @@ import { ContextExternalToolConfigurationTemplateResponse, SchoolExternalToolConfigurationTemplateListResponse, SchoolExternalToolConfigurationTemplateResponse, + ToolContextTypesListResponse, } from '../controller/dto'; import { ExternalTool } from '../domain'; import { ContextExternalToolTemplateInfo } from '../uc'; import { ExternalToolResponseMapper } from './external-tool-response.mapper'; +import { ToolContextType } from '../../common/enum'; export class ToolConfigurationMapper { static mapToSchoolExternalToolConfigurationTemplateResponse( @@ -69,4 +71,10 @@ export class ToolConfigurationMapper { return mapped; } + + static mapToToolContextTypesListResponse(toolContextTypes: ToolContextType[]): ToolContextTypesListResponse { + const mappedTypes = new ToolContextTypesListResponse(toolContextTypes); + + return mappedTypes; + } } diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts index 1fac35ae87c..6e6f7159e80 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.spec.ts @@ -8,18 +8,21 @@ import { schoolExternalToolFactory, setupEntities, } from '@shared/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { CustomParameter } from '../../common/domain'; -import { CustomParameterScope } from '../../common/enum'; +import { CustomParameterScope, ToolContextType } from '../../common/enum'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ContextExternalToolTemplateInfo } from '../uc'; import { ExternalToolConfigurationService } from './external-tool-configuration.service'; +import { CommonToolService } from '../../common/service'; describe('ExternalToolConfigurationService', () => { let module: TestingModule; let service: ExternalToolConfigurationService; + let commonToolService: DeepMocked; let toolFeatures: IToolFeatures; @@ -35,11 +38,16 @@ describe('ExternalToolConfigurationService', () => { contextConfigurationEnabled: false, }, }, + { + provide: CommonToolService, + useValue: createMock(), + }, ], }).compile(); service = module.get(ExternalToolConfigurationService); toolFeatures = module.get(ToolFeatures); + commonToolService = module.get(CommonToolService); }); afterEach(() => { @@ -187,6 +195,88 @@ describe('ExternalToolConfigurationService', () => { }); }); + describe('filterForContextRestrictions', () => { + describe('when tool has no context restrictions', () => { + const setup = () => { + const contextType = ToolContextType.COURSE; + + const externalTool: ExternalTool = externalToolFactory.build({ restrictToContexts: [] }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + + const availableTools: ContextExternalToolTemplateInfo[] = [{ externalTool, schoolExternalTool }]; + + commonToolService.isContextRestricted.mockReturnValueOnce(false); + + return { + contextType, + availableTools, + }; + }; + + it('should check if context is restricted', () => { + const { contextType, availableTools } = setup(); + + service.filterForContextRestrictions(availableTools, contextType); + + expect(commonToolService.isContextRestricted).toHaveBeenCalledWith(availableTools[0].externalTool, contextType); + }); + + it('should pass the filter', () => { + const { contextType, availableTools } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForContextRestrictions( + availableTools, + contextType + ); + + expect(result).toEqual(availableTools); + }); + }); + + describe('when context restrictions are given', () => { + const setup = () => { + const contextType: ToolContextType = ToolContextType.COURSE; + + const externalToolWithCourseRestriction: ExternalTool = externalToolFactory.build({ + restrictToContexts: [ToolContextType.COURSE], + }); + const externalToolWithBoardRestriction: ExternalTool = externalToolFactory.build({ + restrictToContexts: [ToolContextType.BOARD_ELEMENT], + }); + + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + + const availableTools: ContextExternalToolTemplateInfo[] = [ + { externalTool: externalToolWithCourseRestriction, schoolExternalTool }, + { externalTool: externalToolWithBoardRestriction, schoolExternalTool }, + ]; + + commonToolService.isContextRestricted.mockReturnValueOnce(false); + commonToolService.isContextRestricted.mockReturnValueOnce(true); + + return { + contextType, + availableTools, + externalToolWithCourseRestriction, + schoolExternalTool, + }; + }; + + it('should only return tools restricted to this context', () => { + const { contextType, availableTools, externalToolWithCourseRestriction, schoolExternalTool } = setup(); + + const result: ContextExternalToolTemplateInfo[] = service.filterForContextRestrictions( + availableTools, + contextType + ); + + expect(result).toEqual([ + { externalTool: externalToolWithCourseRestriction, schoolExternalTool }, + ]); + }); + }); + }); + describe('filterParametersForScope', () => { describe('when filtering parameters for scope', () => { const setup = () => { @@ -211,4 +301,14 @@ describe('ExternalToolConfigurationService', () => { }); }); }); + + describe('getToolContextTypes', () => { + describe('when it is called', () => { + it('should return ToolContextTypes', () => { + const types: ToolContextType[] = service.getToolContextTypes(); + + expect(types).toEqual([ToolContextType.COURSE, ToolContextType.BOARD_ELEMENT]); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts index 92938e2c98a..da852d195c0 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-configuration.service.ts @@ -2,16 +2,20 @@ import { Inject, Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; import { CustomParameter } from '../../common/domain'; -import { CustomParameterScope } from '../../common/enum'; +import { CustomParameterScope, ToolContextType } from '../../common/enum'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ContextExternalToolTemplateInfo } from '../uc/dto'; +import { CommonToolService } from '../../common/service'; @Injectable() export class ExternalToolConfigurationService { - constructor(@Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures) {} + constructor( + @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, + private readonly commonToolService: CommonToolService + ) {} public filterForAvailableTools(externalTools: Page, toolIdsInUse: EntityId[]): ExternalTool[] { const visibleTools: ExternalTool[] = externalTools.data.filter((tool: ExternalTool): boolean => !tool.isHidden); @@ -75,6 +79,16 @@ export class ExternalToolConfigurationService { return availableTools; } + public filterForContextRestrictions( + availableTools: ContextExternalToolTemplateInfo[], + contextType: ToolContextType + ): ContextExternalToolTemplateInfo[] { + const availableToolsForContext: ContextExternalToolTemplateInfo[] = availableTools.filter( + (availableTool) => !this.commonToolService.isContextRestricted(availableTool.externalTool, contextType) + ); + return availableToolsForContext; + } + public filterParametersForScope(externalTool: ExternalTool, scope: CustomParameterScope) { if (externalTool.parameters) { externalTool.parameters = externalTool.parameters.filter( @@ -82,4 +96,10 @@ export class ExternalToolConfigurationService { ); } } + + public getToolContextTypes(): ToolContextType[] { + const toolContextTypes: ToolContextType[] = Object.values(ToolContextType); + + return toolContextTypes; + } } diff --git a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts index 1c086cb9c96..707546ba55e 100644 --- a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts +++ b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts @@ -1,5 +1,6 @@ import { BasicToolConfig, Lti11ToolConfig, Oauth2ToolConfig } from '../../domain'; import { CustomParameter } from '../../../common/domain'; +import { ToolContextType } from '../../../common/enum'; type PartialBy = Omit & Partial>; @@ -33,6 +34,8 @@ export type ExternalToolDto = { openNewTab: boolean; version: number; + + restrictToContexts?: ToolContextType[]; }; export type ExternalToolCreate = ExternalToolDto; 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 9551bde3572..933ff82c9a2 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 @@ -1,9 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationContextBuilder } from '@modules/authorization'; import { ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; - import { Page } from '@shared/domain/domainobject'; import { Permission } from '@shared/domain/interface'; import { @@ -12,7 +10,10 @@ import { externalToolFactory, schoolExternalToolFactory, setupEntities, + userFactory, } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { User } from '@shared/domain/entity'; import { CustomParameterScope, ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; @@ -33,6 +34,7 @@ describe('ExternalToolConfigurationUc', () => { let contextExternalToolService: DeepMocked; let toolPermissionHelper: DeepMocked; let logoService: DeepMocked; + let authorizationService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -64,6 +66,10 @@ describe('ExternalToolConfigurationUc', () => { provide: ExternalToolLogoService, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, ], }).compile(); @@ -74,6 +80,7 @@ describe('ExternalToolConfigurationUc', () => { contextExternalToolService = module.get(ContextExternalToolService); toolPermissionHelper = module.get(ToolPermissionHelper); logoService = module.get(ExternalToolLogoService); + authorizationService = module.get(AuthorizationService); }); afterEach(() => { @@ -270,6 +277,9 @@ describe('ExternalToolConfigurationUc', () => { externalToolConfigurationService.filterForAvailableExternalTools.mockReturnValue([ { externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }, ]); + externalToolConfigurationService.filterForContextRestrictions.mockReturnValue([ + { externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }, + ]); return { toolIds, @@ -327,6 +337,17 @@ describe('ExternalToolConfigurationUc', () => { ); }); + it('should filter for restricted contexts', async () => { + const { usedTool, usedSchoolExternalTool } = setup(); + + await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); + + expect(externalToolConfigurationService.filterForContextRestrictions).toHaveBeenCalledWith( + [{ externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }], + ToolContextType.COURSE + ); + }); + it('should call filterParametersForScope', async () => { const { usedTool } = setup(); @@ -359,6 +380,7 @@ describe('ExternalToolConfigurationUc', () => { unusedSchoolExternalTool, ]); externalToolConfigurationService.filterForAvailableExternalTools.mockReturnValue([]); + externalToolConfigurationService.filterForContextRestrictions.mockReturnValue([]); return {}; }; @@ -400,6 +422,9 @@ describe('ExternalToolConfigurationUc', () => { externalToolConfigurationService.filterForAvailableExternalTools.mockReturnValue([ { externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }, ]); + externalToolConfigurationService.filterForContextRestrictions.mockReturnValue([ + { externalTool: usedTool, schoolExternalTool: usedSchoolExternalTool }, + ]); return { usedTool, @@ -652,4 +677,76 @@ describe('ExternalToolConfigurationUc', () => { }); }); }); + + describe('getToolContextTypes', () => { + describe('when it is called', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: User = userFactory.build(); + user.id = userId; + const contextTypes: ToolContextType[] = Object.values(ToolContextType); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockReturnValueOnce(); + externalToolConfigurationService.getToolContextTypes.mockReturnValueOnce(contextTypes); + + return { userId, user, contextTypes }; + }; + + it('should get User', async () => { + const { userId } = setup(); + + await uc.getToolContextTypes(userId); + + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(userId); + }); + + it('should check Permission', async () => { + const { userId, user } = setup(); + + await uc.getToolContextTypes(userId); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, ['TOOL_ADMIN']); + }); + + it('should get context types', async () => { + const { userId } = setup(); + + await uc.getToolContextTypes(userId); + + expect(externalToolConfigurationService.getToolContextTypes).toHaveBeenCalled(); + }); + + it('should return all context types', async () => { + const { userId } = setup(); + + const result = await uc.getToolContextTypes(userId); + + expect(result).toEqual([ToolContextType.COURSE, ToolContextType.BOARD_ELEMENT]); + }); + }); + + describe('when user does not have enough Permission', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: User = userFactory.build(); + user.id = userId; + const contextTypes: ToolContextType[] = Object.values(ToolContextType); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw new UnauthorizedException(); + }); + externalToolConfigurationService.getToolContextTypes.mockReturnValueOnce(contextTypes); + + return { userId }; + }; + + it('should throw unauthorized', async () => { + const { userId } = setup(); + + await expect(uc.getToolContextTypes(userId)).rejects.toThrow(new UnauthorizedException()); + }); + }); + }); }); 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 e07db3213ff..90d9f8f718c 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 @@ -1,9 +1,10 @@ -import { AuthorizationContext, AuthorizationContextBuilder } from '@modules/authorization'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Inject, Injectable, forwardRef } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; import { Page } from '@shared/domain/domainobject/page'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { User } from '@shared/domain/entity'; import { CustomParameterScope, ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; @@ -23,9 +24,19 @@ export class ExternalToolConfigurationUc { @Inject(forwardRef(() => ToolPermissionHelper)) private readonly toolPermissionHelper: ToolPermissionHelper, private readonly externalToolConfigurationService: ExternalToolConfigurationService, - private readonly externalToolLogoService: ExternalToolLogoService + private readonly externalToolLogoService: ExternalToolLogoService, + private readonly authorizationService: AuthorizationService ) {} + public async getToolContextTypes(userId: EntityId): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkAllPermissions(user, [Permission.TOOL_ADMIN]); + + const toolContextTypes: ToolContextType[] = this.externalToolConfigurationService.getToolContextTypes(); + + return toolContextTypes; + } + public async getAvailableToolsForSchool(userId: EntityId, schoolId: EntityId): Promise { const externalTools: Page = await this.externalToolService.findExternalTools({}); @@ -91,12 +102,17 @@ export class ExternalToolConfigurationUc { contextExternalToolsInUse ); - const availableToolsForContext: ContextExternalToolTemplateInfo[] = + let availableToolsForContext: ContextExternalToolTemplateInfo[] = this.externalToolConfigurationService.filterForAvailableExternalTools( externalTools.data, availableSchoolExternalTools ); + availableToolsForContext = this.externalToolConfigurationService.filterForContextRestrictions( + availableToolsForContext, + contextType + ); + availableToolsForContext.forEach((toolTemplateInfo) => { this.externalToolConfigurationService.filterParametersForScope( toolTemplateInfo.externalTool, diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index 2ee9321f1cf..aa35059b564 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -14,9 +14,10 @@ import { externalToolEntityFactory, schoolExternalToolEntityFactory, schoolFactory, + customParameterFactory, } from '@shared/testing'; import { Response } from 'supertest'; -import { ToolConfigType } from '../../../common/enum'; +import { CustomParameterLocation, CustomParameterScope, ToolConfigType } from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; import { ExternalToolEntity } from '../../../external-tool/entity'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; @@ -65,6 +66,18 @@ describe('ToolLaunchController (API)', () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), version: 0, + parameters: [ + customParameterFactory.build({ + name: 'schoolMockParameter', + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.PATH, + }), + customParameterFactory.build({ + name: 'contextMockParameter', + scope: CustomParameterScope.CONTEXT, + location: CustomParameterLocation.PATH, + }), + ], }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts index ab35c7b5fda..7e072b9c66a 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts @@ -42,6 +42,7 @@ export class ExternalToolRepoMapper { isHidden: entity.isHidden, openNewTab: entity.openNewTab, version: entity.version, + restrictToContexts: entity.restrictToContexts, }); } @@ -101,6 +102,7 @@ export class ExternalToolRepoMapper { isHidden: entityDO.isHidden, openNewTab: entityDO.openNewTab, version: entityDO.version, + restrictToContexts: entityDO.restrictToContexts, }; } diff --git a/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts index 3e7f4e4b1cb..2d421ab99c8 100644 --- a/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts @@ -17,7 +17,7 @@ export const contextExternalToolEntityFactory = BaseFactory.define< contextType: ContextExternalToolType.COURSE, displayName: 'My Course Tool 1', schoolTool: schoolExternalToolEntityFactory.buildWithId(), - parameters: [new CustomParameterEntryEntity({ name: 'mockParamater', value: 'mockValue' })], + parameters: [new CustomParameterEntryEntity({ name: 'contextMockParameter', value: 'mockValue' })], toolVersion: 1, }; }); diff --git a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts index 023e3a2626d..ff8e62fa4bc 100644 --- a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts @@ -10,7 +10,7 @@ export const schoolExternalToolEntityFactory = BaseFactory.define< return { tool: externalToolEntityFactory.buildWithId(), school: schoolFactory.buildWithId(), - schoolParameters: [{ name: 'mockParamater', value: 'mockValue' }], + schoolParameters: [{ name: 'schoolMockParameter', value: 'mockValue' }], toolVersion: 0, }; }); diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 2a3fd937a37..ffec19620bd 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -99,5 +99,27 @@ "isHidden": false, "openNewTab": false, "version": 2 + }, + { + "_id": { + "$oid": "644a4593d0a8301e6cf25d86" + }, + "createdAt": { + "$date": "2023-04-27T09:51:15.592Z" + }, + "updatedAt": { + "$date": "2023-04-27T09:51:15.592Z" + }, + "name": "CY Test Tool Board-Element Restriction", + "url": "https://google.de/", + "config_type": "basic", + "config_baseUrl": "https://google.de/", + "parameters": [], + "isHidden": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [ + "board-element" + ] } ] diff --git a/backup/setup/school-external-tools.json b/backup/setup/school-external-tools.json index 59befc464af..b3d4e4b0197 100644 --- a/backup/setup/school-external-tools.json +++ b/backup/setup/school-external-tools.json @@ -68,5 +68,28 @@ }, "schoolParameters": [], "toolVersion": 2 + }, + { + "_id": { + "$oid": "65685717ed7c14d921698602" + }, + "createdAt": { + "$date": { + "$numberLong": "1685971828284" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1685971828284" + } + }, + "tool": { + "$oid": "644a4593d0a8301e6cf25d86" + }, + "school": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "schoolParameters": [], + "toolVersion": 1 } ]