diff --git a/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts index 402520d432d..4f875faefe0 100644 --- a/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts +++ b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalTool, ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { ExternalTool } from '../../../external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { CustomParameter } from '../../domain'; @@ -12,7 +12,7 @@ import { ParameterArrayValidator, } from './rules'; -export type ValidatableTool = SchoolExternalTool | ContextExternalTool; +export type ValidatableTool = SchoolExternalTool | ContextExternalTool | ContextExternalToolLaunchable; @Injectable() export class CommonToolValidationService { diff --git a/apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts b/apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts index 5bdd41c2762..94d98c174e5 100644 --- a/apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts +++ b/apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts @@ -1,10 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { + AuthorizableReferenceType, AuthorizationContext, AuthorizationContextBuilder, AuthorizationService, ForbiddenLoggableException, - AuthorizableReferenceType, } from '@modules/authorization'; import { BoardDoAuthorizableService, ContentElementService } from '@modules/board'; import { CourseService } from '@modules/learnroom'; @@ -14,12 +15,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardDoAuthorizable, ExternalToolElement } from '@shared/domain/domainobject'; import { Permission } from '@shared/domain/interface'; import { + boardDoAuthorizableFactory, contextExternalToolFactory, courseFactory, externalToolElementFactory, + schoolExternalToolFactory, setupEntities, userFactory, - boardDoAuthorizableFactory, } from '@shared/testing'; import { ContextExternalTool, ContextRef } from '../../context-external-tool/domain'; import { ToolContextType } from '../enum'; @@ -233,4 +235,193 @@ describe('ToolPermissionHelper', () => { }); }); }); + + describe('ensureContextPermissionsForSchool', () => { + describe('when a school external tool for context "course" is given', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextRef = new ContextRef({ + id: course.id, + type: ToolContextType.COURSE, + }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + courseService.findById.mockResolvedValueOnce(course); + + return { + user, + course, + schoolExternalTool, + contextRef, + context, + }; + }; + + it('should check permission for school external tool', async () => { + const { user, course, schoolExternalTool, context, contextRef } = setup(); + + await helper.ensureContextPermissionsForSchool( + user, + schoolExternalTool, + contextRef.id, + contextRef.type, + context + ); + + expect(authorizationService.checkPermission).toHaveBeenCalledTimes(2); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(1, user, schoolExternalTool, context); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(2, user, course, context); + }); + }); + + describe('when a school external tool for context "board element" is given', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const externalToolElement: ExternalToolElement = externalToolElementFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextRef = new ContextRef({ + id: externalToolElement.id, + type: ToolContextType.BOARD_ELEMENT, + }); + const board: BoardDoAuthorizable = boardDoAuthorizableFactory.build(); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + contentElementService.findById.mockResolvedValueOnce(externalToolElement); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(board); + + return { + user, + board, + schoolExternalTool, + contextRef, + context, + }; + }; + + it('should check permission for school external tool', async () => { + const { user, board, schoolExternalTool, contextRef, context } = setup(); + + await helper.ensureContextPermissionsForSchool( + user, + schoolExternalTool, + contextRef.id, + contextRef.type, + context + ); + + expect(authorizationService.checkPermission).toHaveBeenCalledTimes(2); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(1, user, schoolExternalTool, context); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(2, user, board, context); + }); + }); + + describe('when a school external tool for context "media board" is given', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const board: BoardDoAuthorizable = boardDoAuthorizableFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextRef = new ContextRef({ + id: board.id, + type: ToolContextType.MEDIA_BOARD, + }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + boardDoAuthorizableService.findById.mockResolvedValueOnce(board); + + return { + user, + board, + schoolExternalTool, + contextRef, + context, + }; + }; + + it('should check permission for school external tool', async () => { + const { user, board, schoolExternalTool, contextRef, context } = setup(); + + await helper.ensureContextPermissionsForSchool( + user, + schoolExternalTool, + contextRef.id, + contextRef.type, + context + ); + + expect(authorizationService.checkPermission).toHaveBeenCalledTimes(2); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(1, user, schoolExternalTool, context); + expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(2, user, board, context); + }); + }); + + describe('when the school external tool has an unknown context', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextRef = new ContextRef({ + id: new ObjectId().toHexString(), + type: 'unknown type' as unknown as ToolContextType, + }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + schoolExternalTool, + contextRef, + context, + }; + }; + + it('should throw a forbidden loggable exception', async () => { + const { user, schoolExternalTool, contextRef, context } = setup(); + + await expect( + helper.ensureContextPermissionsForSchool(user, schoolExternalTool, contextRef.id, contextRef.type, context) + ).rejects.toThrowError( + new ForbiddenLoggableException(user.id, AuthorizableReferenceType.ContextExternalToolEntity, context) + ); + }); + }); + + describe('when user is unauthorized', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextRef = new ContextRef({ + id: new ObjectId().toHexString(), + type: ToolContextType.COURSE, + }); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + const error = new ForbiddenException(); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw error; + }); + + return { + user, + schoolExternalTool, + contextRef, + context, + error, + }; + }; + + it('should check permission for school external tool and fail', async () => { + const { user, schoolExternalTool, contextRef, context, error } = setup(); + + await expect( + helper.ensureContextPermissionsForSchool(user, schoolExternalTool, contextRef.id, contextRef.type, context) + ).rejects.toThrowError(error); + }); + }); + }); }); 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 75bf050b676..4e176e1ce85 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 @@ -5,22 +5,32 @@ import { CourseService } from '@modules/learnroom'; import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { BoardDoAuthorizable } from '@shared/domain/domainobject'; import { Course, User } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; import { ContextExternalTool } from '../../context-external-tool/domain'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ToolContextType } from '../enum'; @Injectable() export class ToolPermissionHelper { constructor( @Inject(forwardRef(() => AuthorizationService)) private readonly authorizationService: AuthorizationService, - // 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, private readonly boardElementService: ContentElementService, private readonly boardService: BoardDoAuthorizableService ) {} - // TODO build interface to get contextDO by contextType + public async ensureContextPermissionsForSchool( + user: User, + schoolExternalTool: SchoolExternalTool, + contextId: EntityId, + contextType: ToolContextType, + context: AuthorizationContext + ): Promise { + this.authorizationService.checkPermission(user, schoolExternalTool, context); + + await this.checkPermissionsByContextRef(user, contextId, contextType, context); + } + public async ensureContextPermissions( user: User, contextExternalTool: ContextExternalTool, @@ -28,20 +38,37 @@ export class ToolPermissionHelper { ): Promise { this.authorizationService.checkPermission(user, contextExternalTool, context); - switch (contextExternalTool.contextRef.type) { + await this.checkPermissionsByContextRef( + user, + contextExternalTool.contextRef.id, + contextExternalTool.contextRef.type, + context + ); + } + + private async checkPermissionsByContextRef( + user: User, + contextId: EntityId, + contextType: ToolContextType, + context: AuthorizationContext + ): Promise { + switch (contextType) { case ToolContextType.COURSE: { - const course: Course = await this.courseService.findById(contextExternalTool.contextRef.id); + const course: Course = await this.courseService.findById(contextId); + this.authorizationService.checkPermission(user, course, context); break; } case ToolContextType.BOARD_ELEMENT: { - const boardElement = await this.boardElementService.findById(contextExternalTool.contextRef.id); + const boardElement = await this.boardElementService.findById(contextId); const board: BoardDoAuthorizable = await this.boardService.getBoardAuthorizable(boardElement); + this.authorizationService.checkPermission(user, board, context); break; } case ToolContextType.MEDIA_BOARD: { - const board: BoardDoAuthorizable = await this.boardService.findById(contextExternalTool.contextRef.id); + const board: BoardDoAuthorizable = await this.boardService.findById(contextId); + this.authorizationService.checkPermission(user, board, context); break; } diff --git a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts index 1ce23a5a4ed..d4bb8876c94 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts @@ -4,16 +4,18 @@ import { ToolVersion } from '../../common/interface'; import { SchoolExternalToolRefDO } from '../../school-external-tool/domain'; import { ContextRef } from './context-ref'; -export interface ContextExternalToolProps { +export interface ContextExternalToolLaunchable { id?: string; schoolToolRef: SchoolExternalToolRefDO; contextRef: ContextRef; - displayName?: string; - parameters: CustomParameterEntry[]; +} + +export interface ContextExternalToolProps extends ContextExternalToolLaunchable { + displayName?: string; toolVersion: number; } 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 127958c631e..40d8398a25b 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 @@ -9,6 +9,7 @@ import { SchoolExternalTool } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool/service'; import { ContextExternalTool, + ContextExternalToolLaunchable, ContextExternalToolWithId, ContextRef, RestrictedContextMismatchLoggableException, @@ -71,7 +72,7 @@ export class ContextExternalToolService { return contextExternalTools; } - public async checkContextRestrictions(contextExternalTool: ContextExternalTool): Promise { + public async checkContextRestrictions(contextExternalTool: ContextExternalToolLaunchable): Promise { const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId ); diff --git a/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts b/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts index 56c02af20af..6bced502c86 100644 --- a/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts +++ b/apps/server/src/modules/tool/context-external-tool/service/tool-configuration-status.service.ts @@ -8,7 +8,7 @@ import { import { CommonToolValidationService } from '../../common/service'; import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { ContextExternalTool } from '../domain'; +import { ContextExternalToolLaunchable } from '../domain'; @Injectable() export class ToolConfigurationStatusService { @@ -17,7 +17,7 @@ export class ToolConfigurationStatusService { public determineToolConfigurationStatus( externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): ContextExternalToolConfigurationStatus { const configurationStatus: ContextExternalToolConfigurationStatus = new ContextExternalToolConfigurationStatus({ isOutdatedOnScopeContext: false, 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 d6b868b8d01..38b62a4817d 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 @@ -16,8 +16,8 @@ import { EntityId } from '@shared/domain/types'; import { contextExternalToolFactory, schoolExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; import { ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; -import { SchoolExternalToolWithId } from '../../school-external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool'; +import { SchoolExternalToolWithId } from '../../school-external-tool/domain'; import { ContextExternalTool, ContextExternalToolWithId } from '../domain'; import { ContextExternalToolService } from '../service'; import { ContextExternalToolValidationService } from '../service/context-external-tool-validation.service'; 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 b04a56d2abf..37934f6fdaa 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 @@ -1,21 +1,30 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Course, SchoolEntity } from '@shared/domain/entity'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { Course, MediaBoardNode, SchoolEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { basicToolConfigFactory, contextExternalToolFactory, courseFactory, customParameterFactory, + mediaBoardNodeFactory, schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; import { Response } from 'supertest'; -import { CustomParameterLocation, CustomParameterScope, ToolConfigType } from '../../../common/enum'; +import { + CustomParameterLocation, + CustomParameterScope, + CustomParameterType, + ToolConfigType, + ToolContextType, +} from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; import { contextExternalToolEntityFactory } from '../../../context-external-tool/testing'; import { ExternalToolEntity } from '../../../external-tool/entity'; @@ -23,7 +32,7 @@ import { externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { LaunchRequestMethod } from '../../types'; -import { ToolLaunchParams, ToolLaunchRequestResponse } from '../dto'; +import { ContextExternalToolBodyParams, ContextExternalToolLaunchParams, ToolLaunchRequestResponse } from '../dto'; describe('ToolLaunchController (API)', () => { let app: INestApplication; @@ -31,7 +40,7 @@ describe('ToolLaunchController (API)', () => { let orm: MikroORM; let testApiClient: TestApiClient; - const BASE_URL = '/tools/context'; + const BASE_URL = '/tools'; beforeAll(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ @@ -90,7 +99,7 @@ describe('ToolLaunchController (API)', () => { contextType: ContextExternalToolType.COURSE, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + const params: ContextExternalToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; await em.persistAndFlush([ school, @@ -111,7 +120,7 @@ describe('ToolLaunchController (API)', () => { it('should return a launch response', async () => { const { params, loggedInClient } = await setup(); - const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + const response: Response = await loggedInClient.get(`/context/${params.contextExternalToolId}/launch`); expect(response.statusCode).toEqual(HttpStatus.OK); @@ -148,7 +157,7 @@ describe('ToolLaunchController (API)', () => { toolVersion: 0, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + const params: ContextExternalToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; await em.persistAndFlush([ school, @@ -166,12 +175,12 @@ describe('ToolLaunchController (API)', () => { return { params, loggedInClient }; }; - it('should return a bad request', async () => { + it('should return a unprocessable entity', async () => { const { params, loggedInClient } = await setup(); - const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + const response: Response = await loggedInClient.get(`/context/${params.contextExternalToolId}/launch`); - expect(response.status).toBe(HttpStatus.BAD_REQUEST); + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); }); }); @@ -201,7 +210,7 @@ describe('ToolLaunchController (API)', () => { toolVersion: 0, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + const params: ContextExternalToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; await em.persistAndFlush([ school, @@ -219,12 +228,12 @@ describe('ToolLaunchController (API)', () => { return { params, loggedInClient }; }; - it('should return a bad request', async () => { + it('should return a unprocessable entity', async () => { const { params, loggedInClient } = await setup(); - const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + const response: Response = await loggedInClient.get(`/context/${params.contextExternalToolId}/launch`); - expect(response.status).toBe(HttpStatus.BAD_REQUEST); + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); }); }); @@ -255,7 +264,7 @@ describe('ToolLaunchController (API)', () => { toolVersion: 0, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + const params: ContextExternalToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; await em.persistAndFlush([ school, @@ -273,12 +282,12 @@ describe('ToolLaunchController (API)', () => { return { params, loggedInClient }; }; - it('should return a bad request', async () => { + it('should return a unprocessable entity', async () => { const { params, loggedInClient } = await setup(); - const response: Response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + const response: Response = await loggedInClient.get(`/context/${params.contextExternalToolId}/launch`); - expect(response.status).toBe(HttpStatus.BAD_REQUEST); + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); }); }); }); @@ -306,7 +315,7 @@ describe('ToolLaunchController (API)', () => { contextType: ContextExternalToolType.COURSE, }); - const params: ToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; + const params: ContextExternalToolLaunchParams = { contextExternalToolId: contextExternalToolEntity.id }; await em.persistAndFlush([ toolSchool, @@ -328,7 +337,7 @@ describe('ToolLaunchController (API)', () => { it('should return forbidden', async () => { const { params, loggedInClient } = await setup(); - const response = await loggedInClient.get(`${params.contextExternalToolId}/launch`); + const response = await loggedInClient.get(`/context/${params.contextExternalToolId}/launch`); expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); }); @@ -337,7 +346,7 @@ describe('ToolLaunchController (API)', () => { describe('when user is not authenticated', () => { const setup = () => { const contextExternalTool = contextExternalToolFactory.buildWithId(); - const params: ToolLaunchParams = { + const params: ContextExternalToolLaunchParams = { contextExternalToolId: contextExternalTool.id as string, }; @@ -347,7 +356,302 @@ describe('ToolLaunchController (API)', () => { it('should return unauthorized', async () => { const { params } = setup(); - const response = await testApiClient.get(`${params.contextExternalToolId}/launch`); + const response = await testApiClient.get(`/context/${params.contextExternalToolId}/launch`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); + + describe('[GET] tools/school/{schoolExternalToolId}/launch', () => { + describe('when valid data is given', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const mediaBoard: MediaBoardNode = mediaBoardNodeFactory.buildWithId({ + context: { id: teacherUser.id, type: BoardExternalReferenceType.User }, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + parameters: [ + customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.PATH, + type: CustomParameterType.STRING, + default: 'test', + }), + ], + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + }); + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + mediaBoard, + ]); + em.clear(); + + const requestBody: ContextExternalToolBodyParams = { + contextType: ToolContextType.MEDIA_BOARD, + contextId: mediaBoard.id, + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + schoolExternalToolEntity, + requestBody, + }; + }; + + it('should return a launch response', async () => { + const { loggedInClient, schoolExternalToolEntity, requestBody } = await setup(); + + const response: Response = await loggedInClient.post( + `/school/${schoolExternalToolEntity.id}/launch`, + requestBody + ); + + expect(response.statusCode).toEqual(HttpStatus.OK); + + const responseBody: ToolLaunchRequestResponse = response.body as ToolLaunchRequestResponse; + expect(responseBody).toEqual({ + method: LaunchRequestMethod.GET, + url: 'https://mockurl.de/', + openNewTab: true, + }); + }); + }); + + describe('when user wants to launch an outdated tool', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const mediaBoard: MediaBoardNode = mediaBoardNodeFactory.buildWithId({ + context: { id: teacherUser.id, type: BoardExternalReferenceType.User }, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + parameters: [ + customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.PATH, + type: CustomParameterType.STRING, + default: 'test', + }), + customParameterFactory.build({ + scope: CustomParameterScope.SCHOOL, + location: CustomParameterLocation.PATH, + type: CustomParameterType.STRING, + }), + ], + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + }); + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + mediaBoard, + ]); + em.clear(); + + const requestBody: ContextExternalToolBodyParams = { + contextType: ToolContextType.MEDIA_BOARD, + contextId: mediaBoard.id, + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + schoolExternalToolEntity, + requestBody, + }; + }; + + it('should return a unprocessable entity', async () => { + const { loggedInClient, requestBody, schoolExternalToolEntity } = await setup(); + + const response: Response = await loggedInClient.post( + `/school/${schoolExternalToolEntity.id}/launch`, + requestBody + ); + + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + + describe('when user wants to launch a deactivated tool', () => { + describe('when external tool is deactivated', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const mediaBoard: MediaBoardNode = mediaBoardNodeFactory.buildWithId({ + context: { id: teacherUser.id, type: BoardExternalReferenceType.User }, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + parameters: [ + customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.PATH, + type: CustomParameterType.STRING, + default: 'test', + }), + ], + isDeactivated: true, + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school, + schoolParameters: [], + }); + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + mediaBoard, + ]); + em.clear(); + + const requestBody: ContextExternalToolBodyParams = { + contextType: ToolContextType.MEDIA_BOARD, + contextId: mediaBoard.id, + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + schoolExternalToolEntity, + requestBody, + }; + }; + + it('should return a unprocessable entity', async () => { + const { loggedInClient, requestBody, schoolExternalToolEntity } = await setup(); + + const response: Response = await loggedInClient.post( + `/school/${schoolExternalToolEntity.id}/launch`, + requestBody + ); + + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + }); + + describe('when user wants to launch tool from another school', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); + const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ + Permission.CONTEXT_TOOL_USER, + ]); + const mediaBoard: MediaBoardNode = mediaBoardNodeFactory.buildWithId({ + context: { id: teacherUser.id, type: BoardExternalReferenceType.User }, + }); + + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId({ + config: basicToolConfigFactory.build({ baseUrl: 'https://mockurl.de', type: ToolConfigType.BASIC }), + parameters: [ + customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.PATH, + type: CustomParameterType.STRING, + default: 'test', + }), + ], + }); + const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ + tool: externalToolEntity, + school: otherSchool, + schoolParameters: [], + }); + + await em.persistAndFlush([ + school, + teacherUser, + teacherAccount, + externalToolEntity, + schoolExternalToolEntity, + otherSchool, + mediaBoard, + ]); + em.clear(); + + const requestBody: ContextExternalToolBodyParams = { + contextType: ToolContextType.MEDIA_BOARD, + contextId: mediaBoard.id, + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + schoolExternalToolEntity, + requestBody, + }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, requestBody, schoolExternalToolEntity } = await setup(); + + const response: Response = await loggedInClient.post( + `/school/${schoolExternalToolEntity.id}/launch`, + requestBody + ); + + expect(response.statusCode).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user is not authenticated', () => { + const setup = () => { + const requestBody: ContextExternalToolBodyParams = { + contextType: ToolContextType.MEDIA_BOARD, + contextId: new ObjectId().toHexString(), + }; + + return { + requestBody, + }; + }; + + it('should return unauthorized', async () => { + const { requestBody } = setup(); + + const response: Response = await testApiClient.post( + `/school/${new ObjectId().toHexString()}/launch`, + requestBody + ); expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); }); diff --git a/apps/server/src/modules/tool/tool-launch/controller/dto/context-external-tool-body.params.ts b/apps/server/src/modules/tool/tool-launch/controller/dto/context-external-tool-body.params.ts new file mode 100644 index 00000000000..8c5ec84cf54 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/controller/dto/context-external-tool-body.params.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsMongoId } from 'class-validator'; +import { ToolContextType } from '../../../common/enum'; + +export class ContextExternalToolBodyParams { + @ApiProperty({ example: '0000dcfbfb5c7a3f00bf21ab' }) + @IsMongoId() + contextId!: string; + + @IsEnum(ToolContextType) + @ApiProperty({ + enum: ToolContextType, + enumName: 'ToolContextType', + example: ToolContextType.MEDIA_BOARD, + }) + contextType!: ToolContextType; +} diff --git a/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch.params.ts b/apps/server/src/modules/tool/tool-launch/controller/dto/context-external-tool-launch.params.ts similarity index 83% rename from apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch.params.ts rename to apps/server/src/modules/tool/tool-launch/controller/dto/context-external-tool-launch.params.ts index d9de8797a49..0ae60a51332 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch.params.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/dto/context-external-tool-launch.params.ts @@ -1,7 +1,7 @@ -import { IsMongoId } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; -export class ToolLaunchParams { +export class ContextExternalToolLaunchParams { @IsMongoId() @ApiProperty({ description: 'The id of the context external tool', nullable: false, required: true }) contextExternalToolId!: string; diff --git a/apps/server/src/modules/tool/tool-launch/controller/dto/index.ts b/apps/server/src/modules/tool/tool-launch/controller/dto/index.ts index 8a5ee7f30a5..e4679f84aee 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/dto/index.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/dto/index.ts @@ -1,2 +1,4 @@ -export * from './tool-launch.params'; -export * from './tool-launch-request.response'; +export { ContextExternalToolLaunchParams } from './context-external-tool-launch.params'; +export { SchoolExternalToolLaunchParams } from './school-external-tool-launch.params'; +export { ToolLaunchRequestResponse } from './tool-launch-request.response'; +export { ContextExternalToolBodyParams } from './context-external-tool-body.params'; diff --git a/apps/server/src/modules/tool/tool-launch/controller/dto/school-external-tool-launch.params.ts b/apps/server/src/modules/tool/tool-launch/controller/dto/school-external-tool-launch.params.ts new file mode 100644 index 00000000000..6ed10b2d004 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/controller/dto/school-external-tool-launch.params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class SchoolExternalToolLaunchParams { + @IsMongoId() + @ApiProperty({ description: 'The id of the school external tool' }) + schoolExternalToolId!: string; +} diff --git a/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts b/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts index 3ac323b1000..437ef9b5664 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/tool-launch.controller.ts @@ -1,17 +1,22 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Controller, Get, Param } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { - ApiBadRequestResponse, ApiForbiddenResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse, + ApiUnprocessableEntityResponse, } from '@nestjs/swagger'; import { ToolLaunchMapper } from '../mapper'; import { ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from '../uc'; -import { ToolLaunchParams, ToolLaunchRequestResponse } from './dto'; +import { + ContextExternalToolBodyParams, + ContextExternalToolLaunchParams, + SchoolExternalToolLaunchParams, + ToolLaunchRequestResponse, +} from './dto'; @ApiTags('Tool') @Authenticate('jwt') @@ -24,12 +29,12 @@ export class ToolLaunchController { @ApiOkResponse({ description: 'Tool launch request', type: ToolLaunchRequestResponse }) @ApiUnauthorizedResponse({ description: 'Unauthorized' }) @ApiForbiddenResponse({ description: 'Forbidden' }) - @ApiBadRequestResponse({ description: 'Outdated tools cannot be launched' }) - async getToolLaunchRequest( + @ApiUnprocessableEntityResponse({ description: 'Outdated tools cannot be launched' }) + async getContextExternalToolLaunchRequest( @CurrentUser() currentUser: ICurrentUser, - @Param() params: ToolLaunchParams + @Param() params: ContextExternalToolLaunchParams ): Promise { - const toolLaunchRequest: ToolLaunchRequest = await this.toolLaunchUc.getToolLaunchRequest( + const toolLaunchRequest: ToolLaunchRequest = await this.toolLaunchUc.getContextExternalToolLaunchRequest( currentUser.userId, params.contextExternalToolId ); @@ -37,4 +42,34 @@ export class ToolLaunchController { const response: ToolLaunchRequestResponse = ToolLaunchMapper.mapToToolLaunchRequestResponse(toolLaunchRequest); return response; } + + @Post('school/:schoolExternalToolId/launch') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get tool launch request for a school external tool' }) + @ApiOkResponse({ description: 'Tool launch request', type: ToolLaunchRequestResponse }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiForbiddenResponse({ description: 'Forbidden' }) + @ApiUnprocessableEntityResponse({ description: 'Outdated tools cannot be launched' }) + async getSchoolExternalToolLaunchRequest( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: SchoolExternalToolLaunchParams, + @Body() body: ContextExternalToolBodyParams + ): Promise { + const toolLaunchRequest: ToolLaunchRequest = await this.toolLaunchUc.getSchoolExternalToolLaunchRequest( + currentUser.userId, + { + schoolToolRef: { + schoolToolId: params.schoolExternalToolId, + }, + contextRef: { + type: body.contextType, + id: body.contextId, + }, + parameters: [], + } + ); + + const response: ToolLaunchRequestResponse = ToolLaunchMapper.mapToToolLaunchRequestResponse(toolLaunchRequest); + return response; + } } diff --git a/apps/server/src/modules/tool/tool-launch/error/index.ts b/apps/server/src/modules/tool/tool-launch/error/index.ts index f657499dd46..f57f583e432 100644 --- a/apps/server/src/modules/tool/tool-launch/error/index.ts +++ b/apps/server/src/modules/tool/tool-launch/error/index.ts @@ -2,3 +2,4 @@ export * from './tool-status-not-launchable.loggable-exception'; export * from './missing-tool-parameter-value.loggable-exception'; export * from './parameter-type-not-implemented.loggable-exception'; export { MissingMediaLicenseLoggableException } from './missing-licence.loggable-exception'; +export { LaunchContextUnavailableLoggableException } from './launch-context-unavailable.loggable-exception'; diff --git a/apps/server/src/modules/tool/tool-launch/error/launch-context-unavailable.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/launch-context-unavailable.loggable-exception.spec.ts new file mode 100644 index 00000000000..a721baba68f --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/error/launch-context-unavailable.loggable-exception.spec.ts @@ -0,0 +1,48 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { ToolContextType } from '../../common/enum'; +import { ContextExternalToolLaunchable } from '../../context-external-tool/domain'; +import { LaunchContextUnavailableLoggableException } from './launch-context-unavailable.loggable-exception'; + +describe(LaunchContextUnavailableLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalToolLaunchable = { + schoolToolRef: { + schoolToolId: new ObjectId().toHexString(), + }, + contextRef: { + type: ToolContextType.COURSE, + id: new ObjectId().toHexString(), + }, + parameters: [], + }; + + const exception = new LaunchContextUnavailableLoggableException(contextExternalTool, userId); + + return { + contextExternalTool, + userId, + exception, + }; + }; + + it('should log the correct message', () => { + const { contextExternalTool, userId, exception } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'LAUNCH_CONTEXT_UNAVAILABLE', + message: 'The context type cannot launch school external tools', + stack: expect.any(String), + data: { + userId, + schoolExternalToolId: contextExternalTool.schoolToolRef.schoolToolId, + contextType: contextExternalTool.contextRef.type, + contextId: contextExternalTool.contextRef.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/tool-launch/error/launch-context-unavailable.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/launch-context-unavailable.loggable-exception.ts new file mode 100644 index 00000000000..f74ba30b677 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/error/launch-context-unavailable.loggable-exception.ts @@ -0,0 +1,32 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ContextExternalToolLaunchable } from '../../context-external-tool/domain'; + +export class LaunchContextUnavailableLoggableException extends BusinessError implements Loggable { + constructor(private readonly contextExternalTool: ContextExternalToolLaunchable, private readonly userId: EntityId) { + super( + { + type: 'LAUNCH_CONTEXT_UNAVAILABLE', + title: 'Launch context unavailable', + defaultMessage: 'The context type cannot launch school external tools', + }, + HttpStatus.FORBIDDEN + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: this.type, + message: this.message, + stack: this.stack, + data: { + userId: this.userId, + schoolExternalToolId: this.contextExternalTool.schoolToolRef.schoolToolId, + contextType: this.contextExternalTool.contextRef.type, + contextId: this.contextExternalTool.contextRef.id, + }, + }; + } +} diff --git a/apps/server/src/modules/tool/tool-launch/error/missing-licence.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/missing-licence.loggable-exception.ts index a8e24ec80e9..a7b398646d4 100644 --- a/apps/server/src/modules/tool/tool-launch/error/missing-licence.loggable-exception.ts +++ b/apps/server/src/modules/tool/tool-launch/error/missing-licence.loggable-exception.ts @@ -3,12 +3,13 @@ import { HttpStatus } from '@nestjs/common'; import { BusinessError } from '@shared/common'; import { EntityId } from '@shared/domain/types'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ContextExternalToolLaunchable } from '../../context-external-tool/domain'; export class MissingMediaLicenseLoggableException extends BusinessError implements Loggable { constructor( private readonly medium: ExternalToolMedium, private readonly userId: EntityId, - private readonly contextExternalToolId?: string + private readonly contextExternalTool: ContextExternalToolLaunchable ) { super( { @@ -26,9 +27,15 @@ export class MissingMediaLicenseLoggableException extends BusinessError implemen message: this.message, stack: this.stack, data: { - medium: { mediumId: this.medium.mediumId, publisher: this.medium.publisher }, + medium: { + mediumId: this.medium.mediumId, + publisher: this.medium.publisher, + }, userId: this.userId, - contextExternalToolId: this.contextExternalToolId, + contextExternalToolId: this.contextExternalTool.id, + schoolExternalToolId: this.contextExternalTool.schoolToolRef.schoolToolId, + contextType: this.contextExternalTool.contextRef.type, + contextId: this.contextExternalTool.contextRef.id, }, }; } diff --git a/apps/server/src/modules/tool/tool-launch/error/missing-license.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/missing-license.loggable-exception.spec.ts index 59f0fab5baa..80f72c5e34c 100644 --- a/apps/server/src/modules/tool/tool-launch/error/missing-license.loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/error/missing-license.loggable-exception.spec.ts @@ -1,17 +1,18 @@ import { ExternalToolMedium } from '@modules/tool/external-tool/domain'; import { MissingMediaLicenseLoggableException } from '@modules/tool/tool-launch/error'; +import { contextExternalToolFactory } from '@shared/testing'; -describe('MissingMediaLicenseLoggableException', () => { +describe(MissingMediaLicenseLoggableException.name, () => { describe('getLogMessage', () => { const setup = () => { - const contextExternalToolId = 'contextExternalTooId'; + const contextExternalTool = contextExternalToolFactory.buildWithId(); const medium: ExternalToolMedium = new ExternalToolMedium({ mediumId: 'mediumId', publisher: 'publisher' }); const userId = 'userId'; - const exception = new MissingMediaLicenseLoggableException(medium, userId, contextExternalToolId); + const exception = new MissingMediaLicenseLoggableException(medium, userId, contextExternalTool); return { - contextExternalToolId, + contextExternalTool, medium, userId, exception, @@ -19,7 +20,7 @@ describe('MissingMediaLicenseLoggableException', () => { }; it('should log the correct message', () => { - const { contextExternalToolId, medium, userId, exception } = setup(); + const { contextExternalTool, medium, userId, exception } = setup(); const result = exception.getLogMessage(); @@ -30,7 +31,10 @@ describe('MissingMediaLicenseLoggableException', () => { data: { medium, userId, - contextExternalToolId, + contextExternalToolId: contextExternalTool.id, + schoolExternalToolId: contextExternalTool.schoolToolRef.schoolToolId, + contextType: contextExternalTool.contextRef.type, + contextId: contextExternalTool.contextRef.id, }, }); }); diff --git a/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts index e72af82d85a..d7705d05693 100644 --- a/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts +++ b/apps/server/src/modules/tool/tool-launch/error/missing-tool-parameter-value.loggable-exception.ts @@ -1,12 +1,12 @@ import { HttpStatus } from '@nestjs/common'; import { BusinessError } from '@shared/common'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { ContextExternalTool } from '../../context-external-tool/domain'; import { CustomParameter } from '../../common/domain'; +import { ContextExternalToolLaunchable } from '../../context-external-tool/domain'; export class MissingToolParameterValueLoggableException extends BusinessError implements Loggable { constructor( - private readonly contextExternalTool: ContextExternalTool, + private readonly contextExternalTool: ContextExternalToolLaunchable, private readonly parameters: CustomParameter[] ) { super( diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.ts index c78ff80e764..a0fca0f2c1b 100644 --- a/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.ts +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.ts @@ -1,8 +1,8 @@ -import { BadRequestException } from '@nestjs/common'; +import { UnprocessableEntityException } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class ToolStatusNotLaunchableLoggableException extends BadRequestException implements Loggable { +export class ToolStatusNotLaunchableLoggableException extends UnprocessableEntityException implements Loggable { constructor( private readonly userId: EntityId, private readonly toolId: EntityId, diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-id.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-id.strategy.ts index a732d038522..42916b383f6 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-id.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-id.strategy.ts @@ -1,11 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { AutoParameterStrategy } from './auto-parameter.strategy'; @Injectable() export class AutoContextIdStrategy implements AutoParameterStrategy { - getValue(schoolExternalTool: SchoolExternalTool, contextExternalTool: ContextExternalTool): string | undefined { + getValue( + schoolExternalTool: SchoolExternalTool, + contextExternalTool: ContextExternalToolLaunchable + ): string | undefined { return contextExternalTool.contextRef.id; } } diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-name.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-name.strategy.ts index b9755692674..2931c662e9c 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-name.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-context-name.strategy.ts @@ -6,7 +6,7 @@ import { Course } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { CustomParameterType, ToolContextType } from '../../../common/enum'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { ParameterTypeNotImplementedLoggableException } from '../../error'; import { AutoParameterStrategy } from './auto-parameter.strategy'; @@ -21,7 +21,7 @@ export class AutoContextNameStrategy implements AutoParameterStrategy { async getValue( schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): Promise { switch (contextExternalTool.contextRef.type) { case ToolContextType.COURSE: { diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts index ae652ea31fb..ff4bf471da1 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ContextExternalTool } from '@src/modules/tool/context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '@src/modules/tool/context-external-tool/domain'; import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; import { ExternalTool } from '../../../external-tool/domain'; import { ExternalToolService } from '../../../external-tool/service'; @@ -12,7 +12,7 @@ export class AutoMediumIdStrategy implements AutoParameterStrategy { async getValue( schoolExternalTool: SchoolExternalTool, // eslint-disable-next-line @typescript-eslint/no-unused-vars - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): Promise { const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); return externalTool.medium?.mediumId; diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-parameter.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-parameter.strategy.ts index 5c5efbcc2b1..fd96aaa93ce 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-parameter.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-parameter.strategy.ts @@ -1,9 +1,9 @@ -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; export interface AutoParameterStrategy { getValue( schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): string | Promise | undefined; } diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-id.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-id.strategy.ts index faad4be07d9..d4c848481e5 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-id.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-id.strategy.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { AutoParameterStrategy } from './auto-parameter.strategy'; @@ -8,7 +8,7 @@ export class AutoSchoolIdStrategy implements AutoParameterStrategy { getValue( schoolExternalTool: SchoolExternalTool, // eslint-disable-next-line @typescript-eslint/no-unused-vars - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): string | undefined { return schoolExternalTool.schoolId; } diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-number.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-number.strategy.ts index c501212da18..1dab1f9feb0 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-number.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-school-number.strategy.ts @@ -1,7 +1,7 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { Injectable } from '@nestjs/common'; import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { AutoParameterStrategy } from './auto-parameter.strategy'; @@ -12,7 +12,7 @@ export class AutoSchoolNumberStrategy implements AutoParameterStrategy { async getValue( schoolExternalTool: SchoolExternalTool, // eslint-disable-next-line @typescript-eslint/no-unused-vars - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): Promise { const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts index 36057141c08..9abfb4cf78c 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts @@ -3,7 +3,7 @@ import { EntityId } from '@shared/domain/types'; import { URLSearchParams } from 'url'; import { CustomParameter, CustomParameterEntry } from '../../../common/domain'; import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '../../../common/enum'; -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { ExternalTool } from '../../../external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; @@ -160,7 +160,7 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { customParameterDOs: CustomParameter[], scopes: { scope: CustomParameterScope; params: CustomParameterEntry[] }[], schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): Promise { await Promise.all( scopes.map(async ({ scope, params }): Promise => { @@ -186,7 +186,7 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { parametersToInclude: CustomParameter[], params: CustomParameterEntry[], schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): Promise { const missingParameters: CustomParameter[] = []; @@ -222,7 +222,7 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { customParameter: CustomParameter, matchingParameterEntry: CustomParameterEntry | undefined, schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): Promise { if ( customParameter.type === CustomParameterType.BOOLEAN || diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts index 610ecefb48f..f8b83a00604 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { PseudonymService } from '@modules/pseudonym/service'; import { UserService } from '@modules/user'; import { InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; @@ -12,7 +13,6 @@ import { userDoFactory, } from '@shared/testing'; import { pseudonymFactory } from '@shared/testing/factory/domainobject/pseudonym.factory'; -import { ObjectId } from '@mikro-orm/mongodb'; import { Authorization } from 'oauth-1.0a'; import { LtiMessageType, LtiPrivacyPermission, LtiRole } from '../../../common/enum'; import { ContextExternalTool } from '../../../context-external-tool/domain'; @@ -22,9 +22,9 @@ import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoMediumIdStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; @@ -565,7 +565,7 @@ describe('Lti11ToolLaunchStrategy', () => { }) .buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: ToolLaunchParams = { contextExternalTool, @@ -573,17 +573,34 @@ describe('Lti11ToolLaunchStrategy', () => { externalTool, }; + const user: UserDO = userDoFactory.buildWithId({ + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + ], + }); + + userService.findById.mockResolvedValue(user); + return { data, }; }; - it('should throw an InternalServerErrorException', async () => { + it('should use a random id', async () => { const { data } = setup(); - const func = async () => strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + const result = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - await expect(func).rejects.toThrow(new InternalServerErrorException()); + expect(result).toContainEqual( + new PropertyData({ + name: 'resource_link_id', + value: expect.any(String), + location: PropertyLocation.BODY, + }) + ); }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts index 04852bb6537..9747bfabb25 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts @@ -1,3 +1,4 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { PseudonymService } from '@modules/pseudonym/service'; import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; @@ -12,9 +13,9 @@ import { AuthenticationValues, LaunchRequestMethod, PropertyData, PropertyLocati import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoMediumIdStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; @@ -54,10 +55,6 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { ); } - if (!data.contextExternalTool.id) { - throw new InternalServerErrorException(); - } - const user: UserDO = await this.userService.findById(userId); const roleNames: RoleName[] = user.roles.map((roleRef: RoleReference): RoleName => roleRef.name); @@ -69,9 +66,10 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { new PropertyData({ name: 'lti_message_type', value: config.lti_message_type, location: PropertyLocation.BODY }), new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), + // When there is no persistent link to a resource, then generate a new one every time new PropertyData({ name: 'resource_link_id', - value: data.contextExternalTool.id, + value: data.contextExternalTool.id || new ObjectId().toHexString(), location: PropertyLocation.BODY, }), new PropertyData({ diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-params.interface.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-params.interface.ts index 73fdf1d78b7..22eabc15a4c 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-params.interface.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-params.interface.ts @@ -1,4 +1,4 @@ -import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../../context-external-tool/domain'; import { ExternalTool } from '../../../external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; @@ -7,5 +7,5 @@ export interface ToolLaunchParams { schoolExternalTool: SchoolExternalTool; - contextExternalTool: ContextExternalTool; + contextExternalTool: ContextExternalToolLaunchable; } 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 b78e7a0eb2e..a5fad4d9bc3 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 @@ -2,12 +2,12 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { ContextExternalToolConfigurationStatus } from '../../common/domain'; import { ToolConfigType } from '../../common/enum'; -import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ContextExternalToolLaunchable } from '../../context-external-tool/domain'; import { ToolConfigurationStatusService } from '../../context-external-tool/service'; -import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; +import { ExternalTool } from '../../external-tool/domain'; import { SchoolExternalToolService } from '../../school-external-tool'; +import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ToolStatusNotLaunchableLoggableException } from '../error'; import { ToolLaunchMapper } from '../mapper'; import { ToolLaunchData, ToolLaunchRequest } from '../types'; @@ -49,7 +49,7 @@ export class ToolLaunchService { return launchRequest; } - async getLaunchData(userId: EntityId, contextExternalTool: ContextExternalTool): Promise { + async getLaunchData(userId: EntityId, contextExternalTool: ContextExternalToolLaunchable): Promise { const schoolExternalToolId: EntityId = contextExternalTool.schoolToolRef.schoolToolId; const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); @@ -88,7 +88,7 @@ export class ToolLaunchService { userId: EntityId, externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, - contextExternalTool: ContextExternalTool + contextExternalTool: ContextExternalToolLaunchable ): void { const status: ContextExternalToolConfigurationStatus = this.toolVersionService.determineToolConfigurationStatus( externalTool, 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 6d460eb0e05..b1e7ace69c4 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,9 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ObjectId } from '@mikro-orm/mongodb'; import { MediaBoardConfig } from '@modules/board/media-board.config'; import { ExternalTool } from '@modules/tool/external-tool/domain'; -import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; -import { MissingMediaLicenseLoggableException } from '@modules/tool/tool-launch/error'; +import { SchoolExternalTool, SchoolExternalToolWithId } from '@modules/tool/school-external-tool/domain'; +import { + LaunchContextUnavailableLoggableException, + MissingMediaLicenseLoggableException, +} from '@modules/tool/tool-launch/error'; import { MediaUserLicense, mediaUserLicenseFactory, UserLicenseService } from '@modules/user-license'; import { MediaUserLicenseService } from '@modules/user-license/service'; import { ConfigService } from '@nestjs/config'; @@ -18,11 +21,13 @@ import { userFactory, } from '@shared/testing'; import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; -import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ContextExternalTool, ContextExternalToolLaunchable } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; +import { SchoolExternalToolService } from '../../school-external-tool'; import { ToolLaunchService } from '../service'; -import { ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; +import { LaunchRequestMethod, ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; describe('ToolLaunchUc', () => { @@ -31,6 +36,7 @@ describe('ToolLaunchUc', () => { let toolLaunchService: DeepMocked; let contextExternalToolService: DeepMocked; + let schoolExternalToolService: DeepMocked; let toolPermissionHelper: DeepMocked; let authorizationService: DeepMocked; let userLicenseService: DeepMocked; @@ -49,6 +55,10 @@ describe('ToolLaunchUc', () => { provide: ContextExternalToolService, useValue: createMock(), }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, { provide: ToolPermissionHelper, useValue: createMock(), @@ -75,6 +85,7 @@ describe('ToolLaunchUc', () => { uc = module.get(ToolLaunchUc); toolLaunchService = module.get(ToolLaunchService); contextExternalToolService = module.get(ContextExternalToolService); + schoolExternalToolService = module.get(SchoolExternalToolService); toolPermissionHelper = module.get(ToolPermissionHelper); authorizationService = module.get(AuthorizationService); userLicenseService = module.get(UserLicenseService); @@ -94,7 +105,7 @@ describe('ToolLaunchUc', () => { jest.clearAllMocks(); }); - describe('getToolLaunchRequest', () => { + describe('getContextExternalToolLaunchRequest', () => { describe('when licensing feature is disabled', () => { const setup = () => { configService.get.mockReturnValueOnce(false); @@ -127,7 +138,7 @@ describe('ToolLaunchUc', () => { it('should check user permissions to launch the tool', async () => { const { user, contextExternalToolId, contextExternalTool } = setup(); - await uc.getToolLaunchRequest(user.id, contextExternalToolId); + await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( user, @@ -139,7 +150,7 @@ describe('ToolLaunchUc', () => { it('should call service to get context external tool', async () => { const { user, contextExternalToolId } = setup(); - await uc.getToolLaunchRequest(user.id, contextExternalToolId); + await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); expect(contextExternalToolService.findByIdOrFail).toHaveBeenCalledWith(contextExternalToolId); }); @@ -147,7 +158,7 @@ describe('ToolLaunchUc', () => { it('should call service to get data', async () => { const { user, contextExternalToolId, contextExternalTool } = setup(); - await uc.getToolLaunchRequest(user.id, contextExternalToolId); + await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(user.id, contextExternalTool); }); @@ -157,7 +168,7 @@ describe('ToolLaunchUc', () => { toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); - await uc.getToolLaunchRequest(user.id, contextExternalToolId); + await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(toolLaunchData); }); @@ -165,7 +176,10 @@ describe('ToolLaunchUc', () => { it('should return launch request', async () => { const { user, contextExternalToolId } = setup(); - const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(user.id, contextExternalToolId); + const toolLaunchRequest: ToolLaunchRequest = await uc.getContextExternalToolLaunchRequest( + user.id, + contextExternalToolId + ); expect(toolLaunchRequest).toBeDefined(); }); @@ -209,7 +223,7 @@ describe('ToolLaunchUc', () => { it('should not check license', async () => { const { user, contextExternalToolId } = setup(); - await uc.getToolLaunchRequest(user.id, contextExternalToolId); + await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); expect(mediaUserLicenseService.hasLicenseForExternalTool).not.toHaveBeenCalled(); }); @@ -217,7 +231,10 @@ describe('ToolLaunchUc', () => { it('should return launch request', async () => { const { user, contextExternalToolId } = setup(); - const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(user.id, contextExternalToolId); + const toolLaunchRequest: ToolLaunchRequest = await uc.getContextExternalToolLaunchRequest( + user.id, + contextExternalToolId + ); expect(toolLaunchRequest).toBeDefined(); }); @@ -242,7 +259,6 @@ describe('ToolLaunchUc', () => { }); const mediaUserlicense: MediaUserLicense = mediaUserLicenseFactory.build(); - Configuration.set('FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED', true); toolLaunchService.loadToolHierarchy.mockResolvedValue({ externalTool, schoolExternalTool }); userLicenseService.getMediaUserLicensesForUser.mockResolvedValue([mediaUserlicense]); mediaUserLicenseService.hasLicenseForExternalTool.mockReturnValue(true); @@ -263,7 +279,7 @@ describe('ToolLaunchUc', () => { it('should check license', async () => { const { user, contextExternalToolId } = setup(); - await uc.getToolLaunchRequest(user.id, contextExternalToolId); + await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); expect(mediaUserLicenseService.hasLicenseForExternalTool).toHaveBeenCalled(); }); @@ -271,7 +287,10 @@ describe('ToolLaunchUc', () => { it('should return launch request', async () => { const { user, contextExternalToolId } = setup(); - const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(user.id, contextExternalToolId); + const toolLaunchRequest: ToolLaunchRequest = await uc.getContextExternalToolLaunchRequest( + user.id, + contextExternalToolId + ); expect(toolLaunchRequest).toBeDefined(); }); @@ -296,7 +315,6 @@ describe('ToolLaunchUc', () => { }); const mediaUserlicense: MediaUserLicense = mediaUserLicenseFactory.build(); - Configuration.set('FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED', true); toolLaunchService.loadToolHierarchy.mockResolvedValue({ externalTool, schoolExternalTool }); userLicenseService.getMediaUserLicensesForUser.mockResolvedValue([mediaUserlicense]); mediaUserLicenseService.hasLicenseForExternalTool.mockReturnValue(false); @@ -317,11 +335,335 @@ describe('ToolLaunchUc', () => { it('should throw MissingMediaLicenseLoggableException', async () => { const { user, contextExternalToolId } = setup(); - const toolLaunchRequest: Promise = uc.getToolLaunchRequest(user.id, contextExternalToolId); + const toolLaunchRequest: Promise = uc.getContextExternalToolLaunchRequest( + user.id, + contextExternalToolId + ); await expect(toolLaunchRequest).rejects.toThrow(MissingMediaLicenseLoggableException); }); }); }); }); + + describe('getSchoolExternalToolLaunchRequest', () => { + describe('when licensing feature is disabled', () => { + const setup = () => { + const user: User = userFactory.build(); + const schoolExternalToolId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: schoolExternalToolId }); + const contextExternalTool: ContextExternalToolLaunchable = { + schoolToolRef: { + schoolToolId: schoolExternalToolId, + }, + contextRef: { + type: ToolContextType.MEDIA_BOARD, + id: new ObjectId().toHexString(), + }, + parameters: [], + }; + const toolLaunchData: ToolLaunchData = new ToolLaunchData({ + baseUrl: 'baseUrl', + type: ToolLaunchDataType.BASIC, + openNewTab: true, + properties: [], + }); + const toolLaunchRequest = new ToolLaunchRequest({ + openNewTab: true, + method: LaunchRequestMethod.GET, + url: 'https://mock.com/', + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool as SchoolExternalToolWithId); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + configService.get.mockReturnValueOnce(false); + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockReturnValueOnce(toolLaunchRequest); + + return { + user, + schoolExternalTool, + contextExternalTool, + toolLaunchData, + toolLaunchRequest, + }; + }; + + it('should check user permissions to launch the tool', async () => { + const { user, contextExternalTool, schoolExternalTool } = setup(); + + await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(toolPermissionHelper.ensureContextPermissionsForSchool).toHaveBeenCalledWith( + user, + schoolExternalTool, + contextExternalTool.contextRef.id, + contextExternalTool.contextRef.type, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]) + ); + }); + + it('should check for context restrictions', async () => { + const { user, contextExternalTool } = setup(); + + await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(contextExternalToolService.checkContextRestrictions).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should call service to get data', async () => { + const { user, contextExternalTool } = setup(); + + await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(user.id, contextExternalTool); + }); + + it('should call service to generate launch request', async () => { + const { user, contextExternalTool, toolLaunchData } = setup(); + + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + + await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(toolLaunchData); + }); + + it('should return launch request', async () => { + const { user, contextExternalTool, toolLaunchRequest } = setup(); + + const result = await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(result).toEqual(toolLaunchRequest); + }); + }); + + describe('when licensing feature flag is enabled', () => { + describe('when the tool has no medium id', () => { + const setup = () => { + const user: User = userFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalToolId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: schoolExternalToolId }); + const contextExternalTool: ContextExternalToolLaunchable = { + schoolToolRef: { + schoolToolId: schoolExternalToolId, + }, + contextRef: { + type: ToolContextType.MEDIA_BOARD, + id: new ObjectId().toHexString(), + }, + parameters: [], + }; + const toolLaunchData: ToolLaunchData = new ToolLaunchData({ + baseUrl: 'baseUrl', + type: ToolLaunchDataType.BASIC, + openNewTab: true, + properties: [], + }); + const toolLaunchRequest = new ToolLaunchRequest({ + openNewTab: true, + method: LaunchRequestMethod.GET, + url: 'https://mock.com/', + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool as SchoolExternalToolWithId); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + configService.get.mockReturnValueOnce(true); + toolLaunchService.loadToolHierarchy.mockResolvedValue({ externalTool, schoolExternalTool }); + userLicenseService.getMediaUserLicensesForUser.mockResolvedValue([]); + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockReturnValueOnce(toolLaunchRequest); + + return { + user, + schoolExternalTool, + contextExternalTool, + toolLaunchData, + toolLaunchRequest, + }; + }; + + it('should not check license', async () => { + const { user, contextExternalTool } = setup(); + + await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(mediaUserLicenseService.hasLicenseForExternalTool).not.toHaveBeenCalled(); + }); + + it('should return launch request', async () => { + const { user, contextExternalTool } = setup(); + + const toolLaunchRequest: ToolLaunchRequest = await uc.getSchoolExternalToolLaunchRequest( + user.id, + contextExternalTool + ); + + expect(toolLaunchRequest).toBeDefined(); + }); + }); + + describe('when the tool has a medium id and a license exists', () => { + const setup = () => { + const user: User = userFactory.build(); + const mediaUserLicense: MediaUserLicense = mediaUserLicenseFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ + medium: { mediumId: mediaUserLicense.mediumId }, + }); + const schoolExternalToolId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: schoolExternalToolId }); + const contextExternalTool: ContextExternalToolLaunchable = { + schoolToolRef: { + schoolToolId: schoolExternalToolId, + }, + contextRef: { + type: ToolContextType.MEDIA_BOARD, + id: new ObjectId().toHexString(), + }, + parameters: [], + }; + const toolLaunchData: ToolLaunchData = new ToolLaunchData({ + baseUrl: 'baseUrl', + type: ToolLaunchDataType.BASIC, + openNewTab: true, + properties: [], + }); + const toolLaunchRequest = new ToolLaunchRequest({ + openNewTab: true, + method: LaunchRequestMethod.GET, + url: 'https://mock.com/', + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool as SchoolExternalToolWithId); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + configService.get.mockReturnValueOnce(true); + toolLaunchService.loadToolHierarchy.mockResolvedValue({ externalTool, schoolExternalTool }); + userLicenseService.getMediaUserLicensesForUser.mockResolvedValue([mediaUserLicense]); + mediaUserLicenseService.hasLicenseForExternalTool.mockReturnValueOnce(true); + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockReturnValueOnce(toolLaunchRequest); + + return { + user, + schoolExternalTool, + contextExternalTool, + toolLaunchData, + toolLaunchRequest, + mediaUserLicense, + }; + }; + + it('should check license', async () => { + const { user, contextExternalTool, mediaUserLicense } = setup(); + + await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(mediaUserLicenseService.hasLicenseForExternalTool).toHaveBeenCalledWith(mediaUserLicense.mediumId, [ + mediaUserLicense, + ]); + }); + + it('should return launch request', async () => { + const { user, contextExternalTool, toolLaunchRequest } = setup(); + + const result = await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); + + expect(result).toEqual(toolLaunchRequest); + }); + }); + + describe('when the tool has a medium id and no license exists', () => { + const setup = () => { + const user: User = userFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ + medium: { mediumId: 'mediumId' }, + }); + const schoolExternalToolId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: schoolExternalToolId }); + const contextExternalTool: ContextExternalToolLaunchable = { + schoolToolRef: { + schoolToolId: schoolExternalToolId, + }, + contextRef: { + type: ToolContextType.MEDIA_BOARD, + id: new ObjectId().toHexString(), + }, + parameters: [], + }; + const toolLaunchData: ToolLaunchData = new ToolLaunchData({ + baseUrl: 'baseUrl', + type: ToolLaunchDataType.BASIC, + openNewTab: true, + properties: [], + }); + const toolLaunchRequest = new ToolLaunchRequest({ + openNewTab: true, + method: LaunchRequestMethod.GET, + url: 'https://mock.com/', + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool as SchoolExternalToolWithId); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + configService.get.mockReturnValueOnce(true); + toolLaunchService.loadToolHierarchy.mockResolvedValue({ externalTool, schoolExternalTool }); + userLicenseService.getMediaUserLicensesForUser.mockResolvedValue([]); + mediaUserLicenseService.hasLicenseForExternalTool.mockReturnValueOnce(false); + toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockReturnValueOnce(toolLaunchRequest); + + return { + user, + schoolExternalTool, + contextExternalTool, + toolLaunchData, + toolLaunchRequest, + }; + }; + + it('should throw MissingMediaLicenseLoggableException', async () => { + const { user, contextExternalTool } = setup(); + + await expect(uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool)).rejects.toThrow( + MissingMediaLicenseLoggableException + ); + }); + }); + }); + + describe('when launching a context that is not available', () => { + const setup = () => { + const user: User = userFactory.build(); + const schoolExternalToolId = new ObjectId().toHexString(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ id: schoolExternalToolId }); + const contextExternalTool: ContextExternalToolLaunchable = { + schoolToolRef: { + schoolToolId: schoolExternalToolId, + }, + contextRef: { + type: ToolContextType.COURSE, + id: new ObjectId().toHexString(), + }, + parameters: [], + }; + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool as SchoolExternalToolWithId); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should throw an exception', async () => { + const { user, contextExternalTool } = setup(); + + await expect(uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool)).rejects.toThrow( + LaunchContextUnavailableLoggableException + ); + }); + }); + }); }); 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 14f2905c4fc..cee7aa74877 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 @@ -8,9 +8,13 @@ import { ConfigService } from '@nestjs/config'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; -import { ContextExternalTool } from '../../context-external-tool/domain'; +import { type ContextExternalTool, ContextExternalToolLaunchable } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; +import { type SchoolExternalTool } from '../../school-external-tool/domain'; +import { SchoolExternalToolService } from '../../school-external-tool/service'; +import { LaunchContextUnavailableLoggableException } from '../error'; import { ToolLaunchService } from '../service'; import { ToolLaunchData, ToolLaunchRequest } from '../types'; @@ -19,6 +23,7 @@ export class ToolLaunchUc { constructor( private readonly toolLaunchService: ToolLaunchService, private readonly contextExternalToolService: ContextExternalToolService, + private readonly schoolExternalToolService: SchoolExternalToolService, private readonly toolPermissionHelper: ToolPermissionHelper, private readonly authorizationService: AuthorizationService, private readonly userLicenseService: UserLicenseService, @@ -26,13 +31,16 @@ export class ToolLaunchUc { private readonly configService: ConfigService ) {} - async getToolLaunchRequest(userId: EntityId, contextExternalToolId: EntityId): Promise { + async getContextExternalToolLaunchRequest( + userId: EntityId, + contextExternalToolId: EntityId + ): Promise { const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findByIdOrFail( contextExternalToolId ); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); - const user: User = await this.authorizationService.getUserWithPermissions(userId); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); if (this.configService.get('FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED')) { @@ -45,7 +53,48 @@ export class ToolLaunchUc { return launchRequest; } - private async checkUserHasLicenseForExternalTool(contextExternalTool: ContextExternalTool, userId: EntityId) { + async getSchoolExternalToolLaunchRequest( + userId: EntityId, + pseudoContextExternalTool: ContextExternalToolLaunchable + ): Promise { + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( + pseudoContextExternalTool.schoolToolRef.schoolToolId + ); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + await this.toolPermissionHelper.ensureContextPermissionsForSchool( + user, + schoolExternalTool, + pseudoContextExternalTool.contextRef.id, + pseudoContextExternalTool.contextRef.type, + context + ); + + const availableLaunchContexts: ToolContextType[] = [ToolContextType.MEDIA_BOARD]; + if (!availableLaunchContexts.includes(pseudoContextExternalTool.contextRef.type)) { + throw new LaunchContextUnavailableLoggableException(pseudoContextExternalTool, userId); + } + + await this.contextExternalToolService.checkContextRestrictions(pseudoContextExternalTool); + + if (this.configService.get('FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED')) { + await this.checkUserHasLicenseForExternalTool(pseudoContextExternalTool, userId); + } + + const toolLaunchData: ToolLaunchData = await this.toolLaunchService.getLaunchData( + userId, + pseudoContextExternalTool + ); + const launchRequest: ToolLaunchRequest = this.toolLaunchService.generateLaunchRequest(toolLaunchData); + + return launchRequest; + } + + private async checkUserHasLicenseForExternalTool( + contextExternalTool: ContextExternalToolLaunchable, + userId: EntityId + ): Promise { const schoolExternalToolId: EntityId = contextExternalTool.schoolToolRef.schoolToolId; const { externalTool } = await this.toolLaunchService.loadToolHierarchy(schoolExternalToolId); @@ -56,7 +105,7 @@ export class ToolLaunchUc { externalTool.medium?.mediumId && !this.mediaUserLicenseService.hasLicenseForExternalTool(externalTool.medium.mediumId, mediaUserLicenses) ) { - throw new MissingMediaLicenseLoggableException(externalTool.medium, userId, contextExternalTool.id); + throw new MissingMediaLicenseLoggableException(externalTool.medium, userId, contextExternalTool); } } }