diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 95dbe9c648d..15b976ab8da 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -545,6 +545,17 @@ data: "upsert": true } );' + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Merlin Bibliothek", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_MERLIN'", + } }, + { + "upsert": true + } + );' echo "Inserted ctl seed data secrets to external-tools." # ========== End of the CTL seed data configuration section. diff --git a/apps/server/src/modules/tool/common/enum/lti-message-type.enum.ts b/apps/server/src/modules/tool/common/enum/lti-message-type.enum.ts index 6574f8d89b7..977c0524edd 100644 --- a/apps/server/src/modules/tool/common/enum/lti-message-type.enum.ts +++ b/apps/server/src/modules/tool/common/enum/lti-message-type.enum.ts @@ -1,3 +1,4 @@ export enum LtiMessageType { BASIC_LTI_LAUNCH_REQUEST = 'basic-lti-launch-request', + CONTENT_ITEM_SELECTION_REQUEST = 'ContentItemSelectionRequest', } diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts index b43b30b4cbe..17e415eb7d3 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts @@ -1,7 +1,7 @@ import { Embedded, Entity, ManyToOne, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from '@mikro-orm/mongodb'; import { CustomParameterEntryEntity } from '../../common/entity'; import { SchoolExternalToolEntity } from '../../school-external-tool/entity'; import { ContextExternalToolType } from './context-external-tool-type.enum'; diff --git a/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts b/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts index 96f18c0751d..677d33bf4cf 100644 --- a/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/external-tool-datasheet-template-data.factory.ts @@ -60,7 +60,7 @@ export const externalToolDatasheetTemplateDataFactory = ExternalToolDatasheetTem creatorName: `John Doe ${sequence}`, instance: 'dBildungscloud', toolName: `external-tool-${sequence}`, - toolUrl: 'https://www.basic-baseUrl.com/', + toolUrl: 'https://www.basic-baseurl.com/', toolType: 'Basic', }; } diff --git a/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts b/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts index f1b07fb7d78..ac3005b33e7 100644 --- a/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/external-tool.factory.ts @@ -1,6 +1,6 @@ -import { DeepPartial } from 'fishery'; import { ObjectId } from '@mikro-orm/mongodb'; import { DoBaseFactory } from '@shared/testing/factory/domainobject/do-base.factory'; +import { DeepPartial } from 'fishery'; import { CustomParameter } from '../../common/domain'; import { CustomParameterLocation, @@ -24,7 +24,7 @@ import { fileRecordRefFactory } from './file-record-ref.factory'; export const basicToolConfigFactory = DoBaseFactory.define(BasicToolConfig, () => { return { type: ToolConfigType.BASIC, - baseUrl: 'https://www.basic-baseUrl.com/', + baseUrl: 'https://www.basic-baseurl.com/', }; }); @@ -86,10 +86,19 @@ export const customParameterFactory = CustomParameterFactory.define(CustomParame }); class ExternalToolFactory extends DoBaseFactory { + withBasicConfig(customParam?: DeepPartial): this { + const params: DeepPartial = { + config: basicToolConfigFactory.build(customParam), + }; + + return this.params(params); + } + withOauth2Config(customParam?: DeepPartial): this { const params: DeepPartial = { config: oauth2ToolConfigFactory.build(customParam), }; + return this.params(params); } @@ -97,6 +106,7 @@ class ExternalToolFactory extends DoBaseFactory const params: DeepPartial = { config: lti11ToolConfigFactory.build(customParam), }; + return this.params(params); } @@ -104,6 +114,7 @@ class ExternalToolFactory extends DoBaseFactory const params: DeepPartial = { parameters: customParameterFactory.buildList(number, customParam), }; + return this.params(params); } diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index 625d60eee53..42eb7db29e5 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -1055,7 +1055,7 @@ describe(ExternalToolUc.name, () => { const lti11ToolConfig: Lti11ToolConfigUpdate = { type: ToolConfigType.LTI11, - baseUrl: 'https://www.basic-baseUrl.com/', + baseUrl: 'https://www.basic-baseurl.com/', key: 'key', privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, 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 f85cfcd69a3..4a695caa828 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 @@ -123,6 +123,7 @@ describe('ToolLaunchController (API)', () => { method: LaunchRequestMethod.GET, url: 'https://mockurl.de/', openNewTab: true, + isDeepLink: false, }); }); }); @@ -413,6 +414,7 @@ describe('ToolLaunchController (API)', () => { method: LaunchRequestMethod.GET, url: 'https://mockurl.de/', openNewTab: true, + isDeepLink: false, }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts b/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts index 8893dd37af5..a488bca921a 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/dto/tool-launch-request.response.ts @@ -29,10 +29,16 @@ export class ToolLaunchRequestResponse { }) openNewTab?: boolean; + @ApiProperty({ + description: 'Specifies whether the request is an LTI Deep linking content item selection request', + }) + isDeepLink: boolean; + constructor(props: ToolLaunchRequestResponse) { this.url = props.url; this.method = props.method; this.payload = props.payload; this.openNewTab = props.openNewTab; + this.isDeepLink = props.isDeepLink; } } diff --git a/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.spec.ts b/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.spec.ts index 7fb66a4f5c6..229fbb4d49d 100644 --- a/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/error/tool-status-not-launchable.loggable-exception.spec.ts @@ -1,31 +1,25 @@ +import { contextExternalToolFactory } from '../../context-external-tool/testing'; import { toolConfigurationStatusFactory } from '../../external-tool/testing'; import { ToolStatusNotLaunchableLoggableException } from './tool-status-not-launchable.loggable-exception'; -describe('ToolStatusNotLaunchableLoggableException', () => { +describe(ToolStatusNotLaunchableLoggableException.name, () => { describe('getLogMessage', () => { const setup = () => { - const toolId = 'toolId'; const userId = 'userId'; + const contextExternalTool = contextExternalToolFactory.build(); const toolConfigStatus = toolConfigurationStatusFactory.build(); - const exception = new ToolStatusNotLaunchableLoggableException( - userId, - toolId, - toolConfigStatus.isOutdatedOnScopeSchool, - toolConfigStatus.isOutdatedOnScopeContext, - toolConfigStatus.isIncompleteOnScopeContext, - toolConfigStatus.isIncompleteOperationalOnScopeContext, - toolConfigStatus.isDeactivated, - toolConfigStatus.isNotLicensed - ); + const exception = new ToolStatusNotLaunchableLoggableException(userId, contextExternalTool, toolConfigStatus); return { exception, + toolConfigStatus, + contextExternalTool, }; }; it('should log the correct message', () => { - const { exception } = setup(); + const { exception, toolConfigStatus, contextExternalTool } = setup(); const result = exception.getLogMessage(); @@ -35,13 +29,9 @@ describe('ToolStatusNotLaunchableLoggableException', () => { stack: expect.any(String), data: { userId: 'userId', - toolId: 'toolId', - isOutdatedOnScopeSchool: false, - isOutdatedOnScopeContext: false, - isIncompleteOnScopeContext: false, - isIncompleteOperationalOnScopeContext: false, - isDeactivated: false, - isNotLicensed: false, + contextExternalToolId: contextExternalTool.id, + schoolExternalToolId: contextExternalTool.schoolToolRef.schoolToolId, + status: toolConfigStatus, }, }); }); 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 c8d1c7d49c3..94254fcc47b 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,17 +1,14 @@ import { UnprocessableEntityException } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ContextExternalToolConfigurationStatus } from '../../common/domain'; +import { ContextExternalToolLaunchable } from '../../context-external-tool/domain'; export class ToolStatusNotLaunchableLoggableException extends UnprocessableEntityException implements Loggable { constructor( private readonly userId: EntityId, - private readonly toolId: EntityId, - private readonly isOutdatedOnScopeSchool: boolean, - private readonly isOutdatedOnScopeContext: boolean, - private readonly isIncompleteOnScopeContext: boolean, - private readonly isIncompleteOperationalOnScopeContext: boolean, - private readonly isDeactivated: boolean, - private readonly isNotLicensed: boolean + private readonly contextExternalTool: ContextExternalToolLaunchable, + private readonly configStatus: ContextExternalToolConfigurationStatus ) { super(); } @@ -23,13 +20,9 @@ export class ToolStatusNotLaunchableLoggableException extends UnprocessableEntit stack: this.stack, data: { userId: this.userId, - toolId: this.toolId, - isOutdatedOnScopeSchool: this.isOutdatedOnScopeSchool, - isOutdatedOnScopeContext: this.isOutdatedOnScopeContext, - isIncompleteOnScopeContext: this.isIncompleteOnScopeContext, - isIncompleteOperationalOnScopeContext: this.isIncompleteOperationalOnScopeContext, - isDeactivated: this.isDeactivated, - isNotLicensed: this.isNotLicensed, + contextExternalToolId: this.contextExternalTool.id, + schoolExternalToolId: this.contextExternalTool.schoolToolRef.schoolToolId, + status: { ...this.configStatus }, }, }; } diff --git a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts index 611e83e36f1..9a095fbf116 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.spec.ts @@ -1,7 +1,7 @@ +import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; import { ToolLaunchRequestResponse } from '../controller/dto'; import { LaunchRequestMethod, PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; import { ToolLaunchMapper } from './tool-launch.mapper'; -import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; describe('ToolLaunchMapper', () => { describe('mapToParameterLocation', () => { @@ -33,15 +33,17 @@ describe('ToolLaunchMapper', () => { url: 'url', openNewTab: true, payload: 'payload', + isDeepLink: false, }); const result: ToolLaunchRequestResponse = ToolLaunchMapper.mapToToolLaunchRequestResponse(toolLaunchRequest); - expect(result).toEqual({ + expect(result).toEqual({ method: toolLaunchRequest.method, url: toolLaunchRequest.url, payload: toolLaunchRequest.payload, openNewTab: toolLaunchRequest.openNewTab, + isDeepLink: toolLaunchRequest.isDeepLink, }); }); }); diff --git a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts index d3d8e060127..816509c4ff6 100644 --- a/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts +++ b/apps/server/src/modules/tool/tool-launch/mapper/tool-launch.mapper.ts @@ -1,6 +1,6 @@ -import { PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; -import { ToolLaunchRequestResponse } from '../controller/dto'; import { CustomParameterLocation, ToolConfigType } from '../../common/enum'; +import { ToolLaunchRequestResponse } from '../controller/dto'; +import { PropertyLocation, ToolLaunchDataType, ToolLaunchRequest } from '../types'; const customToParameterLocationMapping: Record = { [CustomParameterLocation.PATH]: PropertyLocation.PATH, @@ -14,37 +14,22 @@ const toolConfigTypeToToolLaunchDataTypeMapping: Record = { - [ToolLaunchDataType.BASIC]: ToolConfigType.BASIC, - [ToolLaunchDataType.LTI11]: ToolConfigType.LTI11, - [ToolLaunchDataType.OAUTH2]: ToolConfigType.OAUTH2, -}; - export class ToolLaunchMapper { static mapToParameterLocation(location: CustomParameterLocation): PropertyLocation { const mappedLocation = customToParameterLocationMapping[location]; + return mappedLocation; } static mapToToolLaunchDataType(configType: ToolConfigType): ToolLaunchDataType { const mappedType = toolConfigTypeToToolLaunchDataTypeMapping[configType]; - return mappedType; - } - static mapToToolConfigType(launchDataType: ToolLaunchDataType): ToolConfigType { - const mappedType = toolLaunchDataTypeToToolConfigTypeMapping[launchDataType]; return mappedType; } static mapToToolLaunchRequestResponse(toolLaunchRequest: ToolLaunchRequest): ToolLaunchRequestResponse { - const { method, url, payload, openNewTab } = toolLaunchRequest; - - const response = new ToolLaunchRequestResponse({ - method, - url, - payload, - openNewTab, - }); + const response = new ToolLaunchRequestResponse(toolLaunchRequest); + return response; } } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts index 2bb4a05d7b0..c2a049acef4 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts @@ -4,7 +4,12 @@ import { Injectable } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain/types'; import { CustomParameterEntry } from '../../../common/domain'; -import { CustomParameterLocation, CustomParameterScope, CustomParameterType } from '../../../common/enum'; +import { + CustomParameterLocation, + CustomParameterScope, + CustomParameterType, + ToolContextType, +} from '../../../common/enum'; import { ContextExternalTool } from '../../../context-external-tool/domain'; import { contextExternalToolFactory } from '../../../context-external-tool/testing'; import { ExternalTool } from '../../../external-tool/domain'; @@ -12,33 +17,24 @@ import { customParameterFactory, externalToolFactory } from '../../../external-t import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; import { MissingToolParameterValueLoggableException, ParameterTypeNotImplementedLoggableException } from '../../error'; -import { - LaunchRequestMethod, - PropertyData, - PropertyLocation, - ToolLaunchData, - ToolLaunchDataType, - ToolLaunchRequest, -} from '../../types'; +import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; const concreteConfigParameter: PropertyData = { - location: PropertyLocation.QUERY, + location: PropertyLocation.BODY, name: 'concreteParam', value: 'test', }; -const expectedPayload = 'payload'; - const launchMethod = LaunchRequestMethod.GET; @Injectable() @@ -49,13 +45,15 @@ class TestLaunchStrategy extends AbstractLaunchStrategy { // eslint-disable-next-line @typescript-eslint/no-unused-vars config: ToolLaunchParams ): Promise { + // should be implemented for further tests.. maybe use mapper for parameter to popertydata + // Implement this method with your own logic for the mock launch strategy return Promise.resolve([concreteConfigParameter]); } // eslint-disable-next-line @typescript-eslint/no-unused-vars public buildToolLaunchRequestPayload(url: string, properties: PropertyData[]): string { - return expectedPayload; + return JSON.stringify(properties); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -120,12 +118,9 @@ describe(AbstractLaunchStrategy.name, () => { await module.close(); }); - describe('createLaunchData', () => { + describe('createLaunchRequest', () => { describe('when parameters of every type are defined', () => { const setup = () => { - const schoolId: string = new ObjectId().toHexString(); - const mockedAutoValue = 'mockedAutoValue'; - // External Tool const globalCustomParameter = customParameterFactory.build({ scope: CustomParameterScope.GLOBAL, @@ -183,21 +178,30 @@ describe(AbstractLaunchStrategy.name, () => { type: CustomParameterType.AUTO_GROUP_EXTERNALUUID, }); - const externalTool: ExternalTool = externalToolFactory.build({ - parameters: [ - globalCustomParameter, - schoolCustomParameter, - contextCustomParameter, - autoSchoolIdCustomParameter, - autoSchoolNumberCustomParameter, - autoContextIdCustomParameter, - autoContextNameCustomParameter, - autoMediumIdCustomParameter, - autoGroupExternalUuidCustomParameter, - ], - }); + const mediumId = 'medium:xyz'; + const externalTool: ExternalTool = externalToolFactory + .withBasicConfig({ + baseUrl: 'https://www.basic-baseurl.com/:globalParam', + }) + .build({ + parameters: [ + globalCustomParameter, + schoolCustomParameter, + contextCustomParameter, + autoSchoolIdCustomParameter, + autoSchoolNumberCustomParameter, + autoContextIdCustomParameter, + autoContextNameCustomParameter, + autoMediumIdCustomParameter, + autoGroupExternalUuidCustomParameter, + ], + medium: { + mediumId, + }, + }); // School External Tool + const schoolId: string = new ObjectId().toHexString(); const schoolParameterEntry: CustomParameterEntry = new CustomParameterEntry({ name: schoolCustomParameter.name, value: 'true', @@ -208,12 +212,18 @@ describe(AbstractLaunchStrategy.name, () => { }); // Context External Tool + const contextId = new ObjectId().toHexString(); + const contextParameterValue = '123'; const contextParameterEntry: CustomParameterEntry = new CustomParameterEntry({ name: contextCustomParameter.name, - value: 'anyValue2', + value: contextParameterValue, }); const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ parameters: [contextParameterEntry], + contextRef: { + id: contextId, + type: ToolContextType.COURSE, + }, }); const sortFn = (a: PropertyData, b: PropertyData) => { @@ -226,16 +236,79 @@ describe(AbstractLaunchStrategy.name, () => { return 0; }; - autoSchoolIdStrategy.getValue.mockReturnValueOnce(mockedAutoValue); - autoSchoolNumberStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); - autoContextIdStrategy.getValue.mockReturnValueOnce(mockedAutoValue); - autoContextNameStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); - autoMediumIdStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); - autoGroupExternalUuidStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); + const schoolNumber = '12345'; + const contextName = 'Course XYZ'; + const groupExternalUuid = '21938124712984'; + + autoSchoolIdStrategy.getValue.mockReturnValueOnce(schoolId); + autoSchoolNumberStrategy.getValue.mockResolvedValueOnce(schoolNumber); + autoContextIdStrategy.getValue.mockReturnValueOnce(contextId); + autoContextNameStrategy.getValue.mockResolvedValueOnce(contextName); + autoMediumIdStrategy.getValue.mockResolvedValueOnce(mediumId); + autoGroupExternalUuidStrategy.getValue.mockResolvedValueOnce(groupExternalUuid); + + const expectedUrl = new URL(`https://www.basic-baseurl.com/${globalCustomParameter.default as string}`); + expectedUrl.searchParams.set(contextCustomParameter.name, contextParameterEntry.value as string); + expectedUrl.searchParams.set(autoMediumIdCustomParameter.name, mediumId); + expectedUrl.searchParams.set(autoGroupExternalUuidCustomParameter.name, groupExternalUuid); + + const expectedProperties: PropertyData[] = [ + { + name: globalCustomParameter.name, + value: globalCustomParameter.default as string, + location: PropertyLocation.PATH, + }, + { + name: schoolCustomParameter.name, + value: schoolParameterEntry.value as string, + location: PropertyLocation.BODY, + }, + { + name: contextParameterEntry.name, + value: contextParameterEntry.value as string, + location: PropertyLocation.QUERY, + }, + { + name: autoSchoolIdCustomParameter.name, + value: schoolId, + location: PropertyLocation.BODY, + }, + { + name: autoSchoolNumberCustomParameter.name, + value: schoolNumber, + location: PropertyLocation.BODY, + }, + { + name: autoContextIdCustomParameter.name, + value: contextId, + location: PropertyLocation.BODY, + }, + { + name: autoContextNameCustomParameter.name, + value: contextName, + location: PropertyLocation.BODY, + }, + { + name: autoMediumIdCustomParameter.name, + value: mediumId, + location: PropertyLocation.QUERY, + }, + { + name: autoGroupExternalUuidCustomParameter.name, + value: groupExternalUuid, + location: PropertyLocation.QUERY, + }, + { + location: concreteConfigParameter.location, + name: concreteConfigParameter.name, + value: concreteConfigParameter.value, + }, + ]; return { globalCustomParameter, schoolCustomParameter, + contextCustomParameter, autoSchoolIdCustomParameter, autoSchoolNumberCustomParameter, autoContextIdCustomParameter, @@ -247,93 +320,33 @@ describe(AbstractLaunchStrategy.name, () => { externalTool, schoolExternalTool, contextExternalTool, - mockedAutoValue, + schoolId, + schoolNumber, + contextId, + contextName, + mediumId, + groupExternalUuid, sortFn, + expectedUrl, + expectedProperties, }; }; - it('should return ToolLaunchData with merged parameters', async () => { - const { - globalCustomParameter, - schoolCustomParameter, - contextParameterEntry, - autoSchoolIdCustomParameter, - autoSchoolNumberCustomParameter, - autoContextIdCustomParameter, - autoContextNameCustomParameter, - autoMediumIdCustomParameter, - autoGroupExternalUuidCustomParameter, - schoolParameterEntry, - externalTool, - schoolExternalTool, - contextExternalTool, - mockedAutoValue, - sortFn, - } = setup(); + it('should return ToolLaunchRequest with merged parameters', async () => { + const { externalTool, schoolExternalTool, contextExternalTool, expectedUrl, expectedProperties } = setup(); - const result: ToolLaunchData = await strategy.createLaunchData('userId', { + const result: ToolLaunchRequest = await strategy.createLaunchRequest('userId', { externalTool, schoolExternalTool, contextExternalTool, }); - result.properties = result.properties.sort(sortFn); - expect(result).toEqual({ - baseUrl: externalTool.config.baseUrl, - type: ToolLaunchDataType.BASIC, + expect(result).toEqual({ + url: expectedUrl.toString(), + method: strategy.determineLaunchRequestMethod(expectedProperties), openNewTab: false, - properties: [ - { - name: globalCustomParameter.name, - value: globalCustomParameter.default as string, - location: PropertyLocation.PATH, - }, - { - name: schoolCustomParameter.name, - value: schoolParameterEntry.value as string, - location: PropertyLocation.BODY, - }, - { - name: contextParameterEntry.name, - value: contextParameterEntry.value as string, - location: PropertyLocation.QUERY, - }, - { - name: autoSchoolIdCustomParameter.name, - value: mockedAutoValue, - location: PropertyLocation.BODY, - }, - { - name: autoSchoolNumberCustomParameter.name, - value: mockedAutoValue, - location: PropertyLocation.BODY, - }, - { - name: autoContextIdCustomParameter.name, - value: mockedAutoValue, - location: PropertyLocation.BODY, - }, - { - name: autoContextNameCustomParameter.name, - value: mockedAutoValue, - location: PropertyLocation.BODY, - }, - { - name: autoMediumIdCustomParameter.name, - value: mockedAutoValue, - location: PropertyLocation.QUERY, - }, - { - name: autoGroupExternalUuidCustomParameter.name, - value: mockedAutoValue, - location: PropertyLocation.QUERY, - }, - { - name: concreteConfigParameter.name, - value: concreteConfigParameter.value, - location: concreteConfigParameter.location, - }, - ].sort(sortFn), + isDeepLink: false, + payload: strategy.buildToolLaunchRequestPayload(expectedUrl.toString(), expectedProperties), }); }); }); @@ -362,23 +375,18 @@ describe(AbstractLaunchStrategy.name, () => { it('should return a ToolLaunchData with no custom parameters', async () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); - const result: ToolLaunchData = await strategy.createLaunchData('userId', { + const result: ToolLaunchRequest = await strategy.createLaunchRequest('userId', { externalTool, schoolExternalTool, contextExternalTool, }); - expect(result).toEqual({ - baseUrl: externalTool.config.baseUrl, - type: ToolLaunchDataType.BASIC, + expect(result).toEqual({ + url: externalTool.config.baseUrl, + method: strategy.determineLaunchRequestMethod([concreteConfigParameter]), openNewTab: false, - properties: [ - { - name: concreteConfigParameter.name, - value: concreteConfigParameter.value, - location: concreteConfigParameter.location, - }, - ], + isDeepLink: false, + payload: strategy.buildToolLaunchRequestPayload(externalTool.config.baseUrl, [concreteConfigParameter]), }); }); }); @@ -416,7 +424,7 @@ describe(AbstractLaunchStrategy.name, () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); const func = async () => - strategy.createLaunchData('userId', { + strategy.createLaunchRequest('userId', { externalTool, schoolExternalTool, contextExternalTool, @@ -457,7 +465,7 @@ describe(AbstractLaunchStrategy.name, () => { const { externalTool, schoolExternalTool, contextExternalTool } = setup(); const func = async () => - strategy.createLaunchData('userId', { + strategy.createLaunchRequest('userId', { externalTool, schoolExternalTool, contextExternalTool, @@ -467,86 +475,4 @@ describe(AbstractLaunchStrategy.name, () => { }); }); }); - - describe('createLaunchRequest', () => { - const setup = () => { - const toolLaunchDataDO: ToolLaunchData = new ToolLaunchData({ - type: ToolLaunchDataType.BASIC, - baseUrl: 'https://www.basic-baseurl.com/pre/:pathParam/post', - properties: [], - openNewTab: false, - }); - - return { - toolLaunchDataDO, - }; - }; - - it('should create a LaunchRequest with the correct method, url and payload', () => { - const { toolLaunchDataDO } = setup(); - - const propertyData1 = new PropertyData({ - name: 'pathParam', - value: 'searchValue', - location: PropertyLocation.PATH, - }); - const propertyData2 = new PropertyData({ - name: 'test', - value: 'test', - location: PropertyLocation.QUERY, - }); - toolLaunchDataDO.properties = [propertyData1, propertyData2]; - - const result: ToolLaunchRequest = strategy.createLaunchRequest(toolLaunchDataDO); - - expect(result).toEqual({ - method: LaunchRequestMethod.GET, - url: `https://www.basic-baseurl.com/pre/${propertyData1.value}/post?${propertyData2.name}=${propertyData2.value}`, - payload: expectedPayload, - openNewTab: toolLaunchDataDO.openNewTab, - }); - }); - - it('should create a LaunchRequest with the correct payload when there are BODY properties', () => { - const { toolLaunchDataDO } = setup(); - - const bodyProperty1 = new PropertyData({ - name: 'key1', - value: 'value1', - location: PropertyLocation.BODY, - }); - const bodyProperty2 = new PropertyData({ - name: 'key2', - value: 'value2', - location: PropertyLocation.BODY, - }); - toolLaunchDataDO.properties = [bodyProperty1, bodyProperty2]; - - const result: ToolLaunchRequest = strategy.createLaunchRequest(toolLaunchDataDO); - - expect(result.payload).toEqual(expectedPayload); - }); - - it('should create a LaunchRequest with the correct url when there are PATH and QUERY properties', () => { - const { toolLaunchDataDO } = setup(); - - const pathProperty = new PropertyData({ - name: 'pathParam', - value: 'segmentValue', - location: PropertyLocation.PATH, - }); - const queryProperty = new PropertyData({ - name: 'queryParam', - value: 'queryValue', - location: PropertyLocation.QUERY, - }); - toolLaunchDataDO.properties = [pathProperty, queryProperty]; - - const result: ToolLaunchRequest = strategy.createLaunchRequest(toolLaunchDataDO); - - expect(result.url).toEqual( - `https://www.basic-baseurl.com/pre/${pathProperty.value}/post?${queryProperty.name}=${queryProperty.value}` - ); - }); - }); }); 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 ca5f5535571..7d5b413a9f7 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 @@ -43,7 +43,16 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { ]); } - public async createLaunchData(userId: EntityId, data: ToolLaunchParams): Promise { + public abstract buildToolLaunchDataFromConcreteConfig( + userId: EntityId, + config: ToolLaunchParams + ): Promise; + + public abstract buildToolLaunchRequestPayload(url: string, properties: PropertyData[]): string | null; + + public abstract determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod; + + public async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { const launchData: ToolLaunchData = this.buildToolLaunchDataFromExternalTool(data.externalTool); const launchDataProperties: PropertyData[] = await this.buildToolLaunchDataFromTools(data); @@ -55,28 +64,16 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { launchData.properties.push(...launchDataProperties); launchData.properties.push(...additionalLaunchDataProperties); - return launchData; - } - - public abstract buildToolLaunchDataFromConcreteConfig( - userId: EntityId, - config: ToolLaunchParams - ): Promise; - - public abstract buildToolLaunchRequestPayload(url: string, properties: PropertyData[]): string | null; - - public abstract determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod; - - public createLaunchRequest(toolLaunchData: ToolLaunchData): ToolLaunchRequest { - const requestMethod: LaunchRequestMethod = this.determineLaunchRequestMethod(toolLaunchData.properties); - const url: string = this.buildUrl(toolLaunchData); - const payload: string | null = this.buildToolLaunchRequestPayload(url, toolLaunchData.properties); + const requestMethod: LaunchRequestMethod = this.determineLaunchRequestMethod(launchData.properties); + const url: string = this.buildUrl(launchData); + const payload: string | null = this.buildToolLaunchRequestPayload(url, launchData.properties); const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ method: requestMethod, url, payload: payload ?? undefined, - openNewTab: toolLaunchData.openNewTab, + openNewTab: launchData.openNewTab, + isDeepLink: false, }); return toolLaunchRequest; 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 74b45a6024d..941b2680ec5 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 { Configuration } from '@hpi-schul-cloud/commons/lib'; import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; import { PseudonymService } from '@modules/pseudonym/service'; @@ -10,27 +11,27 @@ import { RoleName } from '@shared/domain/interface'; import { userDoFactory } from '@shared/testing'; import { pseudonymFactory } from '@shared/testing/factory/domainobject/pseudonym.factory'; import { Authorization } from 'oauth-1.0a'; -import { LtiMessageType, LtiPrivacyPermission, LtiRole } from '../../../common/enum'; +import { LtiMessageType, LtiPrivacyPermission, LtiRole, ToolContextType } from '../../../common/enum'; import { ContextExternalTool } from '../../../context-external-tool/domain'; import { contextExternalToolFactory } from '../../../context-external-tool/testing'; import { ExternalTool } from '../../../external-tool/domain'; import { externalToolFactory } from '../../../external-tool/testing'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; import { schoolExternalToolFactory } from '../../../school-external-tool/testing'; -import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; +import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchRequest } from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; -describe('Lti11ToolLaunchStrategy', () => { +describe(Lti11ToolLaunchStrategy.name, () => { let module: TestingModule; let strategy: Lti11ToolLaunchStrategy; @@ -103,7 +104,7 @@ describe('Lti11ToolLaunchStrategy', () => { }); describe('buildToolLaunchDataFromConcreteConfig', () => { - describe('when building the launch data', () => { + describe('when building the launch data for the encryption', () => { const setup = () => { const mockKey = 'mockKey'; const mockSecret = 'mockSecret'; @@ -118,9 +119,9 @@ describe('Lti11ToolLaunchStrategy', () => { privacy_permission: LtiPrivacyPermission.PUBLIC, launch_presentation_locale: launchPresentationLocale, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -169,34 +170,6 @@ describe('Lti11ToolLaunchStrategy', () => { ]) ); }); - - it('should contain mandatory lti attributes', async () => { - const { data, ltiMessageType, contextExternalTool, launchPresentationLocale } = setup(); - - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); - - expect(result).toEqual( - expect.arrayContaining([ - new PropertyData({ name: 'lti_message_type', value: ltiMessageType, location: PropertyLocation.BODY }), - new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), - new PropertyData({ - name: 'resource_link_id', - value: contextExternalTool.id, - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'launch_presentation_document_target', - value: 'window', - location: PropertyLocation.BODY, - }), - new PropertyData({ - name: 'launch_presentation_locale', - value: launchPresentationLocale, - location: PropertyLocation.BODY, - }), - ]) - ); - }); }); describe('when lti privacyPermission is public', () => { @@ -208,9 +181,9 @@ describe('Lti11ToolLaunchStrategy', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.PUBLIC, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -250,7 +223,7 @@ describe('Lti11ToolLaunchStrategy', () => { }; }; - it('should contain the all user related attributes', async () => { + it('should contain all user related attributes', async () => { const { data, userId, userDisplayName, userEmail } = setup(); const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); @@ -283,9 +256,9 @@ describe('Lti11ToolLaunchStrategy', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.NAME, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -353,9 +326,9 @@ describe('Lti11ToolLaunchStrategy', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.EMAIL, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -424,9 +397,9 @@ describe('Lti11ToolLaunchStrategy', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -491,9 +464,9 @@ describe('Lti11ToolLaunchStrategy', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -539,9 +512,9 @@ describe('Lti11ToolLaunchStrategy', () => { describe('when tool config is not lti', () => { const setup = () => { - const externalTool: ExternalTool = externalToolFactory.buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + const externalTool: ExternalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); const data: ToolLaunchParams = { contextExternalTool, @@ -576,12 +549,15 @@ describe('Lti11ToolLaunchStrategy', () => { lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, }) - .buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const pseudoContextExternalTool = { + ...contextExternalToolFactory.build().getProps(), + id: undefined, + }; const data: ToolLaunchParams = { - contextExternalTool, + contextExternalTool: pseudoContextExternalTool, schoolExternalTool, externalTool, }; @@ -616,6 +592,191 @@ describe('Lti11ToolLaunchStrategy', () => { ); }); }); + + describe('when lti messageType is content item selection request', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId( + { + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); + + const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; + const callbackUrl = `${publicBackendUrl}/v3/tools/context-external-tools/${contextExternalTool.id}/lti11-deep-link-callback`; + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + callbackUrl, + }; + }; + + it('should contain all user related attributes', async () => { + const { data, userId, callbackUrl } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); + + expect(result).toEqual( + expect.arrayContaining([ + new PropertyData({ name: 'key', value: 'mockKey' }), + new PropertyData({ name: 'secret', value: 'decryptedSecret' }), + new PropertyData({ + name: 'lti_message_type', + value: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), + new PropertyData({ + name: 'resource_link_id', + value: data.contextExternalTool.id as string, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'launch_presentation_document_target', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + location: PropertyLocation.BODY, + name: 'launch_presentation_locale', + value: 'de-DE', + }), + new PropertyData({ + name: 'content_item_return_url', + value: callbackUrl, + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_media_types', + value: '*/*', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_presentation_document_targets', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_unsigned', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_multiple', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_copy_advice', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'auto_create', + value: 'true', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'data', + value: expect.any(String), + location: PropertyLocation.BODY, + }), + ]) + ); + }); + }); + + describe('when a content item selection request is made without a permanent tool', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const pseudoContextExternalTool = { + ...contextExternalToolFactory.build().getProps(), + id: undefined, + }; + + const data: ToolLaunchParams = { + contextExternalTool: pseudoContextExternalTool, + schoolExternalTool, + externalTool, + }; + + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId( + { + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + data, + userId, + }; + }; + + it('should throw an error', async () => { + const { data, userId } = setup(); + + await expect(() => strategy.buildToolLaunchDataFromConcreteConfig(userId, data)).rejects.toThrow( + new UnprocessableEntityException( + 'Cannot lauch a content selection request with a non-permanent context external tool' + ) + ); + }); + }); }); describe('buildToolLaunchRequestPayload', () => { @@ -744,4 +905,182 @@ describe('Lti11ToolLaunchStrategy', () => { expect(result).toEqual(LaunchRequestMethod.POST); }); }); + + describe('createLaunchRequest', () => { + describe('when lti message type is content item selection request', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); + + const externalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .build(); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); + + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); + + const signedPayload: Authorization = { + oauth_consumer_key: 'mockKey', + oauth_nonce: 'nonce', + oauth_signature: 'signature', + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: 1, + oauth_version: '1.0', + [property1.name]: property1.value, + [property2.name]: property2.value, + }; + + const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: true, + isDeepLink: true, + }); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + toolLaunchRequest, + data, + userId, + }; + }; + + it('should create a LaunchRequest with the correct method, url and payload', async () => { + const { toolLaunchRequest, data, userId } = setup(); + + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); + + expect(result).toEqual(toolLaunchRequest); + }); + }); + + describe('when lti message type is not content item selection request and no deeplink', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const user: UserDO = userDoFactory.buildWithId({ id: userId }); + + const mockKey = 'mockKey'; + const mockSecret = 'mockSecret'; + const ltiMessageType = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; + const launchPresentationLocale = 'de-DE'; + + const externalTool = externalToolFactory + .withLti11Config({ + key: mockKey, + secret: mockSecret, + lti_message_type: ltiMessageType, + privacy_permission: LtiPrivacyPermission.PUBLIC, + launch_presentation_locale: launchPresentationLocale, + }) + .build(); + + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + + const contextExternalToolId = 'contextExternalToolId'; + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + id: contextExternalToolId, + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + contextRef: { + type: ToolContextType.COURSE, + }, + }); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const property1: PropertyData = new PropertyData({ + name: 'param1', + value: 'value1', + location: PropertyLocation.BODY, + }); + + const property2: PropertyData = new PropertyData({ + name: 'param2', + value: 'value2', + location: PropertyLocation.BODY, + }); + + const signedPayload: Authorization = { + oauth_consumer_key: 'mockKey', + oauth_nonce: 'nonce', + oauth_signature: 'signature', + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: 1, + oauth_version: '1.0', + [property1.name]: property1.value, + [property2.name]: property2.value, + }; + + const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ + method: LaunchRequestMethod.POST, + url: 'https://www.lti11-baseurl.com/', + payload: JSON.stringify(signedPayload), + openNewTab: false, + isDeepLink: false, + }); + + userService.findById.mockResolvedValue(user); + const decrypted = 'decryptedSecret'; + encryptionService.decrypt.mockReturnValue(decrypted); + + return { + toolLaunchRequest, + data, + userId, + }; + }; + + it('should create a LaunchRequest with the correct method, url and payload', async () => { + const { toolLaunchRequest, data, userId } = setup(); + + const result: ToolLaunchRequest = await strategy.createLaunchRequest(userId, data); + + expect(result).toEqual(toolLaunchRequest); + }); + }); + }); }); 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 c0977743e54..912c25980d4 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 { Configuration } from '@hpi-schul-cloud/commons/lib'; import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { ObjectId } from '@mikro-orm/mongodb'; import { PseudonymService } from '@modules/pseudonym/service'; @@ -6,18 +7,25 @@ import { Inject, Injectable, InternalServerErrorException, UnprocessableEntityEx import { Pseudonym, RoleReference, UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { UUID } from 'bson'; import { Authorization } from 'oauth-1.0a'; -import { LtiPrivacyPermission, LtiRole } from '../../../common/enum'; -import { ExternalTool } from '../../../external-tool/domain'; +import { LtiMessageType, LtiPrivacyPermission, LtiRole } from '../../../common/enum'; +import { ExternalTool, Lti11ToolConfig } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; -import { AuthenticationValues, LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; +import { + AuthenticationValues, + LaunchRequestMethod, + PropertyData, + PropertyLocation, + ToolLaunchRequest, +} from '../../types'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; @@ -60,6 +68,100 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { ); } + let properties: PropertyData[]; + if (config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST) { + properties = await this.buildToolLaunchDataForContentItemSelectionRequest(userId, data, config); + } else { + properties = await this.buildToolLaunchDataForLtiLaunch( + userId, + data, + config, + LtiMessageType.BASIC_LTI_LAUNCH_REQUEST + ); + } + + return properties; + } + + private async buildToolLaunchDataForContentItemSelectionRequest( + userId: EntityId, + data: ToolLaunchParams, + config: Lti11ToolConfig + ): Promise { + if (!data.contextExternalTool.id) { + throw new UnprocessableEntityException( + 'Cannot lauch a content selection request with a non-permanent context external tool' + ); + } + + const additionalProperties: PropertyData[] = await this.buildToolLaunchDataForLtiLaunch( + userId, + data, + config, + LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST + ); + + const publicBackendUrl = Configuration.get('PUBLIC_BACKEND_URL') as string; + const callbackUrl = new URL( + `${publicBackendUrl}/v3/tools/context-external-tools/${data.contextExternalTool.id}/lti11-deep-link-callback` + ); + + const state = new UUID().toString(); + + additionalProperties.push( + new PropertyData({ + name: 'content_item_return_url', + value: callbackUrl.toString(), + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_media_types', + value: '*/*', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_presentation_document_targets', + value: 'window', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_unsigned', + value: 'false', + location: PropertyLocation.BODY, + }) + ); + additionalProperties.push( + new PropertyData({ + name: 'accept_multiple', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'accept_copy_advice', + value: 'false', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'auto_create', + value: 'true', + location: PropertyLocation.BODY, + }), + new PropertyData({ + name: 'data', + value: state, + location: PropertyLocation.BODY, + }) + ); + + return additionalProperties; + } + + private async buildToolLaunchDataForLtiLaunch( + userId: EntityId, + data: ToolLaunchParams, + config: Lti11ToolConfig, + lti_message_type: LtiMessageType + ): Promise { const user: UserDO = await this.userService.findById(userId); const roleNames: RoleName[] = user.roles.map((roleRef: RoleReference): RoleName => roleRef.name); @@ -71,7 +173,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { new PropertyData({ name: 'key', value: config.key }), new PropertyData({ name: 'secret', value: decrypted }), - new PropertyData({ name: 'lti_message_type', value: config.lti_message_type, location: PropertyLocation.BODY }), + new PropertyData({ name: 'lti_message_type', value: 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({ @@ -199,4 +301,18 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { public override determineLaunchRequestMethod(properties: PropertyData[]): LaunchRequestMethod { return LaunchRequestMethod.POST; } + + public override async createLaunchRequest(userId: EntityId, data: ToolLaunchParams): Promise { + const request: ToolLaunchRequest = await super.createLaunchRequest(userId, data); + + if ( + ExternalTool.isLti11Config(data.externalTool.config) && + data.externalTool.config.lti_message_type === LtiMessageType.CONTENT_ITEM_SELECTION_REQUEST + ) { + request.openNewTab = true; + request.isDeepLink = true; + } + + return request; + } } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-strategy.interface.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-strategy.interface.ts index ea6232098f9..e7b28b04537 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-strategy.interface.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/tool-launch-strategy.interface.ts @@ -1,9 +1,7 @@ import { EntityId } from '@shared/domain/types'; -import { ToolLaunchData, ToolLaunchRequest } from '../../types'; +import { ToolLaunchRequest } from '../../types'; import { ToolLaunchParams } from './tool-launch-params.interface'; export interface ToolLaunchStrategy { - createLaunchData(userId: EntityId, params: ToolLaunchParams): Promise; - - createLaunchRequest(toolLaunchDataDO: ToolLaunchData): ToolLaunchRequest; + createLaunchRequest(userId: EntityId, params: ToolLaunchParams): Promise; } diff --git a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts index 54eea73c539..aa333f1c186 100644 --- a/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/tool-launch.service.spec.ts @@ -1,21 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { EntityId } from '@shared/domain/types'; import { ToolConfigType } from '../../common/enum'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { ToolConfigurationStatusService } from '../../context-external-tool/service'; import { contextExternalToolFactory } from '../../context-external-tool/testing'; import { ExternalToolService } from '../../external-tool'; -import { BasicToolConfig, ExternalTool } from '../../external-tool/domain'; -import { - basicToolConfigFactory, - externalToolFactory, - toolConfigurationStatusFactory, -} from '../../external-tool/testing'; +import { externalToolFactory } from '../../external-tool/testing'; import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ToolStatusNotLaunchableLoggableException } from '../error'; -import { LaunchRequestMethod, ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; +import { LaunchRequestMethod, ToolLaunchRequest } from '../types'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, @@ -24,14 +21,17 @@ import { } from './launch-strategy'; import { ToolLaunchService } from './tool-launch.service'; -describe('ToolLaunchService', () => { +describe(ToolLaunchService.name, () => { let module: TestingModule; let service: ToolLaunchService; - let schoolExternalToolService: DeepMocked; - let externalToolService: DeepMocked; let basicToolLaunchStrategy: DeepMocked; + let lti11ToolLaunchStrategy: DeepMocked; + let oauth2ToolLaunchStrategy: DeepMocked; + let toolConfigurationStatusService: DeepMocked; + let schoolExternalToolService: DeepMocked; + let externalToolService: DeepMocked; beforeEach(async () => { module = await Test.createTestingModule({ @@ -68,6 +68,9 @@ describe('ToolLaunchService', () => { schoolExternalToolService = module.get(SchoolExternalToolService); externalToolService = module.get(ExternalToolService); basicToolLaunchStrategy = module.get(BasicToolLaunchStrategy); + lti11ToolLaunchStrategy = module.get(Lti11ToolLaunchStrategy); + oauth2ToolLaunchStrategy = module.get(OAuth2ToolLaunchStrategy); + toolConfigurationStatusService = module.get(ToolConfigurationStatusService); }); @@ -79,230 +82,278 @@ describe('ToolLaunchService', () => { jest.clearAllMocks(); }); - describe('getLaunchData', () => { - describe('when the tool config type is BASIC', () => { + describe('generateLaunchRequest', () => { + describe('when the tool type is basic', () => { const setup = () => { - const schoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef(schoolExternalTool.id) - .build(); - const basicToolConfigDO: BasicToolConfig = basicToolConfigFactory.build(); - const externalTool: ExternalTool = externalToolFactory.build({ - config: basicToolConfigDO, + const userId: string = new ObjectId().toHexString(); + const externalTool = externalToolFactory.withBasicConfig().build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, }); - const launchDataDO: ToolLaunchData = new ToolLaunchData({ - type: ToolLaunchDataType.BASIC, - baseUrl: 'https://www.basic-baseurl.com/', - properties: [], + const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ + url: 'https://example.com/tool-launch', + method: LaunchRequestMethod.GET, + payload: '{ "key": "value" }', openNewTab: false, + isDeepLink: true, + }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce({ + isDeactivated: false, + isNotLicensed: false, + isIncompleteOnScopeContext: false, + isIncompleteOperationalOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: false, }); + basicToolLaunchStrategy.createLaunchRequest.mockResolvedValueOnce(expectedLaunchRequest); - const launchParams: ToolLaunchParams = { + return { + userId, + expectedLaunchRequest, externalTool, schoolExternalTool, contextExternalTool, }; - - schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); - externalToolService.findById.mockResolvedValue(externalTool); - basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); - toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: false, - isOutdatedOnScopeSchool: false, - isIncompleteOnScopeContext: false, - }) - ); - - return { - launchDataDO, - launchParams, - }; }; - it('should return launchData', async () => { - const { launchParams, launchDataDO } = setup(); + it('should use the basic strategy', async () => { + const { userId, externalTool, schoolExternalTool, contextExternalTool } = setup(); - const result: ToolLaunchData = await service.getLaunchData('userId', launchParams.contextExternalTool); + await service.generateLaunchRequest(userId, contextExternalTool); - expect(result).toEqual(launchDataDO); + expect(basicToolLaunchStrategy.createLaunchRequest).toHaveBeenCalledWith<[EntityId, ToolLaunchParams]>(userId, { + externalTool, + schoolExternalTool, + contextExternalTool, + }); }); - it('should call basicToolLaunchStrategy with given launchParams ', async () => { - const { launchParams } = setup(); + it('should generate launch request ', async () => { + const { userId, contextExternalTool, expectedLaunchRequest } = setup(); - await service.getLaunchData('userId', launchParams.contextExternalTool); + const result: ToolLaunchRequest = await service.generateLaunchRequest(userId, contextExternalTool); - expect(basicToolLaunchStrategy.createLaunchData).toHaveBeenCalledWith('userId', launchParams); + expect(result).toEqual(expectedLaunchRequest); }); + }); - it('should call getSchoolExternalToolById', async () => { - const { launchParams } = setup(); + describe('when the tool type is lti11', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const externalTool = externalToolFactory.withLti11Config().build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + }); - await service.getLaunchData('userId', launchParams.contextExternalTool); + const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ + url: 'https://example.com/tool-launch', + method: LaunchRequestMethod.GET, + payload: '{ "key": "value" }', + openNewTab: false, + isDeepLink: true, + }); - expect(schoolExternalToolService.findById).toHaveBeenCalledWith(launchParams.schoolExternalTool.id); - }); + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce({ + isDeactivated: false, + isNotLicensed: false, + isIncompleteOnScopeContext: false, + isIncompleteOperationalOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: false, + }); + lti11ToolLaunchStrategy.createLaunchRequest.mockResolvedValueOnce(expectedLaunchRequest); - it('should call findExternalToolById', async () => { - const { launchParams } = setup(); + return { + userId, + expectedLaunchRequest, + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should use the lti11 strategy', async () => { + const { userId, externalTool, schoolExternalTool, contextExternalTool } = setup(); - await service.getLaunchData('userId', launchParams.contextExternalTool); + await service.generateLaunchRequest(userId, contextExternalTool); - expect(externalToolService.findById).toHaveBeenCalledWith(launchParams.schoolExternalTool.toolId); + expect(lti11ToolLaunchStrategy.createLaunchRequest).toHaveBeenCalledWith<[EntityId, ToolLaunchParams]>(userId, { + externalTool, + schoolExternalTool, + contextExternalTool, + }); }); - it('should call toolConfigurationStatusService', async () => { - const { launchParams } = setup(); + it('should generate launch request ', async () => { + const { userId, contextExternalTool, expectedLaunchRequest } = setup(); - await service.getLaunchData('userId', launchParams.contextExternalTool); + const result: ToolLaunchRequest = await service.generateLaunchRequest(userId, contextExternalTool); - expect(toolConfigurationStatusService.determineToolConfigurationStatus).toHaveBeenCalled(); + expect(result).toEqual(expectedLaunchRequest); }); }); - describe('when the tool config type is unknown', () => { + describe('when the tool type is oauth2', () => { const setup = () => { - const schoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef(schoolExternalTool.id) - .build(); - const externalTool: ExternalTool = externalToolFactory.build(); - externalTool.config.type = 'unknown' as ToolConfigType; + const userId: string = new ObjectId().toHexString(); + const externalTool = externalToolFactory.withOauth2Config().build(); + const schoolExternalTool = schoolExternalToolFactory.build({ toolId: externalTool.id }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + }); + + const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ + url: 'https://example.com/tool-launch', + method: LaunchRequestMethod.GET, + payload: '{ "key": "value" }', + openNewTab: false, + isDeepLink: true, + }); - const launchParams: ToolLaunchParams = { + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce({ + isDeactivated: false, + isNotLicensed: false, + isIncompleteOnScopeContext: false, + isIncompleteOperationalOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: false, + }); + oauth2ToolLaunchStrategy.createLaunchRequest.mockResolvedValueOnce(expectedLaunchRequest); + + return { + userId, + expectedLaunchRequest, externalTool, schoolExternalTool, contextExternalTool, }; + }; - schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); - externalToolService.findById.mockResolvedValue(externalTool); - toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: false, - isOutdatedOnScopeSchool: false, - isIncompleteOnScopeContext: false, - }) + it('should use the oauth2 strategy', async () => { + const { userId, externalTool, schoolExternalTool, contextExternalTool } = setup(); + + await service.generateLaunchRequest(userId, contextExternalTool); + + expect(oauth2ToolLaunchStrategy.createLaunchRequest).toHaveBeenCalledWith<[EntityId, ToolLaunchParams]>( + userId, + { + externalTool, + schoolExternalTool, + contextExternalTool, + } ); + }); - return { - launchParams, - }; - }; + it('should generate launch request ', async () => { + const { userId, contextExternalTool, expectedLaunchRequest } = setup(); - it('should throw InternalServerErrorException for unknown tool config type', async () => { - const { launchParams } = setup(); + const result: ToolLaunchRequest = await service.generateLaunchRequest(userId, contextExternalTool); - await expect(service.getLaunchData('userId', launchParams.contextExternalTool)).rejects.toThrow( - new InternalServerErrorException('Unknown tool config type') - ); + expect(result).toEqual(expectedLaunchRequest); }); }); - describe('when tool configuration status is not launchable', () => { + describe('when the tool type is unknown', () => { const setup = () => { - const schoolExternalTool = schoolExternalToolFactory.buildWithId(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory - .withSchoolExternalToolRef(schoolExternalTool.id) - .build(); - const basicToolConfigDO: BasicToolConfig = basicToolConfigFactory.build(); - const externalTool: ExternalTool = externalToolFactory.build({ - config: basicToolConfigDO, + const userId: string = new ObjectId().toHexString(); + const externalTool = externalToolFactory.build(); + externalTool.config.type = 'unknown' as ToolConfigType; + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, }); - const launchDataDO: ToolLaunchData = new ToolLaunchData({ - type: ToolLaunchDataType.BASIC, - baseUrl: 'https://www.basic-baseurl.com/', - properties: [], - openNewTab: false, + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + externalToolService.findById.mockResolvedValueOnce(externalTool); + toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce({ + isDeactivated: false, + isNotLicensed: false, + isIncompleteOnScopeContext: false, + isIncompleteOperationalOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: false, }); - const launchParams: ToolLaunchParams = { - externalTool, - schoolExternalTool, + return { contextExternalTool, + userId, }; + }; - const userId = 'userId'; + it('should throw InternalServerErrorException', async () => { + const { userId, contextExternalTool } = setup(); + + await expect(() => service.generateLaunchRequest(userId, contextExternalTool)).rejects.toThrow( + new InternalServerErrorException('Unknown tool launch data type') + ); + }); + }); + + describe.each([ + { isDeactivated: true }, + { isNotLicensed: true }, + { isIncompleteOnScopeContext: true }, + { isOutdatedOnScopeSchool: true }, + { isOutdatedOnScopeContext: true }, + ])('when the tool status is %o', (status) => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool = schoolExternalToolFactory.buildWithId({ toolId: externalTool.id }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ + schoolToolRef: { + schoolToolId: schoolExternalTool.id, + schoolId: schoolExternalTool.schoolId, + }, + }); schoolExternalToolService.findById.mockResolvedValue(schoolExternalTool); externalToolService.findById.mockResolvedValue(externalTool); - basicToolLaunchStrategy.createLaunchData.mockResolvedValue(launchDataDO); - toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValueOnce( - toolConfigurationStatusFactory.build({ - isOutdatedOnScopeContext: true, - isOutdatedOnScopeSchool: true, - isIncompleteOnScopeContext: false, - isIncompleteOperationalOnScopeContext: false, - isDeactivated: true, - isNotLicensed: true, - }) - ); + toolConfigurationStatusService.determineToolConfigurationStatus.mockResolvedValue({ + isDeactivated: false, + isNotLicensed: false, + isIncompleteOnScopeContext: false, + isIncompleteOperationalOnScopeContext: false, + isOutdatedOnScopeSchool: false, + isOutdatedOnScopeContext: false, + ...status, + }); return { - launchParams, + contextExternalTool, userId, - contextExternalToolId: contextExternalTool.id, }; }; it('should throw ToolStatusNotLaunchableLoggableException', async () => { - const { launchParams, userId, contextExternalToolId } = setup(); - - const func = () => service.getLaunchData(userId, launchParams.contextExternalTool); - - await expect(func).rejects.toThrow( - new ToolStatusNotLaunchableLoggableException( - userId, - contextExternalToolId, - true, - true, - false, - false, - true, - true - ) - ); - }); - }); - }); + const { userId, contextExternalTool } = setup(); - describe('generateLaunchRequest', () => { - it('should generate launch request for BASIC type', () => { - const toolLaunchDataDO: ToolLaunchData = new ToolLaunchData({ - type: ToolLaunchDataType.BASIC, - baseUrl: 'https://www.basic-baseurl.com/', - properties: [], - openNewTab: false, - }); - - const expectedLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ - method: LaunchRequestMethod.GET, - url: 'https://example.com/tool-launch', - payload: '{ "key": "value" }', - openNewTab: false, - }); - basicToolLaunchStrategy.createLaunchRequest.mockReturnValue(expectedLaunchRequest); - - const result: ToolLaunchRequest = service.generateLaunchRequest(toolLaunchDataDO); - - expect(result).toEqual(expectedLaunchRequest); - expect(basicToolLaunchStrategy.createLaunchRequest).toHaveBeenCalledWith(toolLaunchDataDO); - }); - - it('should throw InternalServerErrorException for unknown type', () => { - const toolLaunchDataDO: ToolLaunchData = new ToolLaunchData({ - type: 'unknown' as ToolLaunchDataType, - baseUrl: 'https://www.basic-baseurl.com/', - properties: [], - openNewTab: false, + await expect(() => service.generateLaunchRequest(userId, contextExternalTool)).rejects.toThrow( + ToolStatusNotLaunchableLoggableException + ); }); - - const func = () => service.generateLaunchRequest(toolLaunchDataDO); - - expect(() => func()).toThrow(new InternalServerErrorException('Unknown tool launch data type')); }); }); }); 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 4466ac4c12f..a4d93a6ce9f 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 @@ -9,8 +9,7 @@ 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'; +import { ToolLaunchRequest } from '../types'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, @@ -25,10 +24,10 @@ export class ToolLaunchService { constructor( private readonly schoolExternalToolService: SchoolExternalToolService, private readonly externalToolService: ExternalToolService, - private readonly basicToolLaunchStrategy: BasicToolLaunchStrategy, - private readonly lti11ToolLaunchStrategy: Lti11ToolLaunchStrategy, - private readonly oauth2ToolLaunchStrategy: OAuth2ToolLaunchStrategy, - private readonly toolConfigurationStatusService: ToolConfigurationStatusService + private readonly toolConfigurationStatusService: ToolConfigurationStatusService, + readonly basicToolLaunchStrategy: BasicToolLaunchStrategy, + readonly lti11ToolLaunchStrategy: Lti11ToolLaunchStrategy, + readonly oauth2ToolLaunchStrategy: OAuth2ToolLaunchStrategy ) { this.strategies = new Map(); this.strategies.set(ToolConfigType.BASIC, basicToolLaunchStrategy); @@ -36,58 +35,32 @@ export class ToolLaunchService { this.strategies.set(ToolConfigType.OAUTH2, oauth2ToolLaunchStrategy); } - public generateLaunchRequest(toolLaunchData: ToolLaunchData): ToolLaunchRequest { - const toolConfigType: ToolConfigType = ToolLaunchMapper.mapToToolConfigType(toolLaunchData.type); - const strategy: ToolLaunchStrategy | undefined = this.strategies.get(toolConfigType); - - if (!strategy) { - throw new InternalServerErrorException('Unknown tool launch data type'); - } - - const launchRequest: ToolLaunchRequest = strategy.createLaunchRequest(toolLaunchData); - - return launchRequest; - } - - public async getLaunchData( + async generateLaunchRequest( userId: EntityId, contextExternalTool: ContextExternalToolLaunchable - ): Promise { + ): Promise { const schoolExternalToolId: EntityId = contextExternalTool.schoolToolRef.schoolToolId; + const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); - const { externalTool, schoolExternalTool } = await this.loadToolHierarchy(schoolExternalToolId); - - await this.isToolStatusLaunchableOrThrow(userId, externalTool, schoolExternalTool, contextExternalTool); + await this.checkToolStatus(userId, externalTool, schoolExternalTool, contextExternalTool); const strategy: ToolLaunchStrategy | undefined = this.strategies.get(externalTool.config.type); if (!strategy) { - throw new InternalServerErrorException('Unknown tool config type'); + throw new InternalServerErrorException('Unknown tool launch data type'); } - const launchData: ToolLaunchData = await strategy.createLaunchData(userId, { + const launchRequest: ToolLaunchRequest = await strategy.createLaunchRequest(userId, { externalTool, schoolExternalTool, contextExternalTool, }); - return launchData; - } - - public async loadToolHierarchy( - schoolExternalToolId: string - ): Promise<{ schoolExternalTool: SchoolExternalTool; externalTool: ExternalTool }> { - const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); - - const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); - - return { - schoolExternalTool, - externalTool, - }; + return launchRequest; } - private async isToolStatusLaunchableOrThrow( + private async checkToolStatus( userId: EntityId, externalTool: ExternalTool, schoolExternalTool: SchoolExternalTool, @@ -108,16 +81,7 @@ export class ToolLaunchService { status.isNotLicensed || status.isIncompleteOnScopeContext ) { - throw new ToolStatusNotLaunchableLoggableException( - userId, - contextExternalTool.id ?? '', - status.isOutdatedOnScopeSchool, - status.isOutdatedOnScopeContext, - status.isIncompleteOnScopeContext, - status.isIncompleteOperationalOnScopeContext, - status.isDeactivated, - status.isNotLicensed - ); + throw new ToolStatusNotLaunchableLoggableException(userId, contextExternalTool, status); } } } diff --git a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts index 2644dc7f5fc..d58f0ad2cca 100644 --- a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts +++ b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts @@ -1,10 +1,10 @@ import { EncryptionModule } from '@infra/encryption'; import { BoardModule } from '@modules/board'; +import { GroupModule } from '@modules/group'; import { LearnroomModule } from '@modules/learnroom'; import { LegacySchoolModule } from '@modules/legacy-school'; import { PseudonymModule } from '@modules/pseudonym'; import { UserModule } from '@modules/user'; -import { GroupModule } from '@modules/group'; import { forwardRef, Module } from '@nestjs/common'; import { CommonToolModule } from '../common'; import { ContextExternalToolModule } from '../context-external-tool'; @@ -14,10 +14,10 @@ import { Lti11EncryptionService, ToolLaunchService } from './service'; import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoGroupExternalUuidStrategy, AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, - AutoGroupExternalUuidStrategy, } from './service/auto-parameter-strategy'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy } from './service/launch-strategy'; diff --git a/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts b/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts index 450951904ce..9b7d34a130d 100644 --- a/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts +++ b/apps/server/src/modules/tool/tool-launch/types/tool-launch-request.ts @@ -9,10 +9,13 @@ export class ToolLaunchRequest { openNewTab: boolean; + isDeepLink: boolean; + constructor(props: ToolLaunchRequest) { this.url = props.url; this.method = props.method; this.payload = props.payload; this.openNewTab = props.openNewTab; + this.isDeepLink = props.isDeepLink; } } 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 671c6ad8b7a..a8b2e81e942 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,12 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; import { LaunchContextUnavailableLoggableException } from '@modules/tool/tool-launch/error'; import { Test, TestingModule } from '@nestjs/testing'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { setupEntities, userFactory } from '@shared/testing'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool, ContextExternalToolLaunchable } from '../../context-external-tool/domain'; @@ -15,7 +15,7 @@ import { contextExternalToolFactory } from '../../context-external-tool/testing' import { SchoolExternalToolService } from '../../school-external-tool'; import { schoolExternalToolFactory } from '../../school-external-tool/testing'; import { ToolLaunchService } from '../service'; -import { LaunchRequestMethod, ToolLaunchData, ToolLaunchDataType, ToolLaunchRequest } from '../types'; +import { LaunchRequestMethod, ToolLaunchRequest } from '../types'; import { ToolLaunchUc } from './tool-launch.uc'; describe('ToolLaunchUc', () => { @@ -83,23 +83,25 @@ describe('ToolLaunchUc', () => { const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ id: contextExternalToolId, }); - const toolLaunchData: ToolLaunchData = new ToolLaunchData({ - baseUrl: 'baseUrl', - type: ToolLaunchDataType.BASIC, + + const toolLaunchRequest: ToolLaunchRequest = new ToolLaunchRequest({ + url: 'baseUrl', + method: LaunchRequestMethod.GET, + payload: '', openNewTab: true, - properties: [], + isDeepLink: true, }); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); - toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockResolvedValueOnce(toolLaunchRequest); return { user, contextExternalToolId, contextExternalTool, - toolLaunchData, + toolLaunchRequest, }; }; @@ -123,22 +125,14 @@ describe('ToolLaunchUc', () => { expect(contextExternalToolService.findByIdOrFail).toHaveBeenCalledWith(contextExternalToolId); }); - it('should call service to get data', async () => { - const { user, contextExternalToolId, contextExternalTool } = setup(); - - await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); - - expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(user.id, contextExternalTool); - }); - it('should call service to generate launch request', async () => { - const { user, contextExternalToolId, toolLaunchData } = setup(); + const { user, contextExternalToolId, contextExternalTool, toolLaunchRequest } = setup(); - toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockResolvedValueOnce(toolLaunchRequest); await uc.getContextExternalToolLaunchRequest(user.id, contextExternalToolId); - expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(toolLaunchData); + expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(user.id, contextExternalTool); }); it('should return launch request', async () => { @@ -170,28 +164,23 @@ describe('ToolLaunchUc', () => { }, parameters: [], }; - const toolLaunchData: ToolLaunchData = new ToolLaunchData({ - baseUrl: 'baseUrl', - type: ToolLaunchDataType.BASIC, - openNewTab: true, - properties: [], - }); + const toolLaunchRequest = new ToolLaunchRequest({ openNewTab: true, method: LaunchRequestMethod.GET, + payload: '', url: 'https://mock.com/', + isDeepLink: false, }); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); - toolLaunchService.generateLaunchRequest.mockReturnValueOnce(toolLaunchRequest); + toolLaunchService.generateLaunchRequest.mockResolvedValueOnce(toolLaunchRequest); return { user, schoolExternalTool, contextExternalTool, - toolLaunchData, toolLaunchRequest, }; }; @@ -218,22 +207,14 @@ describe('ToolLaunchUc', () => { 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(); + const { user, contextExternalTool, toolLaunchRequest } = setup(); - toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); + toolLaunchService.generateLaunchRequest.mockResolvedValueOnce(toolLaunchRequest); await uc.getSchoolExternalToolLaunchRequest(user.id, contextExternalTool); - expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(toolLaunchData); + expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(user.id, contextExternalTool); }); it('should return launch request', async () => { diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts index 4f96c380554..cd0a35356cc 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 @@ -11,7 +11,7 @@ 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'; +import { ToolLaunchRequest } from '../types'; @Injectable() export class ToolLaunchUc { @@ -35,8 +35,10 @@ export class ToolLaunchUc { const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); - const toolLaunchData: ToolLaunchData = await this.toolLaunchService.getLaunchData(userId, contextExternalTool); - const launchRequest: ToolLaunchRequest = this.toolLaunchService.generateLaunchRequest(toolLaunchData); + const launchRequest: ToolLaunchRequest = await this.toolLaunchService.generateLaunchRequest( + userId, + contextExternalTool + ); return launchRequest; } @@ -66,12 +68,10 @@ export class ToolLaunchUc { await this.contextExternalToolService.checkContextRestrictions(pseudoContextExternalTool); - const toolLaunchData: ToolLaunchData = await this.toolLaunchService.getLaunchData( + const launchRequest: ToolLaunchRequest = await this.toolLaunchService.generateLaunchRequest( userId, pseudoContextExternalTool ); - const launchRequest: ToolLaunchRequest = this.toolLaunchService.generateLaunchRequest(toolLaunchData); - return launchRequest; } } diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index 25587ccdca2..3fe1439010c 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -1074,7 +1074,9 @@ "openNewTab": true, "version": 1, "isDeactivated": false, - "restrictToContexts": ["course"], + "restrictToContexts": [ + "course" + ], "isPreferred": true, "iconName": "mdiFileQuestionOutline" }, @@ -1097,8 +1099,36 @@ "openNewTab": true, "version": 1, "isDeactivated": false, - "restrictToContexts": ["board-element"], + "restrictToContexts": [ + "board-element" + ], "isPreferred": true, "iconName": "mdiEyeOutline" + }, + { + "_id": { + "$oid": "667e52a4162707ce02b9ac06" + }, + "createdAt": { + "$date": "2024-10-25T02:16:42.959Z" + }, + "updatedAt": { + "$date": "2024-10-25T02:16:42.959Z" + }, + "name": "Merlin Bibliothek", + "url": "https://nds.edupool.de", + "config_type": "lti11", + "config_baseUrl": "https://nds.edupool.de", + "config_key": "xvD0eMHxEPsKI198", + "config_lti_message_type": "ContentItemSelectionRequest", + "config_privacy_permission": "anonymous", + "config_launch_presentation_locale": "de-DE", + "parameters": [], + "isHidden": false, + "isDeactivated": false, + "openNewTab": false, + "restrictToContexts": [], + "isPreferred": true, + "iconName": "mdiMovieRoll" } ] diff --git a/backup/setup/school-external-tools.json b/backup/setup/school-external-tools.json index 0f87303d154..88402bf6544 100644 --- a/backup/setup/school-external-tools.json +++ b/backup/setup/school-external-tools.json @@ -118,5 +118,24 @@ }, "schoolParameters": [], "isDeactivated": false + }, + { + "_id": { + "$oid": "672b2dde7bb05b9a2ea92b07" + }, + "createdAt": { + "$date": "2024-10-25T02:29:56.005Z" + }, + "updatedAt": { + "$date": "2024-10-25T02:29:56.005Z" + }, + "tool": { + "$oid": "667e52a4162707ce02b9ac06" + }, + "school": { + "$oid": "5f2987e020834114b8efd6f8" + }, + "schoolParameters": [], + "isDeactivated": false } ]